From 94b9a3aceb29a52b353a04b68f6b389c525d094c Mon Sep 17 00:00:00 2001 From: Luxian Date: Mon, 5 Oct 2026 10:46:34 +0800 Subject: [PATCH 1/3] feat(codec): encode canonical namespace v1 identities --- .github/workflows/codec.yml | 44 ++++ src/lib.rs | 1 + src/namespace.rs | 364 +++++++++++++++++++++++++++++++ src/namespace/tests.rs | 350 +++++++++++++++++++++++++++++ src/treeframe.rs | 9 +- tests/fixtures/namespace-v1.json | 74 +++++++ tests/fixtures/source-v1.json | 24 ++ 7 files changed, 863 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/codec.yml create mode 100644 src/namespace.rs create mode 100644 src/namespace/tests.rs create mode 100644 tests/fixtures/namespace-v1.json create mode 100644 tests/fixtures/source-v1.json diff --git a/.github/workflows/codec.yml b/.github/workflows/codec.yml new file mode 100644 index 0000000..97a8f11 --- /dev/null +++ b/.github/workflows/codec.yml @@ -0,0 +1,44 @@ +name: Codec gates + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: codec-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + codec: + name: ${{ matrix.os }} / ${{ matrix.profile }} + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + profile: [identity, zstd] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.profile }} + - name: Format + run: cargo fmt --all --check + - name: Build, lint and test + shell: bash + run: | + set -euo pipefail + codec_features=--no-default-features + if [ '${{ matrix.profile }}' = zstd ]; then + codec_features=--all-features + fi + cargo clippy --all-targets "$codec_features" --locked -- -D warnings + cargo build "$codec_features" --locked + cargo test "$codec_features" --locked diff --git a/src/lib.rs b/src/lib.rs index 7ec108e..7a27da2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ pub mod chunkmap; pub mod descriptor; pub mod metapage; +pub mod namespace; pub mod treeframe; #[cfg(feature = "zstd")] pub mod zstd1; diff --git a/src/namespace.rs b/src/namespace.rs new file mode 100644 index 0000000..1b260ae --- /dev/null +++ b/src/namespace.rs @@ -0,0 +1,364 @@ +//! Canonical source/binding/view identities from the fixed namespace v1 contract. +//! +//! The authority is Mega draft c488b78a82361ecc6e5bb285977b8439993c062e, +//! `docs/spec/source-snapshot-v1.md` and `namespace-manifest-v1.md`. +//! These structural codecs do not attest membership, authorize reads, retain +//! objects, interpret overrides or enforce release policy at publication. + +use crate::{sha256, CodecError, CodecResult}; + +pub const MANIFEST_MAX_BYTES: usize = 16_384; +const PATH_MAX_BYTES: usize = 4096; +const SOURCE_DOMAIN: &[u8] = b"mega.source-snapshot.v1\0"; +const BINDING_DOMAIN: &[u8] = b"mega.namespace-binding.v1\0"; +const VIEW_DOMAIN: &[u8] = b"mega.namespace-view.v1\0"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceSnapshot { + source_id: String, + scope_path: String, + commit_oid: String, + root_tree_oid: String, +} + +impl SourceSnapshot { + /// v1 supports only SHA-1 object IDs; other formats require negotiation. + pub fn new( + source_id: String, + scope_path: String, + commit_oid: String, + root_tree_oid: String, + ) -> CodecResult { + validate_uuid(&source_id)?; + validate_absolute_path(&scope_path)?; + validate_oid(&commit_oid)?; + validate_oid(&root_tree_oid)?; + Ok(Self { + source_id, + scope_path, + commit_oid, + root_tree_oid, + }) + } + + pub fn source_id(&self) -> &str { + &self.source_id + } + pub fn scope_path(&self) -> &str { + &self.scope_path + } + pub fn commit_oid(&self) -> &str { + &self.commit_oid + } + pub fn root_tree_oid(&self) -> &str { + &self.root_tree_oid + } + + pub fn encode(&self) -> Vec { + let mut out = SOURCE_DOMAIN.to_vec(); + for field in [ + self.source_id.as_str(), + self.scope_path.as_str(), + "sha1", + self.commit_oid.as_str(), + self.root_tree_oid.as_str(), + ] { + write_field(&mut out, field.as_bytes()); + } + out + } + + pub fn decode(bytes: &[u8]) -> CodecResult { + let mut input = Input::new(bytes, SOURCE_DOMAIN)?; + let source_id = input.text()?.to_owned(); + let scope_path = input.text()?.to_owned(); + if input.text()? != "sha1" { + return Err(CodecError::BadConstant("source object format")); + } + let commit_oid = input.text()?.to_owned(); + let root_tree_oid = input.text()?.to_owned(); + input.finish()?; + Self::new(source_id, scope_path, commit_oid, root_tree_oid) + } + + pub fn id(&self) -> [u8; 32] { + sha256(&[&self.encode()]) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BindingPolicy { + Mutable, + ImmutableRelease, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NamespaceBinding { + mount_path: String, + source: SourceSnapshot, + source_subpath: String, + policy: BindingPolicy, +} + +impl NamespaceBinding { + pub fn new( + mount_path: String, + source: SourceSnapshot, + source_subpath: String, + policy: BindingPolicy, + ) -> CodecResult { + validate_absolute_path(&mount_path)?; + validate_relative_path(&source_subpath)?; + let composed_len = if source_subpath.is_empty() { + source.scope_path.len() + } else if source.scope_path == "/" { + 1 + source_subpath.len() + } else { + source.scope_path.len() + 1 + source_subpath.len() + }; + if composed_len > PATH_MAX_BYTES { + return Err(CodecError::BadLength("composed source path")); + } + Ok(Self { + mount_path, + source, + source_subpath, + policy, + }) + } + + pub fn mount_path(&self) -> &str { + &self.mount_path + } + pub fn source(&self) -> &SourceSnapshot { + &self.source + } + pub fn source_subpath(&self) -> &str { + &self.source_subpath + } + pub fn policy(&self) -> BindingPolicy { + self.policy + } + + pub fn encode(&self) -> Vec { + let mut out = BINDING_DOMAIN.to_vec(); + write_field(&mut out, self.mount_path.as_bytes()); + write_field(&mut out, &self.source.encode()); + write_field(&mut out, self.source_subpath.as_bytes()); + out.push(match self.policy { + BindingPolicy::Mutable => 1, + BindingPolicy::ImmutableRelease => 2, + }); + out + } + + pub fn decode(bytes: &[u8]) -> CodecResult { + let mut input = Input::new(bytes, BINDING_DOMAIN)?; + let mount_path = input.text()?.to_owned(); + let source = SourceSnapshot::decode(input.field()?)?; + let source_subpath = input.text()?.to_owned(); + let policy = match input.byte()? { + 1 => BindingPolicy::Mutable, + 2 => BindingPolicy::ImmutableRelease, + _ => return Err(CodecError::BadConstant("binding policy")), + }; + input.finish()?; + Self::new(mount_path, source, source_subpath, policy) + } + + pub fn id(&self) -> [u8; 32] { + sha256(&[&self.encode()]) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NamespaceView { + instance_id: String, + native: SourceSnapshot, + bindings_root: [u8; 32], + overrides_root: Option<[u8; 32]>, +} + +impl NamespaceView { + /// An override root is identity data, not permission to serve overrides. + pub fn new( + instance_id: String, + native: SourceSnapshot, + bindings_root: [u8; 32], + overrides_root: Option<[u8; 32]>, + ) -> CodecResult { + validate_uuid(&instance_id)?; + if native.scope_path != "/" { + return Err(CodecError::BadName("native scope must be root")); + } + Ok(Self { + instance_id, + native, + bindings_root, + overrides_root, + }) + } + + pub fn instance_id(&self) -> &str { + &self.instance_id + } + pub fn native(&self) -> &SourceSnapshot { + &self.native + } + pub fn bindings_root(&self) -> &[u8; 32] { + &self.bindings_root + } + pub fn overrides_root(&self) -> Option<&[u8; 32]> { + self.overrides_root.as_ref() + } + + pub fn encode(&self) -> Vec { + let mut out = VIEW_DOMAIN.to_vec(); + out.extend_from_slice(&1u16.to_be_bytes()); + write_field(&mut out, self.instance_id.as_bytes()); + write_field(&mut out, &self.native.encode()); + out.extend_from_slice(&self.bindings_root); + match self.overrides_root { + None => out.push(0), + Some(root) => { + out.push(1); + out.extend_from_slice(&root); + } + } + out.push(1); // git_raw_v1 + out + } + + pub fn decode(bytes: &[u8]) -> CodecResult { + let mut input = Input::new(bytes, VIEW_DOMAIN)?; + if input.take(2)? != 1u16.to_be_bytes() { + return Err(CodecError::BadConstant("namespace schema")); + } + let instance_id = input.text()?.to_owned(); + let native = SourceSnapshot::decode(input.field()?)?; + let bindings_root = input.digest()?; + let overrides_root = match input.byte()? { + 0 => None, + 1 => Some(input.digest()?), + _ => return Err(CodecError::BadConstant("override presence")), + }; + if input.byte()? != 1 { + return Err(CodecError::BadConstant("materialization policy")); + } + input.finish()?; + Self::new(instance_id, native, bindings_root, overrides_root) + } + + pub fn id(&self) -> [u8; 32] { + sha256(&[&self.encode()]) + } +} + +fn validate_uuid(value: &str) -> CodecResult<()> { + let bytes = value.as_bytes(); + if bytes.len() != 36 + || !bytes.iter().enumerate().all(|(i, b)| { + if matches!(i, 8 | 13 | 18 | 23) { + *b == b'-' + } else { + b.is_ascii_digit() || (b'a'..=b'f').contains(b) + } + }) + || !bytes.iter().any(|b| *b != b'0' && *b != b'-') + { + return Err(CodecError::BadName("canonical non-nil UUID")); + } + Ok(()) +} + +fn validate_oid(value: &str) -> CodecResult<()> { + if value.len() != 40 + || !value + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return Err(CodecError::BadName("canonical SHA-1 OID")); + } + Ok(()) +} + +fn validate_absolute_path(path: &str) -> CodecResult<()> { + if path.len() > PATH_MAX_BYTES { + return Err(CodecError::BadLength("absolute path")); + } + let relative = path + .strip_prefix('/') + .ok_or(CodecError::BadName("absolute path"))?; + validate_relative_path(relative) +} + +fn validate_relative_path(path: &str) -> CodecResult<()> { + if path.len() > PATH_MAX_BYTES { + return Err(CodecError::BadLength("relative path")); + } + if path.is_empty() { + return Ok(()); + } + for name in path.split('/') { + crate::validate_name(name.as_bytes())?; + } + Ok(()) +} + +fn write_field(out: &mut Vec, bytes: &[u8]) { + // Constructors bound all fields, so every length fits u32 and every + // complete manifest is smaller than MANIFEST_MAX_BYTES. + out.extend_from_slice(&(bytes.len() as u32).to_be_bytes()); + out.extend_from_slice(bytes); +} + +struct Input<'a> { + remaining: &'a [u8], +} + +impl<'a> Input<'a> { + fn new(bytes: &'a [u8], domain: &[u8]) -> CodecResult { + if bytes.len() > MANIFEST_MAX_BYTES { + return Err(CodecError::BadLength("manifest")); + } + let remaining = bytes + .strip_prefix(domain) + .ok_or(CodecError::BadConstant("manifest domain"))?; + Ok(Self { remaining }) + } + + fn take(&mut self, length: usize) -> CodecResult<&'a [u8]> { + let bytes = self + .remaining + .get(..length) + .ok_or(CodecError::Truncated("manifest field"))?; + self.remaining = &self.remaining[length..]; + Ok(bytes) + } + + fn byte(&mut self) -> CodecResult { + Ok(self.take(1)?[0]) + } + fn digest(&mut self) -> CodecResult<[u8; 32]> { + Ok(self.take(32)?.try_into().unwrap()) + } + fn field(&mut self) -> CodecResult<&'a [u8]> { + let length = u32::from_be_bytes(self.take(4)?.try_into().unwrap()); + let length = + usize::try_from(length).map_err(|_| CodecError::Overflow("manifest length"))?; + self.take(length) + } + fn text(&mut self) -> CodecResult<&'a str> { + std::str::from_utf8(self.field()?).map_err(|_| CodecError::BadName("manifest UTF-8")) + } + fn finish(self) -> CodecResult<()> { + if self.remaining.is_empty() { + Ok(()) + } else { + Err(CodecError::BadLength("manifest trailing bytes")) + } + } +} + +#[cfg(test)] +mod tests; diff --git a/src/namespace/tests.rs b/src/namespace/tests.rs new file mode 100644 index 0000000..70c8195 --- /dev/null +++ b/src/namespace/tests.rs @@ -0,0 +1,350 @@ +use super::*; +use serde_json::Value; + +fn unhex(hex: &str) -> Vec { + hex.as_bytes() + .chunks_exact(2) + .map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap()) + .collect() +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn source(value: &Value) -> SourceSnapshot { + assert_eq!(value["object_format"], "sha1"); + SourceSnapshot::new( + value["source_id"].as_str().unwrap().into(), + value["scope_path"].as_str().unwrap().into(), + value["commit_oid"].as_str().unwrap().into(), + value["root_tree_oid"].as_str().unwrap().into(), + ) + .unwrap() +} + +fn root_source() -> SourceSnapshot { + SourceSnapshot::new( + "11111111-1111-4111-8111-111111111111".into(), + "/".into(), + "1".repeat(40), + "4b825dc642cb6eb9a060e54bf8d69288fbee4904".into(), + ) + .unwrap() +} + +fn vectors() -> Value { + serde_json::from_str(include_str!("../../tests/fixtures/namespace-v1.json")).unwrap() +} + +#[test] +fn independent_dotnet_source_vectors_match_bytes_identity_and_decoding() { + let vectors: Value = + serde_json::from_str(include_str!("../../tests/fixtures/source-v1.json")).unwrap(); + for vector in vectors.as_array().unwrap() { + let expected = unhex(vector["canonical_hex"].as_str().unwrap()); + let source = source(&vector["source"]); + assert_eq!(source.encode(), expected); + assert_eq!(SourceSnapshot::decode(&expected).unwrap(), source); + assert_eq!( + format!("sha256:{}", hex(&source.id())), + vector["source_id_digest"] + ); + } +} + +#[test] +fn independent_dotnet_binding_and_view_vectors_match() { + let vectors = vectors(); + for vector in vectors["bindings"].as_array().unwrap() { + let value = &vector["binding"]; + let policy = match value["policy"].as_str().unwrap() { + "mutable" => BindingPolicy::Mutable, + "immutable_release" => BindingPolicy::ImmutableRelease, + _ => panic!("invalid independent fixture"), + }; + let binding = NamespaceBinding::new( + value["mount_path"].as_str().unwrap().into(), + source(&value["source_snapshot"]), + value["source_subpath"].as_str().unwrap().into(), + policy, + ) + .unwrap(); + let expected = unhex(vector["canonical_hex"].as_str().unwrap()); + assert_eq!(binding.encode(), expected); + assert_eq!(NamespaceBinding::decode(&expected).unwrap(), binding); + assert_eq!(format!("sha256:{}", hex(&binding.id())), vector["digest"]); + } + for vector in vectors["views"].as_array().unwrap() { + let value = &vector["view"]; + assert_eq!(value["schema_version"], 1); + assert_eq!(value["materialization_policy"], "git_raw_v1"); + let digest = |v: &Value| -> [u8; 32] { + unhex(v.as_str().unwrap().strip_prefix("sha256:").unwrap()) + .try_into() + .unwrap() + }; + let overrides = + (!value["overrides_root"].is_null()).then(|| digest(&value["overrides_root"])); + let view = NamespaceView::new( + value["instance_id"].as_str().unwrap().into(), + source(&value["native"]), + digest(&value["bindings_root"]), + overrides, + ) + .unwrap(); + let expected = unhex(vector["canonical_hex"].as_str().unwrap()); + assert_eq!(view.encode(), expected); + assert_eq!(NamespaceView::decode(&expected).unwrap(), view); + assert_eq!(format!("sha256:{}", hex(&view.id())), vector["digest"]); + } +} + +#[test] +fn every_truncation_trailing_byte_and_oversized_manifest_is_rejected() { + let source = root_source().encode(); + let binding = NamespaceBinding::new( + "/a".into(), + root_source(), + "".into(), + BindingPolicy::Mutable, + ) + .unwrap() + .encode(); + let view = NamespaceView::new( + "22222222-2222-4222-8222-222222222222".into(), + root_source(), + [7; 32], + Some([8; 32]), + ) + .unwrap() + .encode(); + for (bytes, decode) in [( + source, + SourceSnapshot::decode as fn(&[u8]) -> CodecResult, + )] { + for end in 0..bytes.len() { + assert!(decode(&bytes[..end]).is_err()); + } + let mut extra = bytes; + extra.push(0); + assert!(decode(&extra).is_err()); + } + for end in 0..binding.len() { + assert!(NamespaceBinding::decode(&binding[..end]).is_err()); + } + for end in 0..view.len() { + assert!(NamespaceView::decode(&view[..end]).is_err()); + } + let mut extra = binding; + extra.push(0); + assert!(NamespaceBinding::decode(&extra).is_err()); + let mut extra = view; + extra.push(0); + assert!(NamespaceView::decode(&extra).is_err()); + let oversized = vec![0; MANIFEST_MAX_BYTES + 1]; + assert_eq!( + SourceSnapshot::decode(&oversized).unwrap_err(), + CodecError::BadLength("manifest") + ); + assert!(NamespaceBinding::decode(&oversized).is_err()); + assert!(NamespaceView::decode(&oversized).is_err()); +} + +#[test] +fn malformed_domains_lengths_utf8_and_tags_fail_closed() { + let mut source = root_source().encode(); + source[0] ^= 1; + assert!(SourceSnapshot::decode(&source).is_err()); + let mut source = root_source().encode(); + source[SOURCE_DOMAIN.len()..SOURCE_DOMAIN.len() + 4].copy_from_slice(&u32::MAX.to_be_bytes()); + assert!(SourceSnapshot::decode(&source).is_err()); + let mut source = root_source().encode(); + source[SOURCE_DOMAIN.len() + 4] = 0xff; + assert!(SourceSnapshot::decode(&source).is_err()); + let mut source = root_source().encode(); + let tag = source.windows(4).position(|w| w == b"sha1").unwrap(); + source[tag..tag + 4].copy_from_slice(b"sha2"); + assert!(SourceSnapshot::decode(&source).is_err()); + let binding = NamespaceBinding::new( + "/a".into(), + root_source(), + "".into(), + BindingPolicy::Mutable, + ) + .unwrap() + .encode(); + for tag in [0, 3, 255] { + let mut bad = binding.clone(); + *bad.last_mut().unwrap() = tag; + assert!(NamespaceBinding::decode(&bad).is_err()); + } + let view = NamespaceView::new( + "22222222-2222-4222-8222-222222222222".into(), + root_source(), + [7; 32], + None, + ) + .unwrap() + .encode(); + for tag in [2, 255] { + let mut bad = view.clone(); + let len = bad.len(); + bad[len - 2] = tag; + assert!(NamespaceView::decode(&bad).is_err()); + } + let mut bad = view.clone(); + bad[VIEW_DOMAIN.len() + 1] = 2; + assert!(NamespaceView::decode(&bad).is_err()); + let mut bad = view; + *bad.last_mut().unwrap() = 2; + assert!(NamespaceView::decode(&bad).is_err()); +} + +#[test] +fn canonical_paths_preserve_unicode_case_plus_and_literal_backslash() { + let mut source = root_source(); + for path in ["", "relative", "/a/", "/a//b", "/.", "/a/../b", "/a\0b"] { + assert!( + SourceSnapshot::new( + source.source_id.clone(), + path.into(), + source.commit_oid.clone(), + source.root_tree_oid.clone() + ) + .is_err(), + "{path:?}" + ); + } + source.scope_path = "/库+1/Case/\\literal".into(); + assert_eq!(SourceSnapshot::decode(&source.encode()).unwrap(), source); + let composed = "/é"; + let decomposed = "/e\u{301}"; + let mut first = source.clone(); + first.scope_path = composed.into(); + let mut second = source; + second.scope_path = decomposed.into(); + assert_ne!(first.id(), second.id()); +} + +#[test] +fn exact_maximum_paths_and_source_composition_are_checked() { + let path = format!("/{}", vec!["x".repeat(255); 16].join("/")); + assert_eq!(path.len(), PATH_MAX_BYTES); + let binding = NamespaceBinding::new( + path.clone(), + root_source(), + path[1..].into(), + BindingPolicy::Mutable, + ) + .unwrap(); + assert!(binding.encode().len() <= MANIFEST_MAX_BYTES); + assert_eq!( + NamespaceBinding::decode(&binding.encode()).unwrap(), + binding + ); + let mut scoped = root_source(); + scoped.scope_path = path.clone(); + assert!( + NamespaceBinding::new("/a".into(), scoped, "z".into(), BindingPolicy::Mutable).is_err() + ); + assert!(SourceSnapshot::new( + root_source().source_id, + format!("{path}/z"), + "1".repeat(40), + "2".repeat(40) + ) + .is_err()); + assert!(NamespaceBinding::new( + "/a".into(), + root_source(), + "x".repeat(256), + BindingPolicy::Mutable + ) + .is_err()); +} + +#[test] +fn canonical_uuids_and_oids_cannot_be_normalized_into_valid_identity() { + let good = root_source(); + for id in [ + "00000000-0000-0000-0000-000000000000", + "11111111111141118111111111111111", + "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA", + ] { + assert!(SourceSnapshot::new( + id.into(), + "/".into(), + good.commit_oid.clone(), + good.root_tree_oid.clone() + ) + .is_err()); + } + for oid in [ + "a".repeat(39), + "a".repeat(41), + "A".repeat(40), + "g".repeat(40), + ] { + assert!(SourceSnapshot::new( + good.source_id.clone(), + "/".into(), + oid, + good.root_tree_oid.clone() + ) + .is_err()); + } + let mut scoped = good; + scoped.scope_path = "/a".into(); + assert!(NamespaceView::new( + "22222222-2222-4222-8222-222222222222".into(), + scoped, + [0; 32], + None + ) + .is_err()); +} + +#[test] +fn identity_changes_with_provenance_policy_route_instance_and_override_presence() { + let source = root_source(); + let mut next = source.clone(); + next.commit_oid = "2".repeat(40); + assert_ne!( + source.id(), + next.id(), + "same tree with new commit changes provenance" + ); + let binding = NamespaceBinding::new( + "/a".into(), + source.clone(), + "".into(), + BindingPolicy::Mutable, + ) + .unwrap(); + let mut release = binding.clone(); + release.policy = BindingPolicy::ImmutableRelease; + assert_ne!(binding.id(), release.id()); + let mut moved = binding.clone(); + moved.mount_path = "/ab".into(); + assert_ne!(binding.id(), moved.id()); + let mut subpath = binding.clone(); + subpath.source_subpath = "src".into(); + assert_ne!(binding.id(), subpath.id()); + let view = NamespaceView::new( + "22222222-2222-4222-8222-222222222222".into(), + source, + [0; 32], + None, + ) + .unwrap(); + let mut with_empty_override = view.clone(); + with_empty_override.overrides_root = Some([0; 32]); + assert_ne!(view.id(), with_empty_override.id()); + let mut provenance = view.clone(); + provenance.native = next; + assert_ne!(view.id(), provenance.id()); + let mut other_instance = view.clone(); + other_instance.instance_id = "33333333-3333-4333-8333-333333333333".into(); + assert_ne!(view.id(), other_instance.id()); +} diff --git a/src/treeframe.rs b/src/treeframe.rs index 2ea8179..ded87a8 100644 --- a/src/treeframe.rs +++ b/src/treeframe.rs @@ -7,9 +7,9 @@ //! `encode_zstd` on the three compressible payloads. END/ERROR are never //! compressed. -use crate::{ - read_u16, read_u32, read_u64, sha256, write_u16, write_u32, write_u64, CodecError, CodecResult, -}; +use crate::{read_u16, read_u32, read_u64, sha256, CodecError, CodecResult}; +#[cfg(feature = "zstd")] +use crate::{write_u16, write_u32, write_u64}; pub const VERSION: u16 = 2; pub const HEADER_LEN: usize = 64; @@ -642,6 +642,7 @@ impl MetaPayload { frame_bytes_zstd(KIND_META, &payload, stream_id, sequence) } + #[cfg(feature = "zstd")] fn raw_payload(&self) -> CodecResult> { let mut payload = Vec::new(); write_u16(&mut payload, self.pages.len() as u16); @@ -695,6 +696,7 @@ impl ObjectPayload { frame_bytes_zstd(KIND_OBJECT, &payload, stream_id, sequence) } + #[cfg(feature = "zstd")] fn raw_payload(&self) -> CodecResult> { let mut payload = Vec::new(); write_u16(&mut payload, self.objects.len() as u16); @@ -738,6 +740,7 @@ impl ChunkPayload { frame_bytes_zstd(KIND_CHUNK, &payload, stream_id, sequence) } + #[cfg(feature = "zstd")] fn raw_payload(&self) -> Vec { let mut payload = Vec::with_capacity(76 + self.chunk_bytes.len()); payload.extend_from_slice(&self.map_id); diff --git a/tests/fixtures/namespace-v1.json b/tests/fixtures/namespace-v1.json new file mode 100644 index 0000000..0d84de5 --- /dev/null +++ b/tests/fixtures/namespace-v1.json @@ -0,0 +1,74 @@ +{ + "bindings": [ + { + "binding": { + "mount_path": "/deps/库+1", + "source_snapshot": { + "source_id": "33333333-3333-4333-8333-333333333333", + "scope_path": "/third-party/库+1", + "object_format": "sha1", + "commit_oid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "root_tree_oid": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "source_subpath": "src", + "policy": "mutable" + }, + "canonical_hex": "6d6567612e6e616d6573706163652d62696e64696e672e7631000000000b2f646570732fe5ba932b31000000b66d6567612e736f757263652d736e617073686f742e7631000000002433333333333333332d333333332d343333332d383333332d333333333333333333333333000000122f74686972642d70617274792fe5ba932b310000000473686131000000286161616161616161616161616161616161616161616161616161616161616161616161616161616100000028626262626262626262626262626262626262626262626262626262626262626262626262626262620000000373726301", + "digest": "sha256:adebe124b05761074c9460ed20426acf3023645e2bfa7e46b12239da68b14a88" + }, + { + "binding": { + "mount_path": "/deps/库+1", + "source_snapshot": { + "source_id": "33333333-3333-4333-8333-333333333333", + "scope_path": "/third-party/库+1", + "object_format": "sha1", + "commit_oid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "root_tree_oid": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "source_subpath": "src", + "policy": "immutable_release" + }, + "canonical_hex": "6d6567612e6e616d6573706163652d62696e64696e672e7631000000000b2f646570732fe5ba932b31000000b66d6567612e736f757263652d736e617073686f742e7631000000002433333333333333332d333333332d343333332d383333332d333333333333333333333333000000122f74686972642d70617274792fe5ba932b310000000473686131000000286161616161616161616161616161616161616161616161616161616161616161616161616161616100000028626262626262626262626262626262626262626262626262626262626262626262626262626262620000000373726302", + "digest": "sha256:4f4263e0096171458b5bb5915497e20c74af2a39c01270ce591d629af63d5ae0" + } + ], + "views": [ + { + "view": { + "schema_version": 1, + "instance_id": "22222222-2222-4222-8222-222222222222", + "native": { + "source_id": "11111111-1111-4111-8111-111111111111", + "scope_path": "/", + "object_format": "sha1", + "commit_oid": "1111111111111111111111111111111111111111", + "root_tree_oid": "4b825dc642cb6eb9a060e54bf8d69288fbee4904" + }, + "bindings_root": "sha256:18946486089198dfa8eeb70fa90e04b137c579dc08ae1e6f8bceafc0d35ef677", + "overrides_root": null, + "materialization_policy": "git_raw_v1" + }, + "canonical_hex": "6d6567612e6e616d6573706163652d766965772e76310000010000002432323232323232322d323232322d343232322d383232322d323232323232323232323232000000a56d6567612e736f757263652d736e617073686f742e7631000000002431313131313131312d313131312d343131312d383131312d313131313131313131313131000000012f00000004736861310000002831313131313131313131313131313131313131313131313131313131313131313131313131313131000000283462383235646336343263623665623961303630653534626638643639323838666265653439303418946486089198dfa8eeb70fa90e04b137c579dc08ae1e6f8bceafc0d35ef6770001", + "digest": "sha256:3c8632afb308bf562973b3af517ae5d0a27c05651f3f7511f91e16d7ad8f1231" + }, + { + "view": { + "schema_version": 1, + "instance_id": "22222222-2222-4222-8222-222222222222", + "native": { + "source_id": "11111111-1111-4111-8111-111111111111", + "scope_path": "/", + "object_format": "sha1", + "commit_oid": "1111111111111111111111111111111111111111", + "root_tree_oid": "4b825dc642cb6eb9a060e54bf8d69288fbee4904" + }, + "bindings_root": "sha256:18946486089198dfa8eeb70fa90e04b137c579dc08ae1e6f8bceafc0d35ef677", + "overrides_root": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "materialization_policy": "git_raw_v1" + }, + "canonical_hex": "6d6567612e6e616d6573706163652d766965772e76310000010000002432323232323232322d323232322d343232322d383232322d323232323232323232323232000000a56d6567612e736f757263652d736e617073686f742e7631000000002431313131313131312d313131312d343131312d383131312d313131313131313131313131000000012f00000004736861310000002831313131313131313131313131313131313131313131313131313131313131313131313131313131000000283462383235646336343263623665623961303630653534626638643639323838666265653439303418946486089198dfa8eeb70fa90e04b137c579dc08ae1e6f8bceafc0d35ef67701cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc01", + "digest": "sha256:671360699c27ba18ae4de2b1288c90f5849b3e381170c33b83b370c42a660a4e" + } + ] +} diff --git a/tests/fixtures/source-v1.json b/tests/fixtures/source-v1.json new file mode 100644 index 0000000..e3cda42 --- /dev/null +++ b/tests/fixtures/source-v1.json @@ -0,0 +1,24 @@ +[ + { + "source": { + "source_id": "11111111-1111-4111-8111-111111111111", + "scope_path": "/project/a", + "object_format": "sha1", + "commit_oid": "1111111111111111111111111111111111111111", + "root_tree_oid": "4b825dc642cb6eb9a060e54bf8d69288fbee4904" + }, + "canonical_hex": "6d6567612e736f757263652d736e617073686f742e7631000000002431313131313131312d313131312d343131312d383131312d3131313131313131313131310000000a2f70726f6a6563742f61000000047368613100000028313131313131313131313131313131313131313131313131313131313131313131313131313131310000002834623832356463363432636236656239613036306535346266386436393238386662656534393034", + "source_id_digest": "sha256:6e3f8a7e41d3a9759bc05cbc1dab153ad27ba0e0ff494f7692392dbfd5a95451" + }, + { + "source": { + "source_id": "33333333-3333-4333-8333-333333333333", + "scope_path": "/third-party/库+1", + "object_format": "sha1", + "commit_oid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "root_tree_oid": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "canonical_hex": "6d6567612e736f757263652d736e617073686f742e7631000000002433333333333333332d333333332d343333332d383333332d333333333333333333333333000000122f74686972642d70617274792fe5ba932b31000000047368613100000028616161616161616161616161616161616161616161616161616161616161616161616161616161610000002862626262626262626262626262626262626262626262626262626262626262626262626262626262", + "source_id_digest": "sha256:4e5857227f3b44e979c35aadcdc92835646a1eadae013d3e01ec07a08017dd74" + } +] From c813373a6bcd9976a3358bfc1b7998042aae958c Mon Sep 17 00:00:00 2001 From: Luxian Date: Mon, 5 Oct 2026 10:49:01 +0800 Subject: [PATCH 2/3] test(codec): keep golden hex decoder lint clean --- src/namespace/tests.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/namespace/tests.rs b/src/namespace/tests.rs index 70c8195..9f88a15 100644 --- a/src/namespace/tests.rs +++ b/src/namespace/tests.rs @@ -2,9 +2,9 @@ use super::*; use serde_json::Value; fn unhex(hex: &str) -> Vec { - hex.as_bytes() - .chunks_exact(2) - .map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap()) + (0..hex.len()) + .step_by(2) + .map(|offset| u8::from_str_radix(&hex[offset..offset + 2], 16).unwrap()) .collect() } From 8317e60da665c62a421183d7a92c918420ba4196 Mon Sep 17 00:00:00 2001 From: Xiaoyang Han Date: Mon, 5 Oct 2026 18:13:09 +0800 Subject: [PATCH 3/3] feat(codec): preserve canonical namespace binding radix bytes (#9) --- src/namespace.rs | 2 + src/namespace/radix.rs | 203 +++++++++++++++++++ src/namespace/radix/tests.rs | 265 +++++++++++++++++++++++++ tests/fixtures/namespace-radix-v1.json | 17 ++ 4 files changed, 487 insertions(+) create mode 100644 src/namespace/radix.rs create mode 100644 src/namespace/radix/tests.rs create mode 100644 tests/fixtures/namespace-radix-v1.json diff --git a/src/namespace.rs b/src/namespace.rs index 1b260ae..1083d0a 100644 --- a/src/namespace.rs +++ b/src/namespace.rs @@ -7,6 +7,8 @@ use crate::{sha256, CodecError, CodecResult}; +pub mod radix; + pub const MANIFEST_MAX_BYTES: usize = 16_384; const PATH_MAX_BYTES: usize = 4096; const SOURCE_DOMAIN: &[u8] = b"mega.source-snapshot.v1\0"; diff --git a/src/namespace/radix.rs b/src/namespace/radix.rs new file mode 100644 index 0000000..74b0908 --- /dev/null +++ b/src/namespace/radix.rs @@ -0,0 +1,203 @@ +//! Local node and component-key codec from Mega draft c488b78's +//! `docs/spec/namespace-index-v1.md`. This is not an index walker or store. +//! +//! Labels may split UTF-8. A walker must validate incoming child labels, +//! assembled value keys, requested digests, source membership and retention. +//! Node decoding alone establishes none of those graph relationships. + +use super::{validate_absolute_path, PATH_MAX_BYTES}; +use crate::{sha256, CodecError, CodecResult}; + +pub const NODE_MAX_BYTES: usize = 16_384; +pub const CHILD_MAX_COUNT: usize = 256; +const NODE_DOMAIN: &[u8] = b"mega.namespace-radix.v1\0"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadixChild { + pub edge: u8, + pub digest: [u8; 32], +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadixNode { + label: Vec, + binding: Option<[u8; 32]>, + children: Vec, +} + +impl RadixNode { + /// Validate only the local canonical shape, not the assembled value path. + pub fn new( + label: Vec, + binding: Option<[u8; 32]>, + children: Vec, + ) -> CodecResult { + if label.len() > PATH_MAX_BYTES { + return Err(CodecError::BadLength("radix label")); + } + if children.len() > CHILD_MAX_COUNT { + return Err(CodecError::BadLength("radix fanout")); + } + for pair in children.windows(2) { + if pair[0].edge >= pair[1].edge { + return Err(CodecError::BadOrdering("radix child edges")); + } + } + if binding.is_none() && (children.len() == 1 || (children.is_empty() && !label.is_empty())) + { + return Err(CodecError::BadOrdering("noncanonical radix compression")); + } + Ok(Self { + label, + binding, + children, + }) + } + + /// Canonical implicit empty index; it need not have a stored row. + pub fn empty() -> Self { + Self { + label: Vec::new(), + binding: None, + children: Vec::new(), + } + } + + pub fn label(&self) -> &[u8] { + &self.label + } + + pub fn binding(&self) -> Option<&[u8; 32]> { + self.binding.as_ref() + } + + pub fn children(&self) -> &[RadixChild] { + &self.children + } + + pub fn encode(&self) -> Vec { + let mut out = NODE_DOMAIN.to_vec(); + out.extend_from_slice(&(self.label.len() as u16).to_be_bytes()); + out.extend_from_slice(&self.label); + out.push(u8::from(self.binding.is_some())); + if let Some(binding) = self.binding { + out.extend_from_slice(&binding); + } + out.extend_from_slice(&(self.children.len() as u16).to_be_bytes()); + for child in &self.children { + out.push(child.edge); + out.extend_from_slice(&child.digest); + } + out + } + + pub fn decode(bytes: &[u8]) -> CodecResult { + if bytes.len() > NODE_MAX_BYTES { + return Err(CodecError::BadLength("radix node")); + } + let mut input = bytes + .strip_prefix(NODE_DOMAIN) + .ok_or(CodecError::BadConstant("radix domain"))?; + let label_len = read_u16(&mut input)?; + if label_len > PATH_MAX_BYTES { + return Err(CodecError::BadLength("radix label")); + } + let label = take(&mut input, label_len)?.to_vec(); + let binding = match take(&mut input, 1)?[0] { + 0 => None, + 1 => Some(read_digest(&mut input)?), + _ => return Err(CodecError::BadConstant("radix binding presence")), + }; + let child_count = read_u16(&mut input)?; + if child_count > CHILD_MAX_COUNT { + return Err(CodecError::BadLength("radix fanout")); + } + let mut children = Vec::with_capacity(child_count); + for _ in 0..child_count { + children.push(RadixChild { + edge: take(&mut input, 1)?[0], + digest: read_digest(&mut input)?, + }); + } + if !input.is_empty() { + return Err(CodecError::BadLength("radix trailing bytes")); + } + Self::new(label, binding, children) + } + + /// Derive identity; the caller must separately compare a requested digest. + pub fn id(&self) -> [u8; 32] { + sha256(&[&self.encode()]) + } +} + +pub fn empty_root() -> [u8; 32] { + RadixNode::empty().id() +} + +/// Preserve v1 key encoding and structural path limits. MST/2 endpoints must +/// separately enforce their 256-component profile before serving a path. +pub fn encode_key(path: &str) -> CodecResult> { + validate_absolute_path(path)?; + if path == "/" { + return Ok(Vec::new()); + } + let mut key = path.as_bytes()[1..].to_vec(); + for byte in &mut key { + if *byte == b'/' { + *byte = 0; + } + } + key.push(0); + Ok(key) +} + +/// Decode a complete value key, never a partial compressed label. Slashes in +/// encoded keys and a NUL-only root are rejected as noncanonical encodings. +pub fn decode_key(key: &[u8]) -> CodecResult { + if key.len() > PATH_MAX_BYTES { + return Err(CodecError::BadLength("radix key")); + } + if key.is_empty() { + return Ok("/".to_string()); + } + if key.last() != Some(&0) || key.contains(&b'/') { + return Err(CodecError::BadName("radix key boundary")); + } + let mut path = Vec::with_capacity(key.len()); + path.push(b'/'); + path.extend( + key[..key.len() - 1] + .iter() + .map(|byte| if *byte == 0 { b'/' } else { *byte }), + ); + let path = String::from_utf8(path).map_err(|_| CodecError::BadName("radix key UTF-8"))?; + validate_absolute_path(&path)?; + if encode_key(&path)?.as_slice() != key { + return Err(CodecError::BadName("noncanonical radix key")); + } + Ok(path) +} + +fn take<'a>(input: &mut &'a [u8], count: usize) -> CodecResult<&'a [u8]> { + let head = input + .get(..count) + .ok_or(CodecError::Truncated("radix node field"))?; + *input = &input[count..]; + Ok(head) +} + +fn read_u16(input: &mut &[u8]) -> CodecResult { + let bytes = take(input, 2)?; + Ok(usize::from(u16::from_be_bytes([bytes[0], bytes[1]]))) +} + +fn read_digest(input: &mut &[u8]) -> CodecResult<[u8; 32]> { + let bytes = take(input, 32)?; + let mut digest = [0; 32]; + digest.copy_from_slice(bytes); + Ok(digest) +} + +#[cfg(test)] +mod tests; diff --git a/src/namespace/radix/tests.rs b/src/namespace/radix/tests.rs new file mode 100644 index 0000000..8c8c15a --- /dev/null +++ b/src/namespace/radix/tests.rs @@ -0,0 +1,265 @@ +use super::*; +use serde_json::Value; + +fn unhex(hex: &str) -> Vec { + (0..hex.len()) + .step_by(2) + .map(|offset| u8::from_str_radix(&hex[offset..offset + 2], 16).unwrap()) + .collect() +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn vectors() -> Value { + serde_json::from_str(include_str!( + "../../../tests/fixtures/namespace-radix-v1.json" + )) + .unwrap() +} + +fn child(edge: u8, fill: u8) -> RadixChild { + RadixChild { + edge, + digest: [fill; 32], + } +} + +#[test] +fn frozen_independent_dotnet_vectors_match_local_nodes_bytes_and_identity() { + let expected_nodes = [ + RadixNode::empty(), + RadixNode::new(b"a\0".to_vec(), Some([0x11; 32]), Vec::new()).unwrap(), + RadixNode::new(Vec::new(), None, vec![child(b'a', 0x22), child(b'b', 0x33)]).unwrap(), + ]; + let frozen_vectors = vectors(); + assert_eq!( + frozen_vectors.as_array().unwrap().len(), + expected_nodes.len() + ); + for (vector, expected_node) in frozen_vectors + .as_array() + .unwrap() + .iter() + .zip(expected_nodes) + { + let bytes = unhex(vector["canonical_hex"].as_str().unwrap()); + let decoded = RadixNode::decode(&bytes).unwrap(); + assert_eq!(decoded, expected_node); + assert_eq!(expected_node.encode(), bytes); + assert_eq!(format!("sha256:{}", hex(&decoded.id())), vector["digest"]); + } + assert_eq!( + hex(&empty_root()), + "18946486089198dfa8eeb70fa90e04b137c579dc08ae1e6f8bceafc0d35ef677" + ); +} + +#[test] +fn every_frozen_node_truncation_and_trailing_byte_is_rejected() { + for vector in vectors().as_array().unwrap() { + let bytes = unhex(vector["canonical_hex"].as_str().unwrap()); + for end in 0..bytes.len() { + assert!( + RadixNode::decode(&bytes[..end]).is_err(), + "{}: {end}", + vector["name"] + ); + } + let mut trailing = bytes; + trailing.push(0); + assert!(RadixNode::decode(&trailing).is_err()); + } + assert_eq!( + RadixNode::decode(&vec![0; NODE_MAX_BYTES + 1]).unwrap_err(), + CodecError::BadLength("radix node") + ); +} + +#[test] +fn domain_presence_tags_and_unbounded_lengths_fail_closed() { + let empty = RadixNode::empty().encode(); + let mut domain = empty.clone(); + domain[0] ^= 1; + assert!(RadixNode::decode(&domain).is_err()); + for tag in [2, 255] { + let mut bad = empty.clone(); + bad[NODE_DOMAIN.len() + 2] = tag; + assert!(RadixNode::decode(&bad).is_err()); + } + for label_length in [4097u16, u16::MAX] { + let mut bad = empty.clone(); + bad[NODE_DOMAIN.len()..NODE_DOMAIN.len() + 2].copy_from_slice(&label_length.to_be_bytes()); + assert_eq!( + RadixNode::decode(&bad).unwrap_err(), + CodecError::BadLength("radix label") + ); + } + for count in [257u16, u16::MAX] { + let mut bad = empty.clone(); + let start = NODE_DOMAIN.len() + 3; + bad[start..start + 2].copy_from_slice(&count.to_be_bytes()); + assert_eq!( + RadixNode::decode(&bad).unwrap_err(), + CodecError::BadLength("radix fanout") + ); + } +} + +#[test] +fn duplicate_and_unsorted_edges_are_not_normalized_into_canonical_nodes() { + let branch = RadixNode::new(Vec::new(), None, vec![child(b'a', 1), child(b'b', 2)]).unwrap(); + let first_edge = NODE_DOMAIN.len() + 2 + 1 + 2; + for second_edge in *b"a0" { + let mut bad = branch.encode(); + bad[first_edge + 33] = second_edge; + assert!(matches!( + RadixNode::decode(&bad), + Err(CodecError::BadOrdering(_)) + )); + } + for children in [ + vec![child(b'a', 1), child(b'a', 2)], + vec![child(b'b', 1), child(b'a', 2)], + ] { + assert!(RadixNode::new(Vec::new(), None, children).is_err()); + } +} + +#[test] +fn noncanonical_uncompressed_nodes_fail_in_constructors_and_decoder() { + assert!(RadixNode::new(Vec::new(), None, vec![child(b'a', 1)]).is_err()); + assert!(RadixNode::new(b"a".to_vec(), None, Vec::new()).is_err()); + // Handwritten encodings deliberately bypass constructors. + let mut one_child = NODE_DOMAIN.to_vec(); + one_child.extend_from_slice(&[0, 0, 0, 0, 1, b'a']); + one_child.extend_from_slice(&[1; 32]); + assert!(RadixNode::decode(&one_child).is_err()); + let mut labelled_empty = NODE_DOMAIN.to_vec(); + labelled_empty.extend_from_slice(&[0, 1, b'a', 0, 0, 0]); + assert!(RadixNode::decode(&labelled_empty).is_err()); + let root_binding = RadixNode::new(Vec::new(), Some([1; 32]), Vec::new()).unwrap(); + assert!(root_binding.binding().is_some()); + let bound_branch = + RadixNode::new(b"a\0".to_vec(), Some([1; 32]), vec![child(b'b', 2)]).unwrap(); + assert_eq!( + RadixNode::decode(&bound_branch.encode()).unwrap(), + bound_branch + ); +} + +#[test] +fn maximum_label_and_all_256_byte_edges_fit_the_frozen_node_budget() { + let children = (0..=255).map(|edge| child(edge, edge)).collect(); + let node = RadixNode::new(vec![b'a'; PATH_MAX_BYTES], Some([7; 32]), children).unwrap(); + assert_eq!(node.children().first().unwrap().edge, 0); + assert_eq!(node.children().last().unwrap().edge, 255); + assert_eq!(node.children().len(), CHILD_MAX_COUNT); + assert!(node.encode().len() <= NODE_MAX_BYTES); + assert_eq!(RadixNode::decode(&node.encode()).unwrap(), node); + assert!(RadixNode::new(vec![b'a'; PATH_MAX_BYTES + 1], Some([7; 32]), Vec::new()).is_err()); + assert!(RadixNode::new(Vec::new(), Some([7; 32]), vec![child(0, 1); 257]).is_err()); +} + +#[test] +fn local_labels_may_split_utf8_but_complete_value_keys_must_be_valid() { + let node = RadixNode::new(vec![0xa9, 0], Some([7; 32]), Vec::new()).unwrap(); + assert_eq!(node.label(), &[0xa9, 0]); + assert_eq!(RadixNode::decode(&node.encode()).unwrap(), node); + assert!( + decode_key(node.label()).is_err(), + "child label is not a complete key" + ); + let assembled = [vec![0xc3], node.label().to_vec()].concat(); + assert_eq!(decode_key(&assembled).unwrap(), "/é"); + let prefix = RadixNode::new(vec![0xc3], None, vec![child(0xa9, 1), child(0xb1, 2)]).unwrap(); + assert_eq!(RadixNode::decode(&prefix.encode()).unwrap(), prefix); + let overlong_assembled = [vec![b'a'; 3000], vec![b'b'; 2000], vec![0]].concat(); + assert!(decode_key(&overlong_assembled).is_err()); +} + +#[test] +fn component_key_bytes_match_handwritten_examples_and_do_not_match_neighbors() { + for (path, expected) in [ + ("/", b"".as_slice()), + ("/a", b"a\0".as_slice()), + ("/a/b", b"a\0b\0".as_slice()), + ("/x/lib", b"x\0lib\0".as_slice()), + ("/x/library", b"x\0library\0".as_slice()), + ] { + assert_eq!(encode_key(path).unwrap(), expected); + assert_eq!(decode_key(expected).unwrap(), path); + } + let ancestor = encode_key("/x/lib").unwrap(); + assert!(encode_key("/x/lib/file").unwrap().starts_with(&ancestor)); + assert!(!encode_key("/x/library/file") + .unwrap() + .starts_with(&ancestor)); +} + +#[test] +fn keys_use_encoded_byte_order_preserve_case_unicode_plus_and_literal_backslash() { + assert!(encode_key("/a").unwrap() < encode_key("/a/b").unwrap()); + assert!(encode_key("/a/b").unwrap() < encode_key("/a+").unwrap()); + assert!("/a/b" > "/a+", "slash-string order is a different order"); + for path in ["/Case", "/case", "/库+1/\\literal", "/é", "/e\u{301}"] { + assert_eq!(decode_key(&encode_key(path).unwrap()).unwrap(), path); + } + assert_ne!(encode_key("/Case").unwrap(), encode_key("/case").unwrap()); + assert_ne!(encode_key("/é").unwrap(), encode_key("/e\u{301}").unwrap()); +} + +#[test] +fn noncanonical_complete_keys_are_rejected_without_aliasing_paths() { + for key in [ + b"a".as_slice(), + b"\0".as_slice(), + b"\0a\0".as_slice(), + b"a\0\0".as_slice(), + b"a/b\0".as_slice(), + b".\0".as_slice(), + b"..\0".as_slice(), + &[0xff, 0], + ] { + assert!(decode_key(key).is_err(), "{key:?}"); + } + let mut overlong_component = vec![b'a'; 256]; + overlong_component.push(0); + assert!(decode_key(&overlong_component).is_err()); + assert!(decode_key(&vec![0; PATH_MAX_BYTES + 1]).is_err()); + for path in ["", "relative", "/a/", "/a//b", "/a/..", "/a\0"] { + assert!(encode_key(path).is_err(), "{path:?}"); + } +} + +#[test] +fn exact_key_byte_component_limits_and_legacy_depth_are_preserved() { + let path = format!("/{}", vec!["a".repeat(255); 16].join("/")); + assert_eq!(path.len(), PATH_MAX_BYTES); + let key = encode_key(&path).unwrap(); + assert_eq!(key.len(), PATH_MAX_BYTES); + assert_eq!(decode_key(&key).unwrap(), path); + assert!(encode_key(&format!("{path}/z")).is_err()); + assert!(encode_key(&format!("/{}a", "é".repeat(127))).is_ok()); + assert!(encode_key(&format!("/{}", "é".repeat(128))).is_err()); + let legacy_deep = format!("/{}", vec!["a"; 257].join("/")); + assert_eq!( + decode_key(&encode_key(&legacy_deep).unwrap()).unwrap(), + legacy_deep + ); +} + +#[test] +fn identity_covers_labels_binding_values_edges_and_child_digests() { + let base = RadixNode::new(b"a\0".to_vec(), Some([1; 32]), vec![child(b'b', 2)]).unwrap(); + let changed = [ + RadixNode::new(b"c\0".to_vec(), Some([1; 32]), vec![child(b'b', 2)]).unwrap(), + RadixNode::new(b"a\0".to_vec(), Some([3; 32]), vec![child(b'b', 2)]).unwrap(), + RadixNode::new(b"a\0".to_vec(), Some([1; 32]), vec![child(b'c', 2)]).unwrap(), + RadixNode::new(b"a\0".to_vec(), Some([1; 32]), vec![child(b'b', 3)]).unwrap(), + ]; + for node in changed { + assert_ne!(base.id(), node.id()); + } +} diff --git a/tests/fixtures/namespace-radix-v1.json b/tests/fixtures/namespace-radix-v1.json new file mode 100644 index 0000000..3b7b2ed --- /dev/null +++ b/tests/fixtures/namespace-radix-v1.json @@ -0,0 +1,17 @@ +[ + { + "name": "empty", + "canonical_hex": "6d6567612e6e616d6573706163652d72616469782e7631000000000000", + "digest": "sha256:18946486089198dfa8eeb70fa90e04b137c579dc08ae1e6f8bceafc0d35ef677" + }, + { + "name": "leaf-a", + "canonical_hex": "6d6567612e6e616d6573706163652d72616469782e763100000261000111111111111111111111111111111111111111111111111111111111111111110000", + "digest": "sha256:0daf3f5407c62015b13000cc10e525975b5893f08bdbbe2a919d63c8bdc18cae" + }, + { + "name": "branch-ab", + "canonical_hex": "6d6567612e6e616d6573706163652d72616469782e7631000000000002612222222222222222222222222222222222222222222222222222222222222222623333333333333333333333333333333333333333333333333333333333333333", + "digest": "sha256:6eff5588f487756adb7b0b1834929ae1f7800e0c15553cc54836b78b9501e74e" + } +]