feat(forge-core): author module for writing signed RVF containers

rvf-forge-core could verify containers but not produce them, so every
test and fixture had to hand-assemble bytes through testkit. The
author module makes writing a first-class operation: ContainerBuilder
assembles segments, computes per-segment digests, and emits a signed
root manifest that this crate's own verifier accepts.

Segment kind decides signing policy rather than the caller: a .wasm
payload becomes an executable WASM segment and is signed individually,
anything else becomes an opaque VEC segment. That keeps rule 3 of the
loading contract — unsigned executable segments are rejected by
default — a property of the writer, not something each caller has to
remember to ask for.

The parity fixture generator now builds its input through this module
instead of a bespoke byte layout, so the TypeScript and Rust sides are
compared against a shared definition of what a valid container is.

138 tests, clippy clean.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01ParP55bZs2iTGEGvpnUecx
This commit is contained in:
ruvnet 2026-08-05 10:23:34 -04:00
parent cbf9f6d7b6
commit 03cf165c4c
9 changed files with 1110 additions and 171 deletions

View file

@ -0,0 +1,439 @@
//! Authoring: build a valid RVF container from segments and sign it.
//!
//! Every other module in this crate reads an artifact someone else produced.
//! This one writes, and it is the only one that does. It exists because a
//! publisher who has just run `rvforge init --keygen` has a key, a config, and
//! nothing to sign: without a supported way to *make* an `.rvf`, the first
//! documented command after `init` cannot run.
//!
//! # What authoring is not
//!
//! Writing bytes is not running them. Core invariant 2 (ADR-283 §2) holds here
//! exactly as it does in [`crate::inspect`]: a payload handed to
//! [`ContainerBuilder::segment`] is copied, length-checked, and hashed. It is
//! never parsed as code, validated as WASM, linked, or executed, and that is
//! true whatever [`SegmentType`] the caller labels it with.
//!
//! # The shape produced
//!
//! ```text
//! [ META segment: capability declaration ] (when any are declared)
//! [ caller segments, in the order supplied ]
//! [ MANIFEST segment: the root manifest ]
//! [ 4096-byte Level-0 root manifest page ] <- at EOF, always
//! ```
//!
//! The trailing page is what makes the output a *file* rather than a bare
//! segment stream: `rvf-manifest` puts a fixed 4096-byte Level-0 root at EOF,
//! and readers discover an RVF from its tail. [`crate::container`] trims it
//! before walking.
//!
//! # Determinism
//!
//! Identical input yields identical bytes. Nothing here reads the clock or the
//! environment: segment timestamps and the root page's created/modified fields
//! are zero, segment ids are assigned by position, and the file id is supplied
//! by the caller rather than generated. Two publishers building the same agent
//! from the same inputs get the same artifact, so the SHA-256 that identifies
//! it is reproducible.
//!
//! # Example
//!
//! ```
//! use rvf_forge_core::{author::ContainerBuilder, inspect_bytes, CapabilityClass};
//!
//! let built = ContainerBuilder::new()
//! .declare_capability(CapabilityClass::Network)
//! .segment(rvf_forge_core::SegmentType::Vec, b"embeddings".to_vec())
//! .build()?;
//!
//! let inspection = inspect_bytes(&built.bytes)?;
//! assert_eq!(inspection.declared_capabilities, vec![CapabilityClass::Network]);
//! # Ok::<(), rvf_forge_core::ForgeError>(())
//! ```
mod root_manifest;
mod segment;
pub use root_manifest::{
root_manifest_page_of, root_manifest_signing_bytes, verify_root_manifest_signature,
};
use crate::capability::CapabilityClass;
use crate::error::{ForgeError, Result};
use crate::inspect::CAPABILITY_DECLARATION_KEY;
use ed25519_dalek::SigningKey;
use root_manifest::root_manifest_page;
use rvf_types::{SegmentType, MAX_SEGMENT_PAYLOAD};
pub(crate) use segment::write_segment_bytes;
use std::collections::BTreeSet;
/// Content-hash algorithm authored containers use: SHAKE-256 truncated to 128
/// bits (`checksum_algo` 2).
///
/// The format's default is XXH3-128, which is faster. SHAKE-256 is chosen here
/// because authoring is the one operation a *non-Rust* implementation has to
/// reproduce byte for byte — the TypeScript CLI writes the same containers —
/// and SHAKE-256 is reachable from any standard crypto library, where XXH3-128
/// would mean shipping a hand-ported hash on the other side. It is also the
/// cryptographic option, which is the right default for a signing-adjacent
/// tool.
pub const DEFAULT_CHECKSUM_ALGO: u8 = 2;
/// Byte length of an Ed25519 signature.
const ED25519_SIGNATURE_LENGTH: usize = 64;
/// A container produced by [`ContainerBuilder::build`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthoredContainer {
/// The complete file, ready to write to disk.
pub bytes: Vec<u8>,
/// The 16-byte file id recorded in the root manifest page.
pub file_id: [u8; 16],
/// How many segments were written, including the generated capability and
/// root-manifest segments.
pub segment_count: usize,
/// Whether a signing key was supplied, and so whether the root manifest
/// page and the signable segments carry signatures.
pub signed: bool,
}
/// Builds a valid RVF container.
///
/// The builder owns two format obligations the caller should not have to know
/// about: every container ends with a `MANIFEST` segment (verification refuses
/// one without a root manifest), and every container ends with a Level-0 root
/// manifest page.
#[derive(Default)]
pub struct ContainerBuilder {
capabilities: BTreeSet<CapabilityClass>,
segments: Vec<(SegmentType, Vec<u8>)>,
manifest_payload: Option<Vec<u8>>,
file_id: [u8; 16],
checksum_algo: Option<u8>,
signing_key: Option<SigningKey>,
}
impl ContainerBuilder {
/// A builder with no segments, no declared capabilities, and no key.
pub fn new() -> Self {
Self::default()
}
/// Set the 16-byte file id recorded in the root manifest page.
///
/// Taken from the caller rather than generated so that authoring stays a
/// pure function of its inputs.
pub fn file_id(mut self, file_id: [u8; 16]) -> Self {
self.file_id = file_id;
self
}
/// Declare one capability class in the container's `META` segment.
///
/// Declaring is not granting. The runtime is default-deny (ADR-286 §1);
/// this records what the artifact *asks* for, which is what
/// [`crate::inspect`] reports and what a reviewer reads.
pub fn declare_capability(mut self, class: CapabilityClass) -> Self {
self.capabilities.insert(class);
self
}
/// Declare several capability classes at once.
pub fn declare_capabilities(
mut self,
classes: impl IntoIterator<Item = CapabilityClass>,
) -> Self {
self.capabilities.extend(classes);
self
}
/// Append a segment. Payload bytes are stored, never interpreted.
pub fn segment(mut self, seg_type: SegmentType, payload: impl Into<Vec<u8>>) -> Self {
self.segments.push((seg_type, payload.into()));
self
}
/// Override the root `MANIFEST` segment's payload.
///
/// One is always written; this replaces its contents.
pub fn manifest_payload(mut self, payload: impl Into<Vec<u8>>) -> Self {
self.manifest_payload = Some(payload.into());
self
}
/// Use a specific `checksum_algo` instead of [`DEFAULT_CHECKSUM_ALGO`].
pub fn checksum_algo(mut self, algo: u8) -> Self {
self.checksum_algo = Some(algo);
self
}
/// Sign with `key`: the root manifest page, the `MANIFEST` segment, and
/// every executable segment get Ed25519 signatures.
///
/// Without a key the container is unsigned, which
/// [`crate::verify`] refuses under default options as soon as it holds an
/// executable segment (ADR-283 invariant 3).
pub fn sign_with(mut self, key: SigningKey) -> Self {
self.signing_key = Some(key);
self
}
/// Assemble the container.
///
/// # Errors
///
/// [`ForgeError::InvalidRvf`] if a payload exceeds [`MAX_SEGMENT_PAYLOAD`].
pub fn build(self) -> Result<AuthoredContainer> {
let algo = self.checksum_algo.unwrap_or(DEFAULT_CHECKSUM_ALGO);
let key = self.signing_key.as_ref();
let mut planned: Vec<(SegmentType, Vec<u8>)> = Vec::new();
if !self.capabilities.is_empty() {
planned.push((
SegmentType::Meta,
self.capability_declaration().into_bytes(),
));
}
planned.extend(self.segments);
planned.push((
SegmentType::Manifest,
self.manifest_payload.unwrap_or_else(|| b"root".to_vec()),
));
let segment_count = planned.len();
let mut body: Vec<u8> = Vec::new();
let mut manifest_span = (0usize, 0usize);
for (index, (seg_type, payload)) in planned.into_iter().enumerate() {
if payload.len() as u64 > MAX_SEGMENT_PAYLOAD {
return Err(ForgeError::invalid_rvf(format!(
"segment {index} carries {} payload bytes, over the \
{MAX_SEGMENT_PAYLOAD}-byte maximum",
payload.len()
)));
}
let signer = key.filter(|_| is_signable(seg_type));
let offset = body.len();
// Segment ids are 1-based and assigned by position, so the same
// inputs always produce the same ids.
let encoded = write_segment_bytes(seg_type, &payload, index as u64 + 1, algo, signer);
if seg_type == SegmentType::Manifest {
manifest_span = (offset, encoded.len());
}
body.extend_from_slice(&encoded);
}
let page = root_manifest_page(
&self.file_id,
manifest_span.0 as u64,
manifest_span.1 as u64,
key,
);
body.extend_from_slice(&page);
Ok(AuthoredContainer {
bytes: body,
file_id: self.file_id,
segment_count,
signed: key.is_some(),
})
}
/// The `META` payload line declaring the requested capability classes.
fn capability_declaration(&self) -> String {
let names: Vec<&str> = self.capabilities.iter().map(|c| c.as_str()).collect();
format!("{CAPABILITY_DECLARATION_KEY}={}\n", names.join(","))
}
}
/// Whether a segment of this type carries a signature footer when a key is
/// supplied.
///
/// Executable segments must be signed for a runtime to load them (ADR-283
/// invariant 3). The root manifest is signed because it is the thing every
/// other check is anchored to. Signing inert data segments as well would cost
/// 128 bytes each and prove nothing the root manifest signature does not.
fn is_signable(seg_type: SegmentType) -> bool {
matches!(
seg_type,
SegmentType::Kernel | SegmentType::Ebpf | SegmentType::Wasm | SegmentType::Manifest
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testkit::TestKeypair;
use crate::{inspect_bytes, verify_bytes, VerifyOptions};
use rvf_types::SEGMENT_HEADER_SIZE;
#[test]
fn a_minimal_authored_container_inspects_and_verifies() {
let built = ContainerBuilder::new().build().unwrap();
let inspection = inspect_bytes(&built.bytes).unwrap();
assert_eq!(inspection.segments.len(), 1);
assert_eq!(
inspection.root_manifest().unwrap().segment_type_name,
"manifest"
);
let report = verify_bytes(&built.bytes, &VerifyOptions::default()).unwrap();
assert!(report.ok, "{:?}", report.failures());
}
#[test]
fn declared_capabilities_round_trip_through_inspection() {
let built = ContainerBuilder::new()
.declare_capabilities([CapabilityClass::Network, CapabilityClass::Filesystem])
.build()
.unwrap();
let inspection = inspect_bytes(&built.bytes).unwrap();
assert_eq!(
inspection.declared_capabilities,
vec![CapabilityClass::Filesystem, CapabilityClass::Network]
);
}
#[test]
fn an_unsigned_executable_is_refused_by_default_and_allowed_by_opt_in() {
let built = ContainerBuilder::new()
.segment(SegmentType::Wasm, b"\0asm\x01\0\0\0".to_vec())
.build()
.unwrap();
let inspection = inspect_bytes(&built.bytes).unwrap();
assert_eq!(inspection.executable_segment_count, 1);
assert!(!inspection.all_executables_signed());
let strict = verify_bytes(&built.bytes, &VerifyOptions::default()).unwrap();
assert!(!strict.ok);
let permissive = verify_bytes(
&built.bytes,
&VerifyOptions {
allow_unsigned_executable: true,
..VerifyOptions::default()
},
)
.unwrap();
assert!(permissive.ok, "{:?}", permissive.failures());
}
#[test]
fn a_signed_executable_verifies_against_the_signing_key() {
let kp = TestKeypair::deterministic(11);
let built = ContainerBuilder::new()
.segment(SegmentType::Wasm, b"\0asm\x01\0\0\0".to_vec())
.sign_with(kp.signing.clone())
.build()
.unwrap();
assert!(built.signed);
assert!(inspect_bytes(&built.bytes)
.unwrap()
.all_executables_signed());
let report = verify_bytes(
&built.bytes,
&VerifyOptions::with_trusted_keys(vec![kp.public]),
)
.unwrap();
assert!(report.ok, "{:?}", report.failures());
}
#[test]
fn tampering_with_any_byte_breaks_verification() {
let kp = TestKeypair::deterministic(14);
let built = ContainerBuilder::new()
.segment(SegmentType::Wasm, b"\0asm\x01\0\0\0".to_vec())
.sign_with(kp.signing.clone())
.build()
.unwrap();
let mut tampered = built.bytes.clone();
// Flip a byte inside the WASM payload, past the first segment header.
let target = SEGMENT_HEADER_SIZE + 4;
tampered[target] ^= 0xff;
let report = verify_bytes(
&tampered,
&VerifyOptions::with_trusted_keys(vec![kp.public]),
)
.unwrap();
assert!(!report.ok);
}
#[test]
fn identical_input_yields_identical_bytes() {
let build = || {
ContainerBuilder::new()
.file_id([7u8; 16])
.declare_capability(CapabilityClass::Network)
.segment(SegmentType::Vec, b"payload".to_vec())
.sign_with(TestKeypair::deterministic(3).signing)
.build()
.unwrap()
};
assert_eq!(build(), build());
}
#[test]
fn capability_order_does_not_change_the_bytes() {
let one = ContainerBuilder::new()
.declare_capabilities([CapabilityClass::Network, CapabilityClass::Clock])
.build()
.unwrap();
let two = ContainerBuilder::new()
.declare_capabilities([CapabilityClass::Clock, CapabilityClass::Network])
.build()
.unwrap();
assert_eq!(one.bytes, two.bytes);
}
#[test]
fn segments_appear_in_the_order_supplied_with_the_manifest_last() {
let built = ContainerBuilder::new()
.declare_capability(CapabilityClass::Network)
.segment(SegmentType::Vec, b"a".to_vec())
.segment(SegmentType::Index, b"b".to_vec())
.build()
.unwrap();
let names: Vec<String> = inspect_bytes(&built.bytes)
.unwrap()
.segments
.into_iter()
.map(|s| s.segment_type_name)
.collect();
assert_eq!(names, ["meta", "vec", "index", "manifest"]);
assert_eq!(built.segment_count, 4);
}
#[test]
fn an_empty_payload_is_legal_and_round_trips() {
let built = ContainerBuilder::new()
.segment(SegmentType::Vec, Vec::new())
.build()
.unwrap();
let segments = inspect_bytes(&built.bytes).unwrap().segments;
assert_eq!(segments[0].payload_length, 0);
assert!(
verify_bytes(&built.bytes, &VerifyOptions::default())
.unwrap()
.ok
);
}
#[test]
fn authored_containers_use_shake256_content_hashes() {
let built = ContainerBuilder::new()
.segment(SegmentType::Vec, b"hash me".to_vec())
.build()
.unwrap();
let vec_segment = &inspect_bytes(&built.bytes).unwrap().segments[0];
assert_eq!(vec_segment.checksum_algo, DEFAULT_CHECKSUM_ALGO);
assert_eq!(
vec_segment.content_hash,
crate::hex_lower(&rvf_wire::hash::compute_shake256_128(b"hash me"))
);
}
}

View file

@ -0,0 +1,234 @@
//! The Level-0 root manifest page that closes every authored container.
//!
//! An RVF file is discovered from its tail: the last 4096 bytes are a fixed
//! Level-0 root manifest (`rvf-manifest/src/level0.rs`), carrying `RVM0` rather
//! than the segment magic. It is not a segment, and [`crate::container`] trims
//! it before walking the segment stream.
//!
//! Writing that page is what makes authored output a *file* rather than a bare
//! segment stream, and it is where the container-level signature lives.
use super::{AuthoredContainer, ED25519_SIGNATURE_LENGTH};
use ed25519_dalek::SigningKey;
use rvf_types::{ROOT_MANIFEST_MAGIC, ROOT_MANIFEST_SIZE};
/// Ed25519, as the Level-0 root manifest's `sig_algo` field names it.
///
/// Not the same value a *segment* signature footer uses for Ed25519, which is
/// 0 (`rvf-crypto/src/sign.rs`). The two structures carry different algorithm
/// enumerations; each writer has to use its own.
pub(super) const SIG_ALGO_ED25519: u16 = 1;
/// Level-0 root manifest field offsets (`rvf-manifest/src/level0.rs`).
mod l0 {
pub(super) const VERSION: usize = 0x004;
pub(super) const L1_OFFSET: usize = 0x008;
pub(super) const L1_LENGTH: usize = 0x010;
pub(super) const DTYPE: usize = 0x022;
pub(super) const EPOCH: usize = 0x024;
pub(super) const SIG_ALGO: usize = 0x098;
pub(super) const SIG_LEN: usize = 0x09A;
pub(super) const SIGNATURE: usize = 0x09C;
pub(super) const FILE_ID: usize = 0xF00;
pub(super) const CHECKSUM: usize = 0xFFC;
}
/// Build the 4096-byte Level-0 root manifest page.
pub(super) fn root_manifest_page(
file_id: &[u8; 16],
l1_offset: u64,
l1_length: u64,
key: Option<&SigningKey>,
) -> [u8; ROOT_MANIFEST_SIZE] {
let mut page = [0u8; ROOT_MANIFEST_SIZE];
page[0x00..0x04].copy_from_slice(&ROOT_MANIFEST_MAGIC.to_le_bytes());
page[l0::VERSION..l0::VERSION + 2].copy_from_slice(&1u16.to_le_bytes());
page[l0::L1_OFFSET..l0::L1_OFFSET + 8].copy_from_slice(&l1_offset.to_le_bytes());
page[l0::L1_LENGTH..l0::L1_LENGTH + 8].copy_from_slice(&l1_length.to_le_bytes());
// total_vector_count and dimension stay zero: an agent container declares
// no vectors, and a zero dimension is only invalid alongside a non-zero
// vector count.
page[l0::DTYPE] = 1; // f32
page[l0::EPOCH..l0::EPOCH + 4].copy_from_slice(&1u32.to_le_bytes());
// created_ns and modified_ns stay zero, for reproducibility.
page[l0::FILE_ID..l0::FILE_ID + 16].copy_from_slice(file_id);
if let Some(key) = key {
use ed25519_dalek::Signer;
// The algorithm and length fields are written *before* signing so the
// signature covers them. Leaving them out would let an attacker
// rewrite `sig_algo` to a weaker scheme without invalidating anything.
page[l0::SIG_ALGO..l0::SIG_ALGO + 2].copy_from_slice(&SIG_ALGO_ED25519.to_le_bytes());
page[l0::SIG_LEN..l0::SIG_LEN + 2]
.copy_from_slice(&(ED25519_SIGNATURE_LENGTH as u16).to_le_bytes());
let signature = key.sign(&root_manifest_signing_bytes(&page)).to_bytes();
page[l0::SIGNATURE..l0::SIGNATURE + ED25519_SIGNATURE_LENGTH].copy_from_slice(&signature);
}
let checksum = rvf_wire::hash::compute_crc32c(&page[..l0::CHECKSUM]);
page[l0::CHECKSUM..].copy_from_slice(&checksum.to_le_bytes());
page
}
/// The bytes a root manifest page's signature covers: the whole page with the
/// signature region and the trailing CRC32C zeroed.
///
/// Both regions have to be excluded because both are written *after* signing.
/// Zeroing rather than skipping keeps the signed length fixed at 4096, so a
/// verifier reconstructs the input without knowing any field order.
pub fn root_manifest_signing_bytes(page: &[u8; ROOT_MANIFEST_SIZE]) -> [u8; ROOT_MANIFEST_SIZE] {
let mut signable = *page;
signable[l0::SIGNATURE..l0::SIGNATURE + ED25519_SIGNATURE_LENGTH].fill(0);
signable[l0::CHECKSUM..].fill(0);
signable
}
/// Verify the Ed25519 signature on a container's root manifest page.
///
/// Returns `false` when the page is unsigned, names an algorithm other than
/// Ed25519, or does not verify against `public_key`.
pub fn verify_root_manifest_signature(
page: &[u8; ROOT_MANIFEST_SIZE],
public_key: &[u8; 32],
) -> bool {
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
let algo = u16::from_le_bytes([page[l0::SIG_ALGO], page[l0::SIG_ALGO + 1]]);
let len = u16::from_le_bytes([page[l0::SIG_LEN], page[l0::SIG_LEN + 1]]);
if algo != SIG_ALGO_ED25519 || len as usize != ED25519_SIGNATURE_LENGTH {
return false;
}
let Ok(verifying) = VerifyingKey::from_bytes(public_key) else {
return false;
};
let mut raw = [0u8; ED25519_SIGNATURE_LENGTH];
raw.copy_from_slice(&page[l0::SIGNATURE..l0::SIGNATURE + ED25519_SIGNATURE_LENGTH]);
verifying
.verify(
&root_manifest_signing_bytes(page),
&Signature::from_bytes(&raw),
)
.is_ok()
}
/// Borrow the trailing Level-0 root manifest page of an authored container.
///
/// Returns `None` when `data` does not end in one.
pub fn root_manifest_page_of(data: &[u8]) -> Option<&[u8; ROOT_MANIFEST_SIZE]> {
let (_, page) = crate::container::split_root_manifest(data);
page.map(|range| {
data[range]
.try_into()
.expect("split_root_manifest returns a 4096-byte range")
})
}
impl AuthoredContainer {
/// The container's Level-0 root manifest page.
pub fn root_manifest_page(&self) -> &[u8; ROOT_MANIFEST_SIZE] {
root_manifest_page_of(&self.bytes).expect("an authored container always ends in a page")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::author::ContainerBuilder;
use crate::inspect_bytes;
use crate::testkit::TestKeypair;
use crate::SegmentType;
use rvf_types::SEGMENT_ALIGNMENT;
#[test]
fn the_page_closes_the_file_and_checksums_itself() {
let built = ContainerBuilder::new().build().unwrap();
assert_eq!(built.bytes.len() % SEGMENT_ALIGNMENT, 0);
let page = built.root_manifest_page();
assert_eq!(page[..4], ROOT_MANIFEST_MAGIC.to_le_bytes());
let stored = u32::from_le_bytes(page[l0::CHECKSUM..].try_into().unwrap());
assert_eq!(
stored,
rvf_wire::hash::compute_crc32c(&page[..l0::CHECKSUM])
);
}
#[test]
fn the_signature_verifies_and_only_against_the_signing_key() {
let kp = TestKeypair::deterministic(12);
let built = ContainerBuilder::new()
.sign_with(kp.signing.clone())
.build()
.unwrap();
assert!(verify_root_manifest_signature(
built.root_manifest_page(),
&kp.public
));
assert!(!verify_root_manifest_signature(
built.root_manifest_page(),
&TestKeypair::deterministic(13).public
));
}
#[test]
fn rewriting_a_signed_field_breaks_the_signature() {
let kp = TestKeypair::deterministic(15);
let built = ContainerBuilder::new()
.sign_with(kp.signing.clone())
.build()
.unwrap();
let mut page = *built.root_manifest_page();
page[l0::VERSION] = 7;
assert!(!verify_root_manifest_signature(&page, &kp.public));
// The algorithm field is covered too, so downgrading it is caught.
let mut downgraded = *built.root_manifest_page();
downgraded[l0::SIG_ALGO] = 9;
assert!(!verify_root_manifest_signature(&downgraded, &kp.public));
}
#[test]
fn an_unsigned_page_carries_no_signature() {
let built = ContainerBuilder::new().build().unwrap();
assert!(!built.signed);
assert!(!verify_root_manifest_signature(
built.root_manifest_page(),
&TestKeypair::deterministic(1).public
));
}
#[test]
fn the_file_id_reaches_the_page() {
let built = ContainerBuilder::new().file_id([0xab; 16]).build().unwrap();
assert_eq!(
&built.root_manifest_page()[l0::FILE_ID..l0::FILE_ID + 16],
&[0xab; 16]
);
}
#[test]
fn the_l1_pointer_locates_the_manifest_segment() {
let built = ContainerBuilder::new()
.segment(SegmentType::Vec, vec![0u8; 100])
.build()
.unwrap();
let page = built.root_manifest_page();
let offset = u64::from_le_bytes(page[l0::L1_OFFSET..l0::L1_OFFSET + 8].try_into().unwrap());
let length = u64::from_le_bytes(page[l0::L1_LENGTH..l0::L1_LENGTH + 8].try_into().unwrap());
assert!(length > 0, "an empty L1 pointer would warn on validation");
assert!(offset + length <= built.bytes.len() as u64);
assert_eq!(
inspect_bytes(&built.bytes).unwrap().root_manifest_offset,
Some(offset)
);
}
#[test]
fn a_bare_segment_stream_has_no_page() {
let stream = crate::testkit::minimal_container("network");
assert!(root_manifest_page_of(&stream).is_none());
}
}

View file

@ -0,0 +1,85 @@
//! Segment serialization: the 64-byte header, the payload, and the optional
//! Ed25519 signature footer that follows it.
//!
//! This is the single place the segment layout is written. [`crate::testkit`]
//! calls it too, so fixtures cannot drift from authored output.
use super::ED25519_SIGNATURE_LENGTH;
use ed25519_dalek::SigningKey;
use rvf_types::{
SegmentFlags, SegmentType, SEGMENT_ALIGNMENT, SEGMENT_HEADER_SIZE, SEGMENT_VERSION,
};
/// Serialize one complete segment: header, payload, optional signature footer,
/// zero padding to the next 64-byte boundary.
///
/// This is the single place the segment layout is written. [`crate::testkit`]
/// calls it too, so fixtures cannot drift from authored output.
pub(crate) fn write_segment_bytes(
seg_type: SegmentType,
payload: &[u8],
segment_id: u64,
checksum_algo: u8,
key: Option<&SigningKey>,
) -> Vec<u8> {
let footer_len = key
.map(|_| {
rvf_types::SignatureFooter::compute_footer_length(ED25519_SIGNATURE_LENGTH as u16)
as usize
})
.unwrap_or(0);
let unpadded = SEGMENT_HEADER_SIZE + payload.len() + footer_len;
let pad = unpadded.div_ceil(SEGMENT_ALIGNMENT) * SEGMENT_ALIGNMENT - unpadded;
let flags = match key {
Some(_) => SegmentFlags::empty().with(SegmentFlags::SIGNED),
None => SegmentFlags::empty(),
};
let head = segment_header(
seg_type as u8,
payload,
flags,
segment_id,
checksum_algo,
pad as u32,
);
let mut out = Vec::with_capacity(unpadded + pad);
out.extend_from_slice(&head);
out.extend_from_slice(payload);
if let Some(key) = key {
// `read_segment_header` cannot fail on bytes we just serialized; the
// signature covers the header as the verifier will re-read it.
let header = rvf_wire::read_segment_header(&head)
.expect("author serialized a well-formed segment header");
let footer = rvf_crypto::sign_segment(&header, payload, key);
out.extend_from_slice(&rvf_crypto::encode_signature_footer(&footer));
}
out.resize(unpadded + pad, 0);
out
}
/// Serialize a 64-byte segment header.
fn segment_header(
seg_type: u8,
payload: &[u8],
flags: SegmentFlags,
segment_id: u64,
checksum_algo: u8,
alignment_pad: u32,
) -> [u8; SEGMENT_HEADER_SIZE] {
let content_hash = rvf_wire::hash::compute_content_hash(checksum_algo, payload);
let mut b = [0u8; SEGMENT_HEADER_SIZE];
b[0x00..0x04].copy_from_slice(&rvf_types::SEGMENT_MAGIC.to_le_bytes());
b[0x04] = SEGMENT_VERSION;
b[0x05] = seg_type;
b[0x06..0x08].copy_from_slice(&flags.bits().to_le_bytes());
b[0x08..0x10].copy_from_slice(&segment_id.to_le_bytes());
b[0x10..0x18].copy_from_slice(&(payload.len() as u64).to_le_bytes());
// timestamp_ns stays zero: authored containers must be byte-reproducible.
b[0x20] = checksum_algo;
b[0x28..0x38].copy_from_slice(&content_hash);
b[0x3C..0x40].copy_from_slice(&alignment_pad.to_le_bytes());
b
}

View file

@ -0,0 +1,158 @@
//! Cross-implementation check: does the Rust crate accept an `.rvf` the
//! TypeScript CLI wrote?
//!
//! `npm/packages/rvforge` writes RVF containers; `rvf-forge-core` reads them.
//! Both implement the same wire format, and they were written independently —
//! so "both pass their own tests" says nothing about whether they interoperate.
//! This binary closes that gap the same way `rvforge-registry-check` does for
//! the registry: it takes a file the CLI produced and runs the crate's own
//! public API over it, with no fixtures and no shared code.
//!
//! Usage: `rvforge-rvf-check <agent.rvf> [--public-key <hex>] [--allow-unsigned]`
//!
//! With `--public-key`, segment signatures are checked against that key rather
//! than skipped, so the run proves the CLI's Ed25519 signatures verify under
//! this crate's verifier — not merely that they are well-formed.
//!
//! Exits 0 when inspection and verification both succeed, 1 otherwise.
use rvf_forge_core::{inspect_bytes, verify_bytes, VerifyOptions};
use std::process::ExitCode;
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
match run(&args) {
Ok(()) => {
println!("\nRVF PARITY OK — rvf-forge-core accepts the container the CLI wrote");
ExitCode::SUCCESS
}
Err(message) => {
eprintln!("\nRVF PARITY FAILED — {message}");
ExitCode::FAILURE
}
}
}
fn run(args: &[String]) -> Result<(), String> {
let mut path: Option<&str> = None;
let mut public_key: Option<[u8; 32]> = None;
let mut allow_unsigned = false;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--public-key" => {
let hex = args.get(i + 1).ok_or("--public-key needs a hex value")?;
public_key = Some(parse_hex_key(hex)?);
i += 2;
}
"--allow-unsigned" => {
allow_unsigned = true;
i += 1;
}
other if other.starts_with('-') => return Err(format!("unknown flag {other}")),
other => {
path = Some(other);
i += 1;
}
}
}
let path = path.ok_or("usage: rvforge-rvf-check <agent.rvf> [--public-key <hex>]")?;
let data = std::fs::read(path).map_err(|e| format!("cannot read {path}: {e}"))?;
let inspection = inspect_bytes(&data).map_err(|e| format!("inspection refused it: {e}"))?;
println!("inspected {path}");
println!(" identity {}", inspection.rvf_identity);
println!(" bytes {}", inspection.byte_length);
println!(" segments {}", inspection.segments.len());
for segment in &inspection.segments {
println!(
" {:<10} id={} payload={} signed={} checksum_algo={}",
segment.segment_type_name,
segment.segment_id,
segment.payload_length,
segment.signed,
segment.checksum_algo,
);
}
println!(
" capabilities {}",
if inspection.declared_capabilities.is_empty() {
"none declared (default-deny)".to_string()
} else {
inspection
.declared_capabilities
.iter()
.map(|c| c.to_string())
.collect::<Vec<_>>()
.join(", ")
}
);
let options = VerifyOptions {
trusted_keys: public_key.into_iter().collect(),
allow_unsigned_executable: allow_unsigned,
expected_identity: Some(inspection.rvf_identity.clone()),
};
let report =
verify_bytes(&data, &options).map_err(|e| format!("verification refused it: {e}"))?;
println!("\nverified {path}");
for record in &report.records {
println!(
" {:?} {:?}: {}",
record.check, record.outcome, record.detail
);
}
if !report.ok {
return Err(format!(
"{} check(s) failed: {}",
report.failures().len(),
report
.failures()
.iter()
.map(|r| r.detail.clone())
.collect::<Vec<_>>()
.join("; ")
));
}
// A skipped signature check is not a passed one. When the caller supplied a
// key, at least one signature must actually have been verified against it,
// or the run proves nothing about the CLI's signing.
if public_key.is_some() {
let verified = report
.records
.iter()
.filter(|r| {
r.check == rvf_forge_core::CheckKind::Signature
&& r.outcome == rvf_forge_core::Outcome::Pass
})
.count();
if verified == 0 {
return Err(
"a public key was supplied but no segment signature verified against it".into(),
);
}
println!("\n {verified} segment signature(s) verified against the supplied key");
}
Ok(())
}
fn parse_hex_key(hex: &str) -> Result<[u8; 32], String> {
let hex = hex.trim();
if hex.len() != 64 {
return Err(format!(
"a public key is 32 bytes / 64 hex characters, got {}",
hex.len()
));
}
let mut out = [0u8; 32];
for (i, byte) in out.iter_mut().enumerate() {
*byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16)
.map_err(|_| "public key is not valid hex".to_string())?;
}
Ok(out)
}

