From f411cd65e97a36c8b30bce3173159451b2623def Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 23 Oct 2026 05:19:24 -0400 Subject: [PATCH 1/2] feat: keep source reconciliation inputs trustworthy Signed-off-by: Yordis Prieto --- docs/README.md | 1 + .../architecture-resource-roadmap.md | 10 +- docs/reference/source-bundle-profile.md | 129 ++++ .../source-integration-acceptance.md | 7 +- .../reference/source-integration-contracts.md | 17 +- rsworkspace/Cargo.lock | 1 + .../crates/trogon-atlas-core/Cargo.toml | 1 + .../crates/trogon-atlas-core/src/lib.rs | 1 + .../trogon-atlas-core/src/source_bundle.rs | 601 ++++++++++++++++++ .../trogon-atlas-core/tests/source_bundle.rs | 507 +++++++++++++++ .../tests/source_bundle_vectors.rs | 55 ++ 11 files changed, 1317 insertions(+), 13 deletions(-) create mode 100644 docs/reference/source-bundle-profile.md create mode 100644 rsworkspace/crates/trogon-atlas-core/src/source_bundle.rs create mode 100644 rsworkspace/crates/trogon-atlas-core/tests/source_bundle.rs create mode 100644 rsworkspace/crates/trogon-atlas-core/tests/source_bundle_vectors.rs diff --git a/docs/README.md b/docs/README.md index 9693469..96a955d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -48,6 +48,7 @@ Resource contracts and acceptance: - [Responsibility acceptance criteria](reference/responsibility-acceptance.md) - [Decision and Document contracts](reference/decision-and-document-contracts.md) - [Decision and Document acceptance requirements](reference/decision-and-document-acceptance.md) +- [Supplied source bundle profile](reference/source-bundle-profile.md) - [Source integration contracts](reference/source-integration-contracts.md) - [Source integration acceptance requirements](reference/source-integration-acceptance.md) diff --git a/docs/explanation/architecture-resource-roadmap.md b/docs/explanation/architecture-resource-roadmap.md index b730480..efc781e 100644 --- a/docs/explanation/architecture-resource-roadmap.md +++ b/docs/explanation/architecture-resource-roadmap.md @@ -108,9 +108,11 @@ are later milestones with explicit acceptance criteria. Native Channel and DataStore support does not require a source connector, a source overlay, Specification resources or format-specific compatibility checking. -Native manifest export already exists. Source integration still needs its own -mapping, provenance, reconciliation and export-fidelity contracts; it must -preserve the existing behavioral anchors and exact selections. +Native manifest export already exists. The +[supplied bundle profile](../reference/source-bundle-profile.md) implements strict +byte intake and deterministic digests in Rust core. Source mapping, retained +provenance, reconciliation and export fidelity remain pending; they must preserve +the existing behavioral anchors and exact selections. The proposed [source integration contract](../reference/source-integration-contracts.md) defines a native-first boundary for immutable supplied bundles, explicit @@ -118,7 +120,7 @@ identity and version mappings, provenance and guarded reconciliation. Its [acceptance requirements](../reference/source-integration-acceptance.md) distinguish same-store reapply from migration into a new store, where incarnation-bound selectors require explicit rebinding. These are proposed contracts; source -records, reconciliation operations and executable acceptance are not implemented. +records, reconciliation operations and their executable acceptance remain pending. Remote connectors and additional format compilers remain separate milestones. ## Atlas Linter comes after the architecture resources diff --git a/docs/reference/source-bundle-profile.md b/docs/reference/source-bundle-profile.md new file mode 100644 index 0000000..8e72fd0 --- /dev/null +++ b/docs/reference/source-bundle-profile.md @@ -0,0 +1,129 @@ +# Supplied source bundle profile + +Status: implemented in the Rust core `source_bundle` module. This intake boundary +validates supplied bytes and computes digests. Registration, retained snapshots, +source overlays and reconciliation remain proposed in the +[source integration contract](source-integration-contracts.md). + +## Inventory JSON + +The profile name is `supplied-native-v1` and `profile_revision` is the integer +`1`. The inventory is strict UTF-8 JSON. Field names are case-sensitive; every +field below is required except `coverage`, which defaults to `PARTIAL`. +Unknown fields, duplicate object keys at any depth, wrong types and trailing +JSON values reject. JSON object-key order and whitespace remain original bytes. + +```json +{ + "profile": "supplied-native-v1", + "profile_revision": 1, + "registration": "registry-a", + "source_version": "revision-A", + "partition": { + "categories": ["model"], + "logical_roots": ["native"], + "selected_keys": [] + }, + "coverage": "PARTIAL", + "resources": [ + { + "key": {"category": "model", "key": "orders"}, + "file": "native/model.yaml", + "pointer": {"kind": "yaml", "raw": "#/orders"} + } + ], + "relations": [ + { + "owner": {"category": "model", "key": "orders"}, + "occurrence": "observes-orders", + "role": "observes", + "position": 0, + "target": {"kind": "opaque", "raw": "unresolved-target"}, + "file": "native/model.yaml" + } + ] +} +``` + +Registration, source version, category, resource key, occurrence key, pointer +kind and role are opaque case-sensitive strings. They must be nonblank, contain +no Unicode control characters and occupy at most 1,024 UTF-8 bytes. They are +retained without trimming, normalization or native version conversion. + +A pointer's `raw` value is any JSON string occupying at most 16,384 decoded UTF-8 +bytes. Empty strings and escaped controls are permitted and retained verbatim +as decoded strings; the original JSON spelling remains in `inventory_bytes`. +Pointers are never resolved, fetched or executed. A relation's `position` is an +unsigned 64-bit integer, independent of its array index. Authored array order is +preserved. Resource keys must be unique. Occurrence keys must be unique within +their owner; repeated equal targets with different occurrence keys remain +separate declarations. Each relation owner must occur in `resources`. + +## Partition and file locators + +`categories` and `logical_roots` must be nonempty unique lists. `selected_keys` +is a unique list of resource-key objects whose categories must be declared. +An empty `selected_keys` list selects all keys within the declared categories +and roots. A nonempty list restricts enumerated resources to those exact keys. +Selected keys may be absent from `resources`, including under `COMPLETE` coverage. +Their absence supplies no deletion authority at this intake boundary. + +Every resource and relation `file` must name a supplied file and match a logical +root exactly or begin with that root followed by `/`. Supplied files outside the +roots are permitted as retained inert bytes. No filesystem lookup occurs. + +Logical names and roots are case-sensitive relative slash-separated strings of +at most 1,024 UTF-8 bytes. Empty segments, `.`/`..`, leading or trailing slash, +backslash, colon anywhere and Unicode control characters reject. No path or +Unicode normalization occurs. Supplied file names must be unique. Media types +are required nonblank ASCII strings without controls, at most 1,024 bytes, and +are retained without trimming or inference. File payloads are unrestricted bytes; +archive content may be retained but is never expanded or treated as packaging. + +`COMPLETE` and `PARTIAL` are the only coverage values. The validator retains the +supplier's declaration; it does not verify external completeness, authorization, +native validity or snapshot acceptance. + +## Intake bounds + +| Input | Inclusive ceiling | +| --- | --- | +| Original inventory bytes | 4 MiB | +| Supplied files | 256 | +| Each file payload | 4 MiB | +| Inventory plus file payload bytes | 32 MiB | +| Resources | 1,000 | +| Relation occurrences | 10,000 | +| Each partition list | 1,000 | +| JSON container depth, root container counted as 1 | 32 | + +A MiB is 1,048,576 bytes. JSON depth, duplicate keys and collection ceilings +are checked before typed collection deserialization. Transport metadata is +bounded separately by the logical-name and media-type limits. Source run, +retention and publication budgets belong to the proposed control plane. + +## Rust boundary and digests + +`source_bundle::validate_bundle(&[u8], &[SuppliedFile])` returns an immutable, +owned `ValidatedBundle` or a sanitized `BundleError`. A `SuppliedFile` borrows +`logical_name`, `media_type` and `bytes`; validation retains independent copies. +No disk, network, compilation or native model operations occur. + +The result exposes `inventory_bytes()`, typed `inventory()`, `files()`, +`inventory_digest()` and `bundle_digest()`. Files are returned sorted by exact +logical-name UTF-8 bytes; resource and relation arrays retain authored order. +Each retained file exposes its original bytes, typed name/media type and digest. +Digest values expose `purpose()`, `bytes()` and lowercase `hex()`; inventory, +bundle and raw file-content purposes are distinct. Their encoding is defined +by the [digest contract](source-integration-contracts.md#supplied-byte-digest-encoding). + +Errors retain fixed categories without raw pointers, unknown keys, input content +or serializer diagnostics. `JsonDepth` identifies excessive nesting; `Inventory` +covers malformed JSON and field shape. Other categories distinguish limits, +text, locators, media types, profile, duplicates, partition and unavailable +supplied owners/files. These categories do not establish native resolution. + +Public [boundary tests](../../rsworkspace/crates/trogon-atlas-core/tests/source_bundle.rs) +and [fixed digest vectors](../../rsworkspace/crates/trogon-atlas-core/tests/source_bundle_vectors.rs) +cover the implemented intake slice. They do not prove source persistence or +reconciliation acceptance. diff --git a/docs/reference/source-integration-acceptance.md b/docs/reference/source-integration-acceptance.md index 0d7926d..a253573 100644 --- a/docs/reference/source-integration-acceptance.md +++ b/docs/reference/source-integration-acceptance.md @@ -5,8 +5,11 @@ Status: proposed acceptance requirements for the records and source run operations are not implemented by this document. Linked native fixtures provide existing anchors and examples of native behavior; they do not prove source registration, snapshot retention, reconciliation, -coordinated publication, cancellation or fidelity reporting. Every source case -below requires future executable evidence. +coordinated publication, cancellation or fidelity reporting. The [supplied bundle profile](source-bundle-profile.md) has executable core +[boundary tests](../../rsworkspace/crates/trogon-atlas-core/tests/source_bundle.rs) +and [digest vectors](../../rsworkspace/crates/trogon-atlas-core/tests/source_bundle_vectors.rs). +They cover intake only. Source retention, native mapping and run cases below +require future executable evidence. ## Connected fixture inputs diff --git a/docs/reference/source-integration-contracts.md b/docs/reference/source-integration-contracts.md index e735d7a..c588a36 100644 --- a/docs/reference/source-integration-contracts.md +++ b/docs/reference/source-integration-contracts.md @@ -1,6 +1,7 @@ # Source integration contracts -Status: proposed, native-first design. Source registration, retained snapshots, +Status: source control design proposed; the [supplied bundle profile](source-bundle-profile.md) +and its Rust core validator are implemented. Source registration, retained snapshots, overlays, reconciliation runs and their persistence are not implemented by this reference. Channel/DataStore, Specification/API, DataProduct, Team/Person and Decision/Document are implemented native foundations. Their contracts remain @@ -81,13 +82,14 @@ encoding below, independently of native content normalization. ### Supplied-byte digest encoding -The proposed `supplied-native-v1` profile accepts an inventory document and a -finite set of named files; it does not accept archives. The inventory is exact +The implemented `supplied-native-v1` intake profile accepts an inventory document +and a finite set of named files; it does not unpack archive containers. The inventory is exact UTF-8 JSON bytes describing the registration, opaque source version, input profile revision, partition/coverage, resource keys, ordered relation occurrences and pointers. Repeated object keys, invalid JSON and invalid UTF-8 reject before -acceptance. Its eventual typed wire schema must represent those fields without -an arbitrary-property escape hatch. Whitespace and object-key order are retained +intake. Its typed JSON shape is defined in the +[profile reference](source-bundle-profile.md#inventory-json), without an +arbitrary-property escape hatch. Whitespace and object-key order are retained input bytes, so changing either changes source identity even if native content would compare equal. @@ -114,10 +116,11 @@ against the profile's limits. Logical names are unique, case-sensitive, relative slash-separated names, at most 1,024 UTF-8 bytes. Empty segments, `.`/`..`, leading/trailing slash, -backslash, control characters, URI schemes and drive prefixes reject; no path +backslash, colon anywhere, control characters, URI schemes and drive prefixes +reject; no path or Unicode normalization is performed. Neither logical names nor media types are trimmed or inferred. A media type is required nonblank ASCII without control -characters. Duplicate logical names reject rather than overwrite. File-list +characters, at most 1,024 bytes. Duplicate logical names reject rather than overwrite. File-list transport order does not affect the digest; authored inventory relation/list order does. Changing version, scope or coverage must change the inventory bytes and therefore its digest. Transport timestamps, current native resolutions and diff --git a/rsworkspace/Cargo.lock b/rsworkspace/Cargo.lock index c535733..3c6b0ff 100644 --- a/rsworkspace/Cargo.lock +++ b/rsworkspace/Cargo.lock @@ -4353,6 +4353,7 @@ dependencies = [ "prost", "prost-reflect", "prost-types", + "serde", "serde_json", "sha2 0.11.1", "thiserror", diff --git a/rsworkspace/crates/trogon-atlas-core/Cargo.toml b/rsworkspace/crates/trogon-atlas-core/Cargo.toml index 69c1474..e006856 100644 --- a/rsworkspace/crates/trogon-atlas-core/Cargo.toml +++ b/rsworkspace/crates/trogon-atlas-core/Cargo.toml @@ -16,6 +16,7 @@ prost = { workspace = true } prost-types = { workspace = true } prost-reflect = { version = "0.16", features = ["serde"] } serde_json = { workspace = true } +serde = { workspace = true } sha2 = { workspace = true } thiserror = { workspace = true } url = "2.5" diff --git a/rsworkspace/crates/trogon-atlas-core/src/lib.rs b/rsworkspace/crates/trogon-atlas-core/src/lib.rs index c1f270c..536a241 100644 --- a/rsworkspace/crates/trogon-atlas-core/src/lib.rs +++ b/rsworkspace/crates/trogon-atlas-core/src/lib.rs @@ -28,6 +28,7 @@ pub mod responsibility; mod responsibility_refs; pub mod schema; pub mod semantic_eq; +pub mod source_bundle; pub mod specification; mod specification_refs; pub mod system_meta; diff --git a/rsworkspace/crates/trogon-atlas-core/src/source_bundle.rs b/rsworkspace/crates/trogon-atlas-core/src/source_bundle.rs new file mode 100644 index 0000000..27c0356 --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-core/src/source_bundle.rs @@ -0,0 +1,601 @@ +//! Exact supplied-byte intake. Validation establishes local shape and digests only, +//! not native validity, authorization, external completeness or snapshot acceptance. + +use std::{cell::Cell, collections::BTreeSet, fmt}; + +use serde::{ + de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor}, + Deserialize, Deserializer, +}; +use sha2::{Digest, Sha256}; + +pub const MAX_INVENTORY_BYTES: usize = 4 * 1024 * 1024; +pub const MAX_FILES: usize = 256; +pub const MAX_FILE_BYTES: usize = 4 * 1024 * 1024; +pub const MAX_COMBINED_BYTES: usize = 32 * 1024 * 1024; +pub const MAX_RESOURCES: usize = 1000; +pub const MAX_RELATIONS: usize = 10_000; +pub const MAX_JSON_DEPTH: usize = 32; +pub const PROFILE: &str = "supplied-native-v1"; +pub const PROFILE_REVISION: u32 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum BundleError { + #[error("input exceeds intake limit")] + Limit, + #[error("JSON nesting exceeds intake limit")] + JsonDepth, + #[error("invalid inventory JSON or field shape")] + Inventory, + #[error("invalid opaque text")] + Text, + #[error("invalid logical file locator")] + Locator, + #[error("invalid media type")] + MediaType, + #[error("unsupported input profile or revision")] + Profile, + #[error("duplicate durable input key")] + Duplicate, + #[error("inventory declaration is outside its partition")] + Partition, + #[error("inventory locator or owner is unavailable in supplied input")] + Unavailable, +} + +macro_rules! text_value { + ($name:ident, $check:expr) => { + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize)] + #[serde(try_from = "String")] + pub struct $name(String); + impl $name { + pub fn new(value: &str) -> Result { + ($check)(value)?; + Ok(Self(value.to_owned())) + } + pub fn as_str(&self) -> &str { + &self.0 + } + } + impl TryFrom for $name { + type Error = BundleError; + fn try_from(value: String) -> Result { + ($check)(&value)?; + Ok(Self(value)) + } + } + }; +} +fn token(value: &str) -> Result<(), BundleError> { + if value.len() > 1024 || value.trim().is_empty() || value.chars().any(char::is_control) { + return Err(BundleError::Text); + } + Ok(()) +} +fn locator(value: &str) -> Result<(), BundleError> { + if value.is_empty() + || value.len() > 1024 + || value.contains(['\\', ':']) + || value.chars().any(char::is_control) + || value + .split('/') + .any(|s| s.is_empty() || s == "." || s == "..") + { + return Err(BundleError::Locator); + } + Ok(()) +} +fn media_type(value: &str) -> Result<(), BundleError> { + if value.len() > 1024 + || !value.is_ascii() + || value.trim().is_empty() + || value.bytes().any(|b| b.is_ascii_control()) + { + return Err(BundleError::MediaType); + } + Ok(()) +} +fn raw_pointer(value: &str) -> Result<(), BundleError> { + if value.len() > 16_384 { + return Err(BundleError::Limit); + } + Ok(()) +} +text_value!(RegistrationId, token); +text_value!(SourceVersion, token); +text_value!(SourceCategory, token); +text_value!(SourceLocalKey, token); +text_value!(OccurrenceKey, token); +text_value!(RelationRole, token); +text_value!(PointerKind, token); +text_value!(RawPointer, raw_pointer); +text_value!(LogicalName, locator); +text_value!(MediaType, media_type); + +macro_rules! accessors { + ($($field:ident : $ty:ty),* $(,)?) => { $(pub fn $field(&self) -> &$ty { &self.$field })* }; +} +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ResourceKey { + category: SourceCategory, + key: SourceLocalKey, +} +impl ResourceKey { + pub fn new(category: SourceCategory, key: SourceLocalKey) -> Self { + Self { category, key } + } + accessors!(category: SourceCategory, key: SourceLocalKey); +} +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SourcePointer { + kind: PointerKind, + raw: RawPointer, +} +impl SourcePointer { + accessors!(kind: PointerKind, raw: RawPointer); +} +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +pub enum DeclaredCoverage { + #[serde(rename = "COMPLETE")] + Complete, + #[default] + #[serde(rename = "PARTIAL")] + Partial, +} +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InventoryPartition { + categories: Vec, + logical_roots: Vec, + selected_keys: Vec, +} +impl InventoryPartition { + pub fn categories(&self) -> &[SourceCategory] { + &self.categories + } + pub fn logical_roots(&self) -> &[LogicalName] { + &self.logical_roots + } + pub fn selected_keys(&self) -> &[ResourceKey] { + &self.selected_keys + } +} +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SourceResource { + key: ResourceKey, + file: LogicalName, + pointer: SourcePointer, +} +impl SourceResource { + accessors!(key: ResourceKey, file: LogicalName, pointer: SourcePointer); +} +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RelationOccurrence { + owner: ResourceKey, + occurrence: OccurrenceKey, + role: RelationRole, + position: u64, + target: SourcePointer, + file: LogicalName, +} +impl RelationOccurrence { + accessors!(owner: ResourceKey, occurrence: OccurrenceKey, role: RelationRole, target: SourcePointer, file: LogicalName); + pub fn position(&self) -> u64 { + self.position + } +} +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Inventory { + profile: String, + profile_revision: u32, + registration: RegistrationId, + source_version: SourceVersion, + partition: InventoryPartition, + #[serde(default)] + coverage: DeclaredCoverage, + resources: Vec, + relations: Vec, +} +impl Inventory { + accessors!(registration: RegistrationId, source_version: SourceVersion, partition: InventoryPartition); + pub fn profile(&self) -> &str { + &self.profile + } + pub fn profile_revision(&self) -> u32 { + self.profile_revision + } + pub fn declared_coverage(&self) -> DeclaredCoverage { + self.coverage + } + pub fn resources(&self) -> &[SourceResource] { + &self.resources + } + pub fn relations(&self) -> &[RelationOccurrence] { + &self.relations + } +} + +#[derive(Debug, Clone, Copy)] +pub struct SuppliedFile<'a> { + pub logical_name: &'a str, + pub media_type: &'a str, + pub bytes: &'a [u8], +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DigestPurpose { + InventoryV1, + BundleV1, + FileContentSha256, +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceDigest { + purpose: DigestPurpose, + bytes: [u8; 32], +} +impl SourceDigest { + pub fn purpose(&self) -> DigestPurpose { + self.purpose + } + pub fn bytes(&self) -> &[u8; 32] { + &self.bytes + } + pub fn hex(&self) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + self.bytes + .iter() + .flat_map(|b| { + [ + char::from(HEX[usize::from(b >> 4)]), + char::from(HEX[usize::from(b & 15)]), + ] + }) + .collect() + } +} +#[derive(Debug)] +pub struct ValidatedFile { + logical_name: LogicalName, + media_type: MediaType, + bytes: Vec, + digest: SourceDigest, +} +impl ValidatedFile { + accessors!(logical_name: LogicalName, media_type: MediaType, digest: SourceDigest); + pub fn bytes(&self) -> &[u8] { + &self.bytes + } +} +#[derive(Debug)] +pub struct ValidatedBundle { + inventory: Inventory, + inventory_bytes: Vec, + files: Vec, + inventory_digest: SourceDigest, + bundle_digest: SourceDigest, +} +impl ValidatedBundle { + accessors!(inventory: Inventory, inventory_digest: SourceDigest, bundle_digest: SourceDigest); + pub fn inventory_bytes(&self) -> &[u8] { + &self.inventory_bytes + } + /// Files are ordered by exact logical-name UTF-8 bytes. + pub fn files(&self) -> &[ValidatedFile] { + &self.files + } +} + +#[derive(Clone, Copy)] +enum JsonShape { + Any, + Inventory, + Partition, + Resource, + ResourceKey, + Relation, + Pointer, + Resources, + Relations, + ResourceKeys, + Coverage, +} +impl JsonShape { + fn field(self, key: &str) -> Self { + match (self, key) { + (Self::Inventory, "partition") => Self::Partition, + (Self::Inventory, "resources") => Self::Resources, + (Self::Inventory, "relations") => Self::Relations, + (Self::Inventory, "coverage") => Self::Coverage, + (Self::Partition, "selected_keys") => Self::ResourceKeys, + (Self::Resource, "key") | (Self::Relation, "owner") => Self::ResourceKey, + (Self::Resource, "pointer") | (Self::Relation, "target") => Self::Pointer, + _ => Self::Any, + } + } + fn element(self) -> Option { + match self { + Self::Any => Some(Self::Any), + Self::Resources => Some(Self::Resource), + Self::Relations => Some(Self::Relation), + Self::ResourceKeys => Some(Self::ResourceKey), + _ => None, + } + } + fn permits_object(self) -> bool { + matches!( + self, + Self::Any + | Self::Inventory + | Self::Partition + | Self::Resource + | Self::ResourceKey + | Self::Relation + | Self::Pointer + ) + } + fn scalar(self) -> Result<(), E> { + if matches!(self, Self::Any) { + Ok(()) + } else { + Err(de::Error::custom("shape")) + } + } +} + +// Reject Serde's alternate positional and enum shapes before typed allocation. +struct JsonCheck<'a> { + shape: JsonShape, + depth_exceeded: &'a Cell, + depth: usize, + list_limit: usize, +} +impl<'de> DeserializeSeed<'de> for JsonCheck<'_> { + type Value = (); + fn deserialize>(self, d: D) -> Result<(), D::Error> { + d.deserialize_any(self) + } +} +impl<'de> Visitor<'de> for JsonCheck<'_> { + type Value = (); + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("bounded JSON") + } + fn visit_bool(self, _: bool) -> Result<(), E> { + self.shape.scalar() + } + fn visit_i64(self, _: i64) -> Result<(), E> { + self.shape.scalar() + } + fn visit_u64(self, _: u64) -> Result<(), E> { + self.shape.scalar() + } + fn visit_f64(self, _: f64) -> Result<(), E> { + self.shape.scalar() + } + fn visit_str(self, _: &str) -> Result<(), E> { + if matches!(self.shape, JsonShape::Coverage) { + Ok(()) + } else { + self.shape.scalar() + } + } + fn visit_unit(self) -> Result<(), E> { + self.shape.scalar() + } + fn visit_seq>(self, mut a: A) -> Result<(), A::Error> { + let element_shape = self + .shape + .element() + .ok_or_else(|| de::Error::custom("shape"))?; + if self.depth >= MAX_JSON_DEPTH { + self.depth_exceeded.set(true); + return Err(de::Error::custom("depth")); + } + let mut n = 0usize; + while a + .next_element_seed(JsonCheck { + shape: element_shape, + depth_exceeded: self.depth_exceeded, + depth: self.depth + 1, + list_limit: MAX_RELATIONS, + })? + .is_some() + { + n = n.checked_add(1).ok_or_else(|| de::Error::custom("count"))?; + if n > self.list_limit { + return Err(de::Error::custom("count")); + } + } + Ok(()) + } + fn visit_map>(self, mut a: A) -> Result<(), A::Error> { + if !self.shape.permits_object() { + return Err(de::Error::custom("shape")); + } + if self.depth >= MAX_JSON_DEPTH { + self.depth_exceeded.set(true); + return Err(de::Error::custom("depth")); + } + let mut keys = BTreeSet::new(); + while let Some(key) = a.next_key::()? { + let limit = match key.as_str() { + "resources" | "categories" | "logical_roots" | "selected_keys" => MAX_RESOURCES, + _ => MAX_RELATIONS, + }; + let shape = self.shape.field(&key); + if !keys.insert(key) { + return Err(de::Error::custom("duplicate")); + } + a.next_value_seed(JsonCheck { + shape, + depth_exceeded: self.depth_exceeded, + depth: self.depth + 1, + list_limit: limit, + })?; + } + Ok(()) + } +} +fn frame(hasher: &mut Sha256, bytes: &[u8]) -> Result<(), BundleError> { + let length = u64::try_from(bytes.len()).map_err(|_| BundleError::Limit)?; + hasher.update(length.to_be_bytes()); + hasher.update(bytes); + Ok(()) +} +fn finish(hasher: Sha256, purpose: DigestPurpose) -> SourceDigest { + SourceDigest { + purpose, + bytes: hasher.finalize().into(), + } +} +fn unique(items: &[T]) -> bool { + items.iter().collect::>().len() == items.len() +} +fn in_roots(file: &LogicalName, roots: &[LogicalName]) -> bool { + roots.iter().any(|root| { + file == root + || file + .as_str() + .strip_prefix(root.as_str()) + .is_some_and(|rest| rest.starts_with('/')) + }) +} + +pub fn validate_bundle( + inventory_bytes: &[u8], + files: &[SuppliedFile<'_>], +) -> Result { + if inventory_bytes.len() > MAX_INVENTORY_BYTES || files.len() > MAX_FILES { + return Err(BundleError::Limit); + } + let mut total = inventory_bytes.len(); + for file in files { + if file.bytes.len() > MAX_FILE_BYTES { + return Err(BundleError::Limit); + } + total = total + .checked_add(file.bytes.len()) + .ok_or(BundleError::Limit)?; + if total > MAX_COMBINED_BYTES { + return Err(BundleError::Limit); + } + locator(file.logical_name)?; + media_type(file.media_type)?; + } + let mut decoder = serde_json::Deserializer::from_slice(inventory_bytes); + let depth_exceeded = Cell::new(false); + JsonCheck { + shape: JsonShape::Inventory, + depth_exceeded: &depth_exceeded, + depth: 0, + list_limit: MAX_RELATIONS, + } + .deserialize(&mut decoder) + .map_err(|_| { + if depth_exceeded.get() { + BundleError::JsonDepth + } else { + BundleError::Inventory + } + })?; + decoder.end().map_err(|_| BundleError::Inventory)?; + let inventory: Inventory = + serde_json::from_slice(inventory_bytes).map_err(|_| BundleError::Inventory)?; + if inventory.profile != PROFILE || inventory.profile_revision != PROFILE_REVISION { + return Err(BundleError::Profile); + } + let partition = &inventory.partition; + if partition.categories.is_empty() || partition.logical_roots.is_empty() { + return Err(BundleError::Partition); + } + if !unique(&partition.categories) + || !unique(&partition.logical_roots) + || !unique(&partition.selected_keys) + { + return Err(BundleError::Duplicate); + } + let categories: BTreeSet<_> = partition.categories.iter().collect(); + let selected: BTreeSet<_> = partition.selected_keys.iter().collect(); + if selected + .iter() + .any(|key| !categories.contains(&key.category)) + { + return Err(BundleError::Partition); + } + let mut sorted_files: Vec<_> = files.iter().collect(); + sorted_files.sort_by(|a, b| a.logical_name.as_bytes().cmp(b.logical_name.as_bytes())); + if sorted_files + .windows(2) + .any(|pair| pair[0].logical_name == pair[1].logical_name) + { + return Err(BundleError::Duplicate); + } + let names: BTreeSet<_> = files.iter().map(|f| f.logical_name).collect(); + let check_file = |file: &LogicalName| { + if !in_roots(file, &partition.logical_roots) { + return Err(BundleError::Partition); + } + if !names.contains(file.as_str()) { + return Err(BundleError::Unavailable); + } + Ok(()) + }; + let mut keys = BTreeSet::new(); + for resource in &inventory.resources { + if !keys.insert(&resource.key) { + return Err(BundleError::Duplicate); + } + if !categories.contains(&resource.key.category) + || (!selected.is_empty() && !selected.contains(&resource.key)) + { + return Err(BundleError::Partition); + } + check_file(&resource.file)?; + } + let mut occurrences = BTreeSet::new(); + for relation in &inventory.relations { + if !occurrences.insert((&relation.owner, &relation.occurrence)) { + return Err(BundleError::Duplicate); + } + if !keys.contains(&relation.owner) { + return Err(BundleError::Unavailable); + } + check_file(&relation.file)?; + } + let mut inventory_hash = Sha256::new(); + inventory_hash.update(b"atlas.source.inventory.v1\0"); + frame(&mut inventory_hash, inventory_bytes)?; + let mut bundle_hash = Sha256::new(); + bundle_hash.update(b"atlas.source.bundle.v1\0"); + frame(&mut bundle_hash, inventory_bytes)?; + bundle_hash.update( + u64::try_from(files.len()) + .map_err(|_| BundleError::Limit)? + .to_be_bytes(), + ); + let mut retained = Vec::with_capacity(files.len()); + for file in sorted_files { + frame(&mut bundle_hash, file.logical_name.as_bytes())?; + frame(&mut bundle_hash, file.media_type.as_bytes())?; + frame(&mut bundle_hash, file.bytes)?; + let mut file_hash = Sha256::new(); + file_hash.update(file.bytes); + retained.push(ValidatedFile { + logical_name: LogicalName::new(file.logical_name)?, + media_type: MediaType::new(file.media_type)?, + bytes: file.bytes.to_vec(), + digest: finish(file_hash, DigestPurpose::FileContentSha256), + }); + } + Ok(ValidatedBundle { + inventory, + inventory_bytes: inventory_bytes.to_vec(), + files: retained, + inventory_digest: finish(inventory_hash, DigestPurpose::InventoryV1), + bundle_digest: finish(bundle_hash, DigestPurpose::BundleV1), + }) +} diff --git a/rsworkspace/crates/trogon-atlas-core/tests/source_bundle.rs b/rsworkspace/crates/trogon-atlas-core/tests/source_bundle.rs new file mode 100644 index 0000000..72ed3e5 --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-core/tests/source_bundle.rs @@ -0,0 +1,507 @@ +#![allow(clippy::unwrap_used)] +use serde_json::{json, Value}; +use trogon_atlas_core::source_bundle::*; + +fn inventory() -> Value { + json!({"profile":"supplied-native-v1","profile_revision":1,"registration":"Registry-A", + "source_version":"00.RC-a","partition":{"categories":["model"],"logical_roots":["native"],"selected_keys":[]}, + "resources":[{"key":{"category":"model","key":"order"},"file":"native/a.yaml","pointer":{"kind":"yaml","raw":"#/order"}}],"relations":[]}) +} +fn file() -> SuppliedFile<'static> { + SuppliedFile { + logical_name: "native/a.yaml", + media_type: "application/yaml", + bytes: b"# original\n", + } +} +fn validate(value: &Value) -> Result { + validate_bundle(&serde_json::to_vec(value).unwrap(), &[file()]) +} +fn relation(id: &str) -> Value { + json!({"owner":{"category":"model","key":"order"},"occurrence":id,"role":"observes","position":0,"target":{"kind":"external","raw":"https://unavailable.invalid/unknown"},"file":"native/a.yaml"}) +} + +#[test] +fn retains_opaque_identity_original_bytes_and_declared_coverage() { + let mut bytes = serde_json::to_vec_pretty(&inventory()).unwrap(); + bytes.push(b'\n'); + let result = validate_bundle(&bytes, &[file()]).unwrap(); + assert_eq!(result.inventory_bytes(), bytes); + assert_eq!(result.inventory().source_version().as_str(), "00.RC-a"); + assert_eq!(result.inventory().registration().as_str(), "Registry-A"); + assert_eq!( + result.inventory().declared_coverage(), + DeclaredCoverage::Partial + ); + assert_eq!(result.files()[0].bytes(), file().bytes); + assert_eq!( + result.inventory_digest().purpose(), + DigestPurpose::InventoryV1 + ); + assert_eq!(result.bundle_digest().purpose(), DigestPurpose::BundleV1); + assert_eq!( + result.files()[0].digest().purpose(), + DigestPurpose::FileContentSha256 + ); + assert_eq!(result.files()[0].digest().hex().len(), 64); +} +#[test] +fn complete_empty_inventory_can_retain_partition_selected_keys() { + let mut v = inventory(); + v["coverage"] = json!("COMPLETE"); + v["partition"]["selected_keys"] = json!([{"category":"model","key":"removed"}]); + v["resources"] = json!([]); + assert_eq!( + validate(&v).unwrap().inventory().declared_coverage(), + DeclaredCoverage::Complete + ); +} +#[test] +fn partition_scope_is_lexical_and_does_not_resolve_targets() { + let mut v = inventory(); + v["relations"] = json!([relation("first"), relation("second")]); + let result = validate(&v).unwrap(); + let relations = result.inventory().relations(); + assert_eq!(relations[0].target(), relations[1].target()); + assert_ne!(relations[0].occurrence(), relations[1].occurrence()); + assert_eq!( + relations[0].target().raw().as_str(), + "https://unavailable.invalid/unknown" + ); + v["partition"]["logical_roots"] = json!(["nat"]); + assert_eq!(validate(&v).unwrap_err(), BundleError::Partition); + v = inventory(); + v["partition"]["categories"] = json!([]); + assert!(validate(&v).is_err()); + v = inventory(); + v["partition"]["selected_keys"] = json!([{"category":"model","key":"other"}]); + assert_eq!(validate(&v).unwrap_err(), BundleError::Partition); +} +#[test] +fn duplicate_durable_keys_and_unavailable_locators_reject() { + let mut v = inventory(); + let r = v["resources"][0].clone(); + v["resources"].as_array_mut().unwrap().push(r); + assert_eq!(validate(&v).unwrap_err(), BundleError::Duplicate); + v = inventory(); + v["relations"] = json!([relation("same"), relation("same")]); + assert_eq!(validate(&v).unwrap_err(), BundleError::Duplicate); + v = inventory(); + v["relations"] = json!([relation("one")]); + v["relations"][0]["owner"]["key"] = json!("missing"); + assert_eq!(validate(&v).unwrap_err(), BundleError::Unavailable); + assert_eq!( + validate_bundle(&serde_json::to_vec(&inventory()).unwrap(), &[]).unwrap_err(), + BundleError::Unavailable + ); + assert_eq!( + validate_bundle( + &serde_json::to_vec(&inventory()).unwrap(), + &[file(), file()] + ) + .unwrap_err(), + BundleError::Duplicate + ); +} +#[test] +fn malformed_types_unknown_fields_duplicates_and_json_reject_without_disclosure() { + for bytes in [ + b"\xff".as_slice(), + b"{}{}", + b"[", + b"null", + b"{\"secret\":1,\"secret\":2}", + b"{\"x\":{\"a\":1,\"\\u0061\":2}}", + ] { + assert_eq!( + validate_bundle(bytes, &[]).unwrap_err(), + BundleError::Inventory + ); + } + for (field, value) in [ + ("profile_revision", json!("1")), + ("profile_revision", json!(0)), + ("profile", json!("other")), + ("source_version", json!(1)), + ("source_version", json!("")), + ("registration", json!(" \n")), + ("coverage", json!(null)), + ("coverage", json!("complete")), + ("resources", json!({})), + ("extra-private-key", json!("private-value")), + ] { + let mut v = inventory(); + v[field] = value; + let error = validate(&v).unwrap_err(); + assert!(!error.to_string().contains("private")); + } + let mut v = inventory(); + v["resources"][0]["pointer"]["extra"] = json!(1); + assert!(validate(&v).is_err()); + let text = serde_json::to_string(&inventory()).unwrap().replace( + "\"raw\":\"#/order\"", + "\"raw\":\"#/order\",\"raw\":\"other\"", + ); + assert!(validate_bundle(text.as_bytes(), &[file()]).is_err()); + let nested = format!( + "{{\"unknown\":{}0{}}}", + "[".repeat(MAX_JSON_DEPTH), + "]".repeat(MAX_JSON_DEPTH) + ); + assert!(validate_bundle(nested.as_bytes(), &[]).is_err()); +} +#[test] +fn locators_and_media_types_are_validated_without_normalization() { + for name in [ + "", + "/a", + "a/", + "a//b", + ".", + "a/../b", + "a/./b", + "a\\b", + "a\nb", + "https://host/a", + "C:/file", + "a\u{7f}b", + ] { + assert!(LogicalName::new(name).is_err(), "{name:?}"); + } + assert!(LogicalName::new(&"x".repeat(1024)).is_ok()); + assert!(LogicalName::new(&"x".repeat(1025)).is_err()); + assert_ne!( + LogicalName::new("A/é").unwrap(), + LogicalName::new("a/e\u{301}").unwrap() + ); + for value in ["", " ", "a\tb", "a\nb", "café"] { + assert!(MediaType::new(value).is_err()); + } + assert_eq!( + MediaType::new(" text/plain ").unwrap().as_str(), + " text/plain " + ); + assert!(SourceVersion::new(&"x".repeat(1024)).is_ok()); + assert!(SourceVersion::new(&"x".repeat(1025)).is_err()); + assert!(RawPointer::new(&"x".repeat(16384)).is_ok()); + assert!(RawPointer::new(&"x".repeat(16385)).is_err()); +} +#[test] +fn only_file_transport_order_is_digest_insensitive() { + let bytes = serde_json::to_vec(&inventory()).unwrap(); + let extra = SuppliedFile { + logical_name: "outside/z", + media_type: "text/plain", + bytes: b"opaque", + }; + let a = validate_bundle(&bytes, &[file(), extra]).unwrap(); + let b = validate_bundle(&bytes, &[extra, file()]).unwrap(); + assert_eq!(a.bundle_digest(), b.bundle_digest()); + assert_eq!(a.files()[0].logical_name().as_str(), "native/a.yaml"); + let mut whitespace = bytes.clone(); + whitespace.push(b' '); + assert_ne!( + a.bundle_digest(), + validate_bundle(&whitespace, &[file(), extra]) + .unwrap() + .bundle_digest() + ); + for (field, value) in [ + ("source_version", json!("00.RC-A")), + ("coverage", json!("COMPLETE")), + ] { + let mut v = inventory(); + v[field] = value; + assert_ne!( + a.inventory_digest(), + validate(&v).unwrap().inventory_digest() + ); + } + for changed in [ + SuppliedFile { + bytes: b"changed", + ..extra + }, + SuppliedFile { + media_type: "application/octet-stream", + ..extra + }, + SuppliedFile { + logical_name: "outside/Z", + ..extra + }, + ] { + assert_ne!( + a.bundle_digest(), + validate_bundle(&bytes, &[file(), changed]) + .unwrap() + .bundle_digest() + ); + } + let mut v = inventory(); + v["relations"] = json!([relation("one"), relation("two")]); + let a = validate(&v).unwrap(); + v["relations"].as_array_mut().unwrap().reverse(); + let b = validate(&v).unwrap(); + assert_ne!(a.bundle_digest(), b.bundle_digest()); + assert_eq!(b.inventory().relations()[0].occurrence().as_str(), "two"); +} +#[test] +fn inventory_file_and_combined_byte_boundaries_are_inclusive() { + let mut bytes = serde_json::to_vec(&inventory()).unwrap(); + bytes.resize(MAX_INVENTORY_BYTES, b' '); + assert!(validate_bundle(&bytes, &[file()]).is_ok()); + bytes.push(b' '); + assert_eq!( + validate_bundle(&bytes, &[]).unwrap_err(), + BundleError::Limit + ); + let payload = vec![0; MAX_FILE_BYTES]; + let large = SuppliedFile { + bytes: &payload, + ..file() + }; + assert!(validate_bundle(&serde_json::to_vec(&inventory()).unwrap(), &[large]).is_ok()); + let too_big = vec![0; MAX_FILE_BYTES + 1]; + assert_eq!( + validate_bundle( + b"", + &[SuppliedFile { + bytes: &too_big, + ..file() + }] + ) + .unwrap_err(), + BundleError::Limit + ); + bytes.pop(); + let names: Vec<_> = (0..7).map(|i| format!("extra/{i}")).collect(); + let mut files = vec![large]; + files.extend(names.iter().map(|name| SuppliedFile { + logical_name: name, + media_type: "a", + bytes: &payload, + })); + // Seven payloads plus a full inventory fit exactly; the eighth does not. + assert!(validate_bundle(&bytes, &files[..7]).is_ok()); + assert_eq!( + validate_bundle(&bytes, &files).unwrap_err(), + BundleError::Limit + ); +} +#[test] +fn file_resource_and_relation_count_boundaries_are_inclusive() { + let names: Vec<_> = (0..MAX_FILES - 1).map(|i| format!("extra/{i}")).collect(); + let mut files = vec![file()]; + files.extend(names.iter().map(|name| SuppliedFile { + logical_name: name, + media_type: "a", + bytes: &[], + })); + let bytes = serde_json::to_vec(&inventory()).unwrap(); + assert!(validate_bundle(&bytes, &files).is_ok()); + files.push(file()); + assert_eq!( + validate_bundle(&bytes, &files).unwrap_err(), + BundleError::Limit + ); + let mut v = inventory(); + let template = v["resources"][0].clone(); + v["resources"] = Value::Array( + (0..MAX_RESOURCES) + .map(|i| { + let mut r = template.clone(); + r["key"]["key"] = json!(format!("r{i}")); + r + }) + .collect(), + ); + assert!(validate(&v).is_ok()); + v["resources"].as_array_mut().unwrap().push(template); + assert!(validate(&v).is_err()); + v = inventory(); + v["relations"] = Value::Array( + (0..MAX_RELATIONS) + .map(|i| relation(&format!("o{i}"))) + .collect(), + ); + assert!(validate(&v).is_ok()); + v["relations"] + .as_array_mut() + .unwrap() + .push(relation("extra")); + assert!(validate(&v).is_err()); +} + +#[test] +fn json_depth_boundary_is_checked_before_strict_inventory_shape() { + // No open extension field can admit nested arrays into a valid inventory. + let at_limit = format!( + "{{\"unknown\":{}0{}}}", + "[".repeat(MAX_JSON_DEPTH - 1), + "]".repeat(MAX_JSON_DEPTH - 1) + ); + assert_eq!( + validate_bundle(at_limit.as_bytes(), &[]).unwrap_err(), + BundleError::Inventory + ); + let beyond_limit = format!( + "{{\"unknown\":{}0{}}}", + "[".repeat(MAX_JSON_DEPTH), + "]".repeat(MAX_JSON_DEPTH) + ); + assert_eq!( + validate_bundle(beyond_limit.as_bytes(), &[]).unwrap_err(), + BundleError::JsonDepth + ); +} + +#[test] +fn retained_bytes_are_independent_of_caller_buffers() { + let mut inventory_bytes = serde_json::to_vec(&inventory()).unwrap(); + let mut file_bytes = b"immutable".to_vec(); + let result = validate_bundle( + &inventory_bytes, + &[SuppliedFile { + bytes: &file_bytes, + ..file() + }], + ) + .unwrap(); + inventory_bytes.fill(0); + file_bytes.fill(0); + assert_eq!(result.inventory().source_version().as_str(), "00.RC-a"); + assert_eq!(result.files()[0].bytes(), b"immutable"); + assert_eq!( + result.inventory_bytes(), + serde_json::to_vec(&inventory()).unwrap() + ); +} + +fn positional_inventory(value: &Value) -> Value { + json!([ + value["profile"], + value["profile_revision"], + value["registration"], + value["source_version"], + value["partition"], + "PARTIAL", + value["resources"], + value["relations"] + ]) +} + +#[test] +fn positional_records_are_rejected_at_every_inventory_record_position() { + let mut cases = vec![positional_inventory(&inventory())]; + let mut v = inventory(); + v["partition"] = json!([["model"], ["native"], []]); + cases.push(v); + let mut v = inventory(); + v["resources"][0] = json!([{"category":"model","key":"order"}, "native/a.yaml", {"kind":"yaml","raw":"#/order"}]); + cases.push(v); + let mut v = inventory(); + v["resources"][0]["key"] = json!(["model", "order"]); + cases.push(v); + let mut v = inventory(); + v["resources"][0]["pointer"] = json!(["yaml", "#/order"]); + cases.push(v); + let mut v = inventory(); + v["partition"]["selected_keys"] = json!([["model", "order"]]); + cases.push(v); + let mut v = inventory(); + v["relations"] = json!([[{"category":"model","key":"order"}, "one", "observes", 0, {"kind":"external","raw":""}, "native/a.yaml"]]); + cases.push(v); + let mut v = inventory(); + v["relations"] = json!([relation("one")]); + v["relations"][0]["owner"] = json!(["model", "order"]); + cases.push(v); + let mut v = inventory(); + v["relations"] = json!([relation("one")]); + v["relations"][0]["target"] = json!(["external", "unresolved"]); + cases.push(v); + let accepted: Vec<_> = cases + .iter() + .enumerate() + .filter_map(|(index, v)| validate(v).is_ok().then_some(index)) + .collect(); + assert!( + accepted.is_empty(), + "accepted positional record cases: {accepted:?}" + ); +} + +#[test] +fn positional_inventory_cannot_bypass_resource_count_ceiling() { + let mut v = inventory(); + let resource = v["resources"][0].clone(); + v["resources"] = Value::Array( + (0..=MAX_RESOURCES) + .map(|i| { + let mut item = resource.clone(); + item["key"]["key"] = json!(format!("resource-{i}")); + item + }) + .collect(), + ); + assert!(validate(&positional_inventory(&v)).is_err()); +} + +#[test] +fn positional_partition_cannot_bypass_partition_list_ceilings() { + let categories: Vec<_> = (0..=MAX_RESOURCES) + .map(|i| format!("category-{i}")) + .collect(); + let roots: Vec<_> = (0..=MAX_RESOURCES) + .map(|i| format!("native/root-{i}")) + .collect(); + let selected: Vec<_> = (0..=MAX_RESOURCES) + .map(|i| json!({"category":"model","key":format!("key-{i}")})) + .collect(); + let partitions = [ + json!([categories, ["native"], []]), + json!([["model"], roots, []]), + json!([["model"], ["native"], selected]), + ]; + let accepted: Vec<_> = partitions + .iter() + .enumerate() + .filter_map(|(index, p)| { + let mut v = inventory(); + v["resources"] = json!([]); + v["partition"] = p.clone(); + validate(&v).is_ok().then_some(index) + }) + .collect(); + assert!( + accepted.is_empty(), + "accepted oversized positional partitions: {accepted:?}" + ); +} + +#[test] +fn explicit_coverage_requires_string_spelling() { + for (spelling, expected) in [ + ("COMPLETE", DeclaredCoverage::Complete), + ("PARTIAL", DeclaredCoverage::Partial), + ] { + let mut v = inventory(); + v["coverage"] = json!(spelling); + assert_eq!( + validate(&v).unwrap().inventory().declared_coverage(), + expected + ); + } + let accepted: Vec<_> = [json!({"COMPLETE":null}), json!({"PARTIAL":null})] + .into_iter() + .filter(|coverage| { + let mut v = inventory(); + v["coverage"] = coverage.clone(); + validate(&v).is_ok() + }) + .collect(); + assert!( + accepted.is_empty(), + "accepted object-form coverage: {accepted:?}" + ); +} diff --git a/rsworkspace/crates/trogon-atlas-core/tests/source_bundle_vectors.rs b/rsworkspace/crates/trogon-atlas-core/tests/source_bundle_vectors.rs new file mode 100644 index 0000000..9093585 --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-core/tests/source_bundle_vectors.rs @@ -0,0 +1,55 @@ +#![allow(clippy::unwrap_used)] + +use trogon_atlas_core::source_bundle::{validate_bundle, DigestPurpose, SuppliedFile}; + +const INVENTORY: &[u8] = br#"{"profile":"supplied-native-v1","profile_revision":1,"registration":"registry-a","source_version":"00.RC-A","partition":{"categories":["model"],"logical_roots":["native"],"selected_keys":[]},"resources":[],"relations":[]} +"#; + +#[test] +fn framed_digest_vectors_match_independent_sha256_values() { + let files = [ + SuppliedFile { + logical_name: "native/z.bin", + media_type: "application/octet-stream", + bytes: &[0, 255, 10], + }, + SuppliedFile { + logical_name: "native/a.txt", + media_type: "text/plain", + bytes: b"abc", + }, + ]; + let bundle = validate_bundle(INVENTORY, &files).unwrap(); + assert_eq!( + bundle.inventory_digest().hex(), + "f66680661550bd526c4f78461f1b31aa950d06cec6416419e44f384c92f03d84" + ); + assert_eq!( + bundle.bundle_digest().hex(), + "0c537e98e184e879a8e2969132eea2c0896dc3d9a52711a9ae5ccb868da22cb9" + ); + assert_eq!( + bundle.files()[0].digest().hex(), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + assert_eq!( + bundle.files()[1].digest().hex(), + "712450d3c4a79eea9509e75dc1dacdeff58034df538536cfae2da882bd8a0c50" + ); + assert_eq!( + bundle.inventory_digest().purpose(), + DigestPurpose::InventoryV1 + ); + assert_eq!(bundle.bundle_digest().purpose(), DigestPurpose::BundleV1); + assert_eq!( + bundle.files()[0].digest().purpose(), + DigestPurpose::FileContentSha256 + ); + let reversed = [files[1], files[0]]; + assert_eq!( + validate_bundle(INVENTORY, &reversed) + .unwrap() + .bundle_digest(), + bundle.bundle_digest() + ); +} From 44a73f812006999e8cb38327616fefea6a7bde5f Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 23 Oct 2026 05:27:17 -0400 Subject: [PATCH 2/2] fix: prevent timing leaks in transitive cryptography Signed-off-by: Yordis Prieto --- rsworkspace/Cargo.lock | 4 ++-- rsworkspace/crates/trogon-atlas-server/fuzz/Cargo.lock | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/rsworkspace/Cargo.lock b/rsworkspace/Cargo.lock index 3c6b0ff..e2bb415 100644 --- a/rsworkspace/Cargo.lock +++ b/rsworkspace/Cargo.lock @@ -827,9 +827,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +checksum = "12218662ccac37d9714f2191252553d67585b97df8c839cbe49e0bde278dc865" dependencies = [ "cmov", ] diff --git a/rsworkspace/crates/trogon-atlas-server/fuzz/Cargo.lock b/rsworkspace/crates/trogon-atlas-server/fuzz/Cargo.lock index 55e7e9d..9f8d42a 100644 --- a/rsworkspace/crates/trogon-atlas-server/fuzz/Cargo.lock +++ b/rsworkspace/crates/trogon-atlas-server/fuzz/Cargo.lock @@ -564,9 +564,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +checksum = "12218662ccac37d9714f2191252553d67585b97df8c839cbe49e0bde278dc865" dependencies = [ "cmov", ]