View file

@ -16,11 +16,19 @@
//! The footer is present exactly when the header's `SIGNED` flag is set, and
//! its own trailing `footer_length` field gives its size. The next segment
//! begins at the next 64-byte boundary.
//!
//! A complete RVF file is that segment stream followed by a fixed 4096-byte
//! Level-0 root manifest page at EOF (`rvf-manifest/src/level0.rs`), which is
//! not itself a segment and carries `RVM0` rather than the segment magic. The
//! walk therefore trims that page before it starts; see
//! [`split_root_manifest`]. Containers that are a bare segment stream — the
//! shape [`crate::testkit`] built before authoring existed — have no such page
//! and are walked whole.
use crate::error::{ForgeError, Result};
use rvf_types::{
SegmentFlags, SegmentHeader, SegmentType, MAX_SEGMENT_PAYLOAD, SEGMENT_ALIGNMENT,
SEGMENT_HEADER_SIZE,
SegmentFlags, SegmentHeader, SegmentType, MAX_SEGMENT_PAYLOAD, ROOT_MANIFEST_MAGIC,
ROOT_MANIFEST_SIZE, SEGMENT_ALIGNMENT, SEGMENT_HEADER_SIZE,
};
use std::ops::Range;
@ -80,14 +88,51 @@ impl ParsedSegment {
}
}
/// Split a container into its segment area and its trailing Level-0 root
/// manifest page.
///
/// The page is identified by its `RVM0` magic at exactly `len - 4096`, which
/// is the only place the format permits it. Recognising it by magic rather
/// than by size alone means a bare segment stream that happens to be at least
/// 4096 bytes long is still walked whole.
pub(crate) fn split_root_manifest(data: &[u8]) -> (&[u8], Option<Range<usize>>) {
// Require room for at least one segment ahead of the page: a file that is
// nothing but a root manifest has no segments, and reporting that as
// "holds no segments" is clearer than reporting a bad magic.
if data.len() < ROOT_MANIFEST_SIZE + SEGMENT_HEADER_SIZE {
return (data, None);
}
let start = data.len() - ROOT_MANIFEST_SIZE;
let magic = u32::from_le_bytes([
data[start],
data[start + 1],
data[start + 2],
data[start + 3],
]);
if magic == ROOT_MANIFEST_MAGIC {
(&data[..start], Some(start..data.len()))
} else {
(data, None)
}
}
/// Walk `data` and return every segment in stream order.
///
/// A trailing Level-0 root manifest page is trimmed first and is not reported
/// as a segment, because it is not one.
///
/// # Errors
///
/// [`ForgeError::InvalidRvf`] if the stream is empty, truncated, declares an
/// implausible payload length, carries a malformed signature footer, or has
/// trailing bytes that are not a segment.
pub(crate) fn walk(data: &[u8]) -> Result<Vec<ParsedSegment>> {
let (body, _) = split_root_manifest(data);
walk_segments(body)
}
/// Walk a segment area that has already had any root manifest page trimmed.
fn walk_segments(data: &[u8]) -> Result<Vec<ParsedSegment>> {
if data.len() < SEGMENT_HEADER_SIZE {
return Err(ForgeError::invalid_rvf(format!(
"container is {} bytes, shorter than one {SEGMENT_HEADER_SIZE}-byte segment header",
@ -320,6 +365,48 @@ mod tests {
assert!(err.to_string().contains("trailing"), "{err}");
}
#[test]
fn a_trailing_root_manifest_page_is_trimmed_not_walked() {
let built = crate::author::ContainerBuilder::new()
.segment(SegmentType::Vec, b"vectors".to_vec())
.build()
.unwrap();
let (body, page) = split_root_manifest(&built.bytes);
assert_eq!(
page,
Some(built.bytes.len() - ROOT_MANIFEST_SIZE..built.bytes.len())
);
assert_eq!(body.len(), built.bytes.len() - ROOT_MANIFEST_SIZE);
// The page is not reported as a segment.
let segs = walk(&built.bytes).unwrap();
assert_eq!(segs.len(), 2);
assert_eq!(segs[1].header.seg_type, SegmentType::Manifest as u8);
}
#[test]
fn a_bare_segment_stream_has_no_root_manifest_page() {
let data = write_segment(
SegmentType::Meta as u8,
&[0u8; 8192],
SegmentFlags::empty(),
1,
);
let (body, page) = split_root_manifest(&data);
assert_eq!(page, None);
assert_eq!(body.len(), data.len());
assert_eq!(walk(&data).unwrap().len(), 1);
}
#[test]
fn trailing_bytes_that_are_not_a_root_manifest_are_still_rejected() {
let mut data = write_segment(SegmentType::Meta as u8, b"x", SegmentFlags::empty(), 1);
data.extend(std::iter::repeat_n(0xEE, ROOT_MANIFEST_SIZE));
let err = walk(&data).unwrap_err();
assert_eq!(err.code(), crate::ErrorCode::InvalidRvf);
}
#[test]
fn executable_types_are_kernel_ebpf_and_wasm() {
assert!(is_executable(SegmentType::Wasm as u8));

View file

@ -1,20 +1,25 @@
//! RVForge packaging and verification library (ADR-283, ADR-290, ADR-291).
//!
//! One canonical `.rvf` becomes installable packages for Windows, macOS,
//! Linux, and RVM. This crate is the part of that pipeline that reads,
//! verifies, and describes the artifact; it does not build installers, and it
//! never runs the agent inside one.
//! Linux, and RVM. This crate is the part of that pipeline that authors,
//! reads, verifies, and describes the artifact; it does not build installers,
//! and it never runs the agent inside one.
//!
//! # The five operations
//! # The six operations
//!
//! | Function | Produces | Purpose |
//! |---|---|---|
//! | [`ContainerBuilder`] | [`AuthoredContainer`] | A valid, signed container built from segments |
//! | [`inspect`] | [`ForgeInspection`] | Identity, segment inventory, declared capabilities, signature metadata |
//! | [`verify`] | [`VerificationReport`] | Root-manifest and per-segment checks, one witness record each |
//! | [`build_manifest`] | [`CanonicalBuildManifest`] | The deterministic, canonical-JSON build request |
//! | [`provenance`] | [`ProvenanceRecord`] | The trace from output back to input, runtime, revision, builder, and signer |
//! | [`checksums`] | [`Sha256Manifest`] | SHA-256 of every produced artifact |
//!
//! Authoring is the newest of the six and the only one that writes. It exists
//! because the rest of the pipeline consumes an artifact that, until it was
//! added, nothing in the toolchain could produce; see [`author`].
//!
//! # What this crate will not do
//!
//! Three refusals are load-bearing rather than incidental, and each traces to
@ -23,7 +28,8 @@
//! - **Nothing here executes an RVF.** Build workers must never run the
//! submitted artifact (ADR-283 invariant 2, ADR-290 §1). Inspection reads
//! 64-byte segment headers and byte ranges; the only time an executable
//! segment's payload is touched at all is to hash it.
//! segment's payload is touched at all is to hash it. Authoring is held to
//! the same line: it writes payload bytes without interpreting them.
//! - **Unsigned executable segments are refused by default.** ADR-283
//! invariant 3 and the RVM loading requirements §4.3. The single opt-out,
//! [`VerifyOptions::allow_unsigned_executable`], exists for the unsigned
@ -92,6 +98,7 @@
#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod author;
pub mod canonical;
pub mod capability;
pub mod checksums;
@ -104,6 +111,7 @@ pub mod target;
pub mod testkit;
pub mod verify;
pub use author::{AuthoredContainer, ContainerBuilder, DEFAULT_CHECKSUM_ALGO};
pub use canonical::{canonical_sha256_hex, hex_lower, to_canonical_json};
pub use capability::{CapabilityClass, CapabilityGrant, CapabilityPolicy};
pub use checksums::{
@ -124,6 +132,9 @@ pub use provenance::{
provenance, BuilderIdentity, BuilderKind, ProvenanceInputs, ProvenanceRecord, SigningIdentity,
SigningPosture, PROVENANCE_SCHEMA,
};
/// The segment type discriminator, re-exported so callers of
/// [`author::ContainerBuilder`] need not depend on `rvf-types` directly.
pub use rvf_types::SegmentType;
pub use target::Target;
pub use verify::{
verify, verify_bytes, CheckKind, Outcome, VerificationRecord, VerificationReport,

View file

@ -1,18 +1,31 @@
//! Synthetic RVF construction, for tests and fixtures.
//!
//! Forge never writes RVF files in production — it consumes them — so nothing
//! here is part of the packaging path. It exists so that a test can build a
//! container byte by byte and then tamper with it, which is the only way to
//! test that verification actually refuses a tampered artifact.
//! Production authoring lives in [`crate::author`]; this module is the
//! tamper-friendly counterpart. It builds *partial* containers — a lone
//! segment, a segment stream with no root manifest page — so that a test can
//! corrupt one field and confirm verification refuses the result. `author`
//! deliberately cannot produce those shapes, which is why both exist.
//!
//! The segment layout written here is the one [`crate::container`] reads:
//! The segment encoder is shared rather than reimplemented: everything here
//! calls [`crate::author::write_segment_bytes`], so a change to the wire layout
//! cannot leave fixtures describing a format nothing else writes.
//!
//! The segment layout is the one [`crate::container`] reads:
//!
//! ```text
//! [ 64-byte header ][ payload ][ signature footer, if SIGNED ][ zero pad to 64 ]
//! ```
use crate::author::write_segment_bytes;
use ed25519_dalek::SigningKey;
use rvf_types::{SegmentFlags, SegmentType, SEGMENT_ALIGNMENT, SEGMENT_HEADER_SIZE};
use rvf_types::SegmentType;
/// Content-hash algorithm the fixtures use: XXH3-128, the format default.
///
/// Authored containers use SHAKE-256 instead (see
/// [`crate::author::DEFAULT_CHECKSUM_ALGO`]). Fixtures keep the default so
/// that the reader is exercised against both algorithms.
const FIXTURE_CHECKSUM_ALGO: u8 = 1;
/// An Ed25519 keypair derived deterministically from a seed, so that a test
/// that signs a fixture produces the same bytes on every run.
@ -33,52 +46,9 @@ impl TestKeypair {
}
}
/// Serialize a 64-byte segment header.
fn header_bytes(
seg_type: u8,
payload: &[u8],
flags: SegmentFlags,
segment_id: u64,
alignment_pad: u32,
) -> [u8; SEGMENT_HEADER_SIZE] {
const CHECKSUM_ALGO: u8 = 1; // XXH3-128, the format default.
let content_hash = rvf_wire::hash::compute_content_hash(CHECKSUM_ALGO, payload);
let mut b = [0u8; SEGMENT_HEADER_SIZE];
b[0x00..0x04].copy_from_slice(&rvf_types::SEGMENT_MAGIC.to_le_bytes());
b[0x04] = rvf_types::SEGMENT_VERSION;
b[0x05] = seg_type;
b[0x06..0x08].copy_from_slice(&flags.bits().to_le_bytes());
b[0x08..0x10].copy_from_slice(&segment_id.to_le_bytes());
b[0x10..0x18].copy_from_slice(&(payload.len() as u64).to_le_bytes());
// timestamp_ns stays zero: fixtures must be byte-reproducible.
b[0x20] = CHECKSUM_ALGO;
b[0x28..0x38].copy_from_slice(&content_hash);
b[0x3C..0x40].copy_from_slice(&alignment_pad.to_le_bytes());
b
}
fn pad_to_alignment(buf: &mut Vec<u8>) {
let padded = buf.len().div_ceil(SEGMENT_ALIGNMENT) * SEGMENT_ALIGNMENT;
buf.resize(padded, 0);
}
/// Build an unsigned segment.
pub fn unsigned_segment(seg_type: SegmentType, payload: &[u8], segment_id: u64) -> Vec<u8> {
let unpadded = SEGMENT_HEADER_SIZE + payload.len();
let pad = unpadded.div_ceil(SEGMENT_ALIGNMENT) * SEGMENT_ALIGNMENT - unpadded;
let mut out = Vec::new();
out.extend_from_slice(&header_bytes(
seg_type as u8,
payload,
SegmentFlags::empty(),
segment_id,
pad as u32,
));
out.extend_from_slice(payload);
pad_to_alignment(&mut out);
out
write_segment_bytes(seg_type, payload, segment_id, FIXTURE_CHECKSUM_ALGO, None)
}
/// Build a segment signed with `keypair`, carrying an Ed25519 signature footer.
@ -88,21 +58,13 @@ pub fn signed_segment(
segment_id: u64,
keypair: &TestKeypair,
) -> Vec<u8> {
let flags = SegmentFlags::empty().with(SegmentFlags::SIGNED);
let footer_len = rvf_types::SignatureFooter::compute_footer_length(64) as usize;
let unpadded = SEGMENT_HEADER_SIZE + payload.len() + footer_len;
let pad = unpadded.div_ceil(SEGMENT_ALIGNMENT) * SEGMENT_ALIGNMENT - unpadded;
let head = header_bytes(seg_type as u8, payload, flags, segment_id, pad as u32);
let header = rvf_wire::read_segment_header(&head).expect("testkit built a valid header");
let footer = rvf_crypto::sign_segment(&header, payload, &keypair.signing);
let mut out = Vec::new();
out.extend_from_slice(&head);
out.extend_from_slice(payload);
out.extend_from_slice(&rvf_crypto::encode_signature_footer(&footer));
pad_to_alignment(&mut out);
out
write_segment_bytes(
seg_type,
payload,
segment_id,
FIXTURE_CHECKSUM_ALGO,
Some(&keypair.signing),
)
}
/// Build a minimal but complete container: a `META` segment carrying the given
@ -117,6 +79,7 @@ pub fn minimal_container(capabilities: &str) -> Vec<u8> {
#[cfg(test)]
mod tests {
use super::*;
use rvf_types::SEGMENT_ALIGNMENT;
#[test]
fn unsigned_segment_is_aligned_and_parses() {

View file

@ -1,21 +1,26 @@
#!/usr/bin/env bash
# CLI ↔ Rust registry parity check.
# CLI ↔ Rust parity check, in two halves.
#
# `npm/packages/rvforge` (TypeScript) writes a local registry; `crates/
# rvforge-registry` (Rust) reads one. Both implement
# `docs/research/rvf-forge/registry-model.md`, and they were built
# independently — so "both pass their own tests" says nothing about whether
# they interoperate.
# `npm/packages/rvforge` (TypeScript) writes `.rvf` containers and a local
# registry; `crates/rvf-forge-core` and `crates/rvforge-registry` (Rust) read
# them. Both sides implement the same specs and were built independently — so
# "both pass their own tests" says nothing about whether they interoperate.
#
# This script closes that gap end to end: it drives the real CLI through
# init → pack → publish against a synthetic `.rvf`, publishes a *second*
# release so the run exercises lineage rather than just a single object, then
# hands the resulting directory to `rvforge-registry-check`, which re-derives
# every content address, recomputes the Merkle log, re-applies the publication
# rules, and walks the witness chains using nothing but the Rust crate's
# public API.
# **Container parity.** `rvforge create` writes a real, signed `.rvf`, and
# `rvforge-rvf-check` inspects and verifies it through `rvf-forge-core`'s public
# API, with the publisher's public key supplied so the Ed25519 signatures are
# actually checked rather than skipped. Nothing is shared between the two
# implementations but the format itself: the segment layout, the SHAKE-256
# content hashes, the signature footers, and the Level-0 root manifest page all
# have to agree byte for byte or this fails.
#
# Prints `PARITY OK` and exits 0 when the Rust reader accepts what the CLI
# **Registry parity.** The run then drives pack → publish against that same
# artifact, publishes a *second* release so it exercises lineage rather than a
# single object, and hands the directory to `rvforge-registry-check`, which
# re-derives every content address, recomputes the Merkle log, re-applies the
# publication rules, and walks the witness chains.
#
# Prints `PARITY OK` and exits 0 when the Rust readers accept what the CLI
# wrote; otherwise prints the violation list and exits non-zero.
#
# Usage: scripts/rvforge-parity-check.sh [--keep]
@ -66,6 +71,34 @@ echo "==> rvforge init --keygen"
(cd "$PROJECT_DIR" && node "$CLI" init --keygen --quiet >/dev/null)
node "$REPO_ROOT/scripts/rvforge-parity-fixture.cjs" "$CLI_DIR/dist" "$PROJECT_DIR" >/dev/null
echo "==> rvforge create agent.rvf"
# The fixture no longer ships a synthetic .rvf: the CLI writes the artifact
# under test. A .wasm payload makes it carry an executable segment, so the
# run covers the signed-executable path rather than metadata alone.
mkdir -p "$PROJECT_DIR/payload"
printf '\0asm\1\0\0\0' > "$PROJECT_DIR/payload/agent.wasm"
(cd "$PROJECT_DIR" && node "$CLI" create agent.rvf \
--from payload --key-file "$KEY_FILE" --quiet >/dev/null)
echo "==> rvforge-rvf-check (Rust reads the container the CLI wrote)"
# The public key is passed as hex so the Rust side needs no base64 decoder;
# without it every signature check would record "skipped", which would prove
# nothing about whether the CLI's signatures verify.
PUBLIC_KEY_HEX="$(node -e '
const { readFileSync } = require("node:fs");
const key = JSON.parse(readFileSync(process.argv[1], "utf8"));
process.stdout.write(Buffer.from(key.publicKey, "base64").toString("hex"));
' "$KEY_FILE")"
if ! (cd "$REPO_ROOT" && cargo run --quiet -p rvf-forge-core \
--bin rvforge-rvf-check -- "$PROJECT_DIR/agent.rvf" \
--public-key "$PUBLIC_KEY_HEX"); then
echo
echo "PARITY FAILED — crates/rvf-forge-core rejected the .rvf that" >&2
echo "npm/packages/rvforge wrote; the two disagree about the RVF format" >&2
exit 1
fi
echo "==> rvforge pack agent.rvf"
(cd "$PROJECT_DIR" && node "$CLI" pack agent.rvf --quiet >/dev/null)
@ -91,7 +124,9 @@ echo "==> rvforge-registry-check (Rust)"
if (cd "$REPO_ROOT" && cargo run --quiet -p rvforge-registry \
--bin rvforge-registry-check -- "$REGISTRY_DIR"); then
echo
echo "PARITY OK — the Rust registry reads everything the CLI wrote"
echo "PARITY OK — the Rust side reads everything the CLI wrote:"
echo " container rvf-forge-core inspected and verified agent.rvf"
echo " registry rvforge-registry replayed the whole publication log"
exit 0
fi

View file

@ -2,18 +2,18 @@
/**
* Fixture generator for `scripts/rvforge-parity-check.sh`.
*
* Writes the two inputs a publish needs a synthetic `.rvf` and an
* `rvforge.json` that packs clean into a directory given on the command line.
* Writes the one input a publish needs that the CLI cannot produce on its own:
* an `rvforge.json` that packs clean. The `.rvf` beside it is no longer written
* here `rvforge create` writes it, which is the point of the parity run.
* Generating the artifact from literals would have tested the fixture rather
* than the writer that ships.
*
* Both are built from the CLI's own compiled modules rather than from literals:
* the RVF layout comes from `dist/rvf.js` (the same constants and CRC the
* parser reads with) and the project scaffold from `dist/project.js`. A fixture
* assembled from hand-copied magic numbers would drift from the parser it is
* meant to feed, and the parity run would start failing for a reason that has
* nothing to do with the registry.
* The scaffold comes from the CLI's own compiled `dist/project.js` rather than
* from a hand-copied object, so it cannot drift from the validator that reads
* it back.
*
* The shapes mirror `npm/packages/rvforge/tests/fixtures/{rvf,project}-fixture.ts`,
* which are TypeScript and therefore not loadable from a plain node process.
* The shape mirrors `npm/packages/rvforge/tests/fixtures/project-fixture.ts`,
* which is TypeScript and therefore not loadable from a plain node process.
*
* Usage: node scripts/rvforge-parity-fixture.cjs <cli-dist-dir> <out-dir>
*/
@ -32,79 +32,8 @@ if (!distDirArg || !outDirArg) {
const distDir = resolve(distDirArg);
const outDir = resolve(outDirArg);
const {
ROOT_MANIFEST_MAGIC_BYTES,
ROOT_MANIFEST_SIZE,
SEGMENT_HEADER_SIZE,
SEGMENT_MAGIC_BYTES,
alignUp,
crc32c,
} = require(join(distDir, 'rvf.js'));
const { defaultDenials, defaultProject } = require(join(distDir, 'project.js'));
/** The same minimal artifact the CLI's own tests pack: three segments, signed. */
const FIXTURE = {
segments: [
{ type: 0x01, payloadLength: 128 },
{ type: 0x02, payloadLength: 64 },
{ type: 0x05, payloadLength: 32 },
],
signed: true,
formatVersion: 1,
dimension: 8,
vectorCount: 4,
fileId: '0123456789abcdef0123456789abcdef',
};
function buildSegments(segments) {
const chunks = [];
segments.forEach((segment, index) => {
const header = Buffer.alloc(SEGMENT_HEADER_SIZE);
SEGMENT_MAGIC_BYTES.copy(header, 0);
header[0x04] = 1; // version
header[0x05] = segment.type;
header.writeUInt16LE(0, 0x06); // flags
header.writeBigUInt64LE(BigInt(index), 0x08); // segment_id
header.writeBigUInt64LE(BigInt(segment.payloadLength), 0x10);
header.writeBigUInt64LE(0n, 0x18); // timestamp_ns — zero keeps the bytes stable
header[0x20] = 0; // checksum_algo: CRC32C
header[0x21] = 0; // compression: none
const unpadded = SEGMENT_HEADER_SIZE + segment.payloadLength;
chunks.push(header, Buffer.alloc(alignUp(unpadded) - SEGMENT_HEADER_SIZE));
});
return Buffer.concat(chunks);
}
function buildRootManifest(spec, bodyLength) {
const page = Buffer.alloc(ROOT_MANIFEST_SIZE);
ROOT_MANIFEST_MAGIC_BYTES.copy(page, 0x000);
page.writeUInt16LE(spec.formatVersion, 0x004);
page.writeUInt16LE(0, 0x006); // flags
page.writeBigUInt64LE(0n, 0x008); // l1_manifest_offset
page.writeBigUInt64LE(BigInt(Math.min(SEGMENT_HEADER_SIZE, bodyLength)), 0x010);
page.writeBigUInt64LE(BigInt(spec.vectorCount), 0x018);
page.writeUInt16LE(spec.dimension, 0x020);
page[0x022] = 1; // base_dtype: f32
page[0x023] = 0; // profile_id
page.writeUInt32LE(1, 0x024); // epoch
page.writeBigUInt64LE(0n, 0x028); // created_ns
page.writeBigUInt64LE(0n, 0x030); // modified_ns
if (spec.signed) {
page.writeUInt16LE(1, 0x098); // sig_algo: Ed25519
page.writeUInt16LE(64, 0x09a); // sig_length
Buffer.alloc(64, 0xab).copy(page, 0x09c); // placeholder signature bytes
}
Buffer.from(spec.fileId, 'hex').copy(page, 0xf00);
page.writeUInt32LE(crc32c(page.subarray(0, 0xffc)), 0xffc);
return page;
}
const body = buildSegments(FIXTURE.segments);
const rvf = Buffer.concat([body, buildRootManifest(FIXTURE, body.length)]);
/** Narrow, concrete scopes: nothing here trips a manual-review trigger. */
const requests = [
{ class: 'filesystem', scope: 'user-selected', rationale: 'reads only documents you choose' },
@ -144,9 +73,7 @@ const project = defaultProject({
state: { checkpointing: true, recovery: true, rollbackSafeUntilStateSchema: 2 },
});
const rvfPath = join(outDir, 'agent.rvf');
const projectPath = join(outDir, 'rvforge.json');
writeFileSync(rvfPath, rvf);
writeFileSync(projectPath, `${JSON.stringify(project, null, 2)}\n`, 'utf8');
process.stdout.write(`${rvfPath}\n${projectPath}\n`);
process.stdout.write(`${projectPath}\n`);