diff --git a/CHANGELOG.md b/CHANGELOG.md index fd26dc8b0..7c3c2733d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] +- Tightened the provisional SPDX JSON-LD container budget to a reachable pre-materialization bound and changed its regression input to shallow fan-out, so container exhaustion is distinguished from nesting-depth and structure-token failures. + - Refreshed the product-gap queue to 126 open pull requests (54 ready, 72 draft) after #190, #188, #185, #192, #182, #184, #115, #181, #116, #117, #118, #183, #114, #127, #112, #109, #186, #110, #108, #111, #174, and #113 were merged into their immediate stacked prerequisites. PRs #147, #146, #145, #144, #143, #142, #141, #139, #136, #132, #129, and #128 moved to ready after exact-head checks and thread review; these are queue-consolidation results, not protected-main shipment. ### Added @@ -11,6 +13,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Refreshed the product and technical gap baseline onto the 2026-08-26 live inventory: 126 open pull requests (54 ready, 72 draft), protected-main promotion of #168/#194/#196/#216/#151, a verified maintenance-loop record (supersession closure of #153, conflict reconciliations on #37/#149/#152/#173/#175, issue #212 option-(b) authorization on #43, Strix vuln-0001 homoglyph remediation on #124), provider-rerun outcome evidence, an organization review-pipeline congestion record, and refreshed merge-order queue guidance. Documentation evidence contracts were aligned to the same snapshot so the baseline, its dated markers, and the pinned exact-head rows cannot silently diverge. - Added `originweave_core::release_acceptance`, a deterministic fail-closed benchmark release-decision contract that requires one authoritative result for every mandatory suite, bounds explicit buyer-visible limitations, rejects duplicate limitation claim identities, and rejects non-canonical surrounding whitespace rather than normalizing it into an alternate claim spelling. +- Added an inert SPDX 3.0.1 JSON-LD SBOM identity binding that requires the SBOM artifact and every non-SBOM release artifact to be exact members of the same release manifest, rejects empty, incomplete, foreign, duplicate, self-described, and case-drifted artifact identities, preserves a non-circular release-inventory-complete exact-digest join, exposes the complete retained manifest and exact described artifact identities for downstream verification/provenance composition, and does not claim SPDX content conformance, package/component completeness inside the SBOM, artifact authentication, provenance, signing, publication, installation, update, rollback, or release authority. - Refreshed the product and technical gap baseline with the 2026-08-24 live inventory: 158 open pull requests (44 ready, 114 draft), refreshed exact base/head evidence for the #208–#222 release, enterprise-approval, BAP, and WARC/PROV chains, the governance issue additions #212 and #215, and a required-check provider-failure record for the fail-closed Strix re-dispatches on #208/#218/#220. - Added a dated product and technical gap baseline that separates protected-main implementation truth, active pull-request evidence, live review/check blockers, and the next buyer-visible Phase 1 acceptance work. - Refreshed the product and technical gap baseline with the current open-PR inventory and exact base/head evidence for the newest Chromium, BAP, extraction, WARC, and idempotency slices. @@ -72,6 +75,11 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Security +- The provisional SPDX 3.0.1 release-envelope parser now rejects both excessive opening-container depth/fan-out and broader scalar/member fan-out before `json.loads`: opening containers are capped at 524,288 and total structural `[]{}:,` tokens at 1,048,576, counting only punctuation outside JSON strings. This prevents compact hostile documents below the 16 MiB byte ceiling from amplifying into an unbounded generic object tree while preserving structural-looking text inside string values. The eventual Rust-owned Release boundary must retain an equivalent or stricter pre-materialization resource invariant. +- The SPDX 3.0.1 release-envelope gate now requires exactly one top-level `SpdxDocument` under `@graph` and rejects both a nested-only document and any additional nested `SpdxDocument`, preventing recursive string/type discovery from substituting for the required serialization placement. +- The SPDX 3.0.1 release-envelope gate requires the exact versioned global-context string and rejects context arrays, `@import`, `@base`, `@vocab`, and alias/term-rebinding attempts until reviewed schema-aware validation can prove extension semantics without ambient or reinterpretation authority. +- SPDX JSON-LD release-file admission now requires a direct regular-file leaf reached through a no-follow descriptor-relative path walk and revalidates the final leaf's device/inode identity after the bounded read, so symlinks, FIFOs, swapped ancestors, and post-open pathname replacement fail closed instead of silently changing the artifact path identity. +- Explicit proxy server identifiers require ASCII decimal port tokens before numeric range parsing, preventing Rust-specific leading-plus spellings from widening proxy authority. - Explicit proxy server identifiers require ASCII decimal port tokens before numeric range parsing, preventing Rust-specific leading-plus spellings from widening proxy authority. - Release artifact admission rejects `COM0` through `COM9` and `LPT0` through `LPT9` case-insensitively, including extensions, as bounded filename identity hygiene. OneDrive and SharePoint impose additional restrictions, including `desktop.ini`, that this validator does not model; this is not a complete OneDrive or SharePoint synchronization-compatibility guarantee. - Raw page content cannot become a trusted instruction. @@ -81,8 +89,8 @@ All notable changes to OriginWeave are documented in this file. The format follo - R3 and R4 approvals are bound to the exact action, target origin, and immutable digest of the complete canonical action intent; R5 legal consent is non-delegable. - Shortened, integer, hexadecimal, and legacy octal-looking IPv4 host spellings are rejected so the policy origin cannot diverge from Chromium host interpretation. - IPv4-mapped IPv6 is canonicalized before destination classification and pin comparison so mapped private or loopback addresses cannot bypass IPv4 policy. -- The default destination policy permits only public addresses and denies unspecified, loopback, private, shared, link-local, metadata, documentation, benchmarking, multicast, broadcast, transition, and protocol-reserved destinations. -- Azure platform IP `168.63.129.16` and Amazon EKS Pod Identity endpoints `169.254.170.23` and `fd00:ec2::23` are classified as metadata or platform services before broader public, link-local, or unique-local rules. +- The default destination policy permits only public addresses and denies unspecified, loopback, private, shared, link-local, metadata, documentation, benchmarking, multicast, broadcast, transition, unallocated, and protocol-reserved destinations. +- Azure platform IP `168.63.129.16` and Amazon EKS Pod Identity endpoints `169.254.170.23` and `fd00:ec2::23` are classified as metadata or platform services before broader address-range rules. - Resolver answers are rejected when empty or larger than 256 addresses, preventing an unbounded resolver response from entering policy state. - `localhost` may approve only loopback addresses, while literal IPv4 and IPv6 origins may approve only the exact canonical address encoded in the origin. - Resolver answers must remain a non-empty subset of the origin-bound approved address set; any newly introduced address fails closed as a possible DNS-rebinding event. @@ -105,4 +113,4 @@ All notable changes to OriginWeave are documented in this file. The format follo - The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it. - The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels. -[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD \ No newline at end of file +[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD diff --git a/crates/originweave-core/src/release_manifest.rs b/crates/originweave-core/src/release_manifest.rs index 2611163d8..011225625 100644 --- a/crates/originweave-core/src/release_manifest.rs +++ b/crates/originweave-core/src/release_manifest.rs @@ -1,8 +1,9 @@ //! Fail-closed identity binding for release artifacts. //! //! The types in this module are deliberately inert metadata contracts. They bind an exact -//! source commit, Chromium revision, release channel, build identity, and artifact digests -//! without granting signing, publication, installation, update, rollback, or release authority. +//! source commit, Chromium revision, release channel, build identity, artifact digests, and +//! release-SBOM identity without granting signing, publication, installation, update, rollback, +//! or release authority. use std::collections::BTreeSet; use std::error::Error; @@ -157,6 +158,205 @@ impl fmt::Display for ReleaseArtifactError { impl Error for ReleaseArtifactError {} +/// SPDX serialization identity admitted for release-SBOM evidence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReleaseSbomFormat { + /// SPDX 3.0.1 serialized as JSON-LD. + Spdx30JsonLd, +} + +impl ReleaseSbomFormat { + /// Return the exact SPDX specification version represented by this format. + #[must_use] + pub const fn spdx_specification_version(self) -> &'static str { + match self { + Self::Spdx30JsonLd => "3.0.1", + } + } + + /// Return the exact global JSON-LD context URI required by this SPDX format. + /// + /// This versioned identifier is inert serialization metadata for downstream validation and + /// serialization. It does not validate SBOM bytes, authenticate artifacts, or grant release + /// authority. + #[must_use] + pub const fn json_ld_context_uri(self) -> &'static str { + match self { + Self::Spdx30JsonLd => "https://spdx.org/rdf/3.0.1/spdx-context.jsonld", + } + } +} + +/// Inert binding between one release manifest and its declared SPDX SBOM artifact identity. +/// +/// This value proves only that the named SBOM and exact described artifact identities are already +/// admitted by the same immutable [`ReleaseManifest`]. It retains the complete bounded release +/// manifest identity and each described artifact digest, so any source, Chromium, channel, build, +/// artifact-inventory, or described-artifact change produces a different binding. It does not +/// parse or validate SPDX content, prove that the SBOM actually describes those artifacts, +/// authenticate any digest, or grant release authority. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReleaseSbomBinding { + release_manifest: ReleaseManifest, + format: ReleaseSbomFormat, + sbom_artifact: ReleaseArtifact, + described_artifact_names: Vec, + described_artifacts: Vec, +} + +impl ReleaseSbomBinding { + /// Bind a declared SPDX SBOM artifact to every other artifact in one release manifest. + /// + /// The complete bounded release-manifest identity is retained in the binding. The SBOM + /// artifact name and every described artifact name must match that manifest exactly. The SBOM + /// artifact itself cannot be admitted as one of its described artifacts. Every other manifest + /// artifact must be described exactly once; missing, foreign, self-described, and duplicate + /// entries fail closed. Exact described artifact identities plus their public names are sorted + /// deterministically. The release manifest's own admission bound therefore also bounds this + /// inventory. + pub fn new<'a, I>( + manifest: &ReleaseManifest, + sbom_artifact_name: &str, + format: ReleaseSbomFormat, + described_artifact_names: I, + ) -> Result + where + I: IntoIterator, + { + let Some(sbom_artifact) = manifest + .artifacts() + .iter() + .find(|artifact| artifact.name() == sbom_artifact_name) + else { + return Err(ReleaseSbomBindingError::UnknownSbomArtifact); + }; + + let mut described_artifacts = Vec::new(); + let mut seen = BTreeSet::new(); + for artifact_name in described_artifact_names { + if artifact_name == sbom_artifact.name() { + return Err(ReleaseSbomBindingError::SelfDescribedSbomArtifact); + } + let Some(artifact) = manifest + .artifacts() + .iter() + .find(|artifact| artifact.name() == artifact_name) + else { + return Err(ReleaseSbomBindingError::UnknownDescribedArtifact); + }; + if !seen.insert(artifact_name.to_owned()) { + return Err(ReleaseSbomBindingError::DuplicateDescribedArtifact); + } + described_artifacts.push(artifact.clone()); + } + if described_artifacts.is_empty() { + return Err(ReleaseSbomBindingError::MissingDescribedArtifacts); + } + if described_artifacts.len() + 1 != manifest.artifacts().len() { + return Err(ReleaseSbomBindingError::IncompleteDescribedArtifacts); + } + described_artifacts.sort_by(|left, right| left.name.cmp(&right.name)); + let described_artifact_names = described_artifacts + .iter() + .map(|artifact| artifact.name().to_owned()) + .collect(); + + Ok(Self { + release_manifest: manifest.clone(), + format, + sbom_artifact: sbom_artifact.clone(), + described_artifact_names, + described_artifacts, + }) + } + + /// Return the complete exact release manifest retained by this SBOM binding. + /// + /// This is inert identity evidence for downstream verification and provenance composition. It + /// does not authenticate the manifest, validate SBOM content, or grant release authority. + #[must_use] + pub const fn release_manifest(&self) -> &ReleaseManifest { + &self.release_manifest + } + + /// Return the declared SPDX serialization format. + #[must_use] + pub const fn format(&self) -> ReleaseSbomFormat { + self.format + } + + /// Return the exact SPDX specification version represented by the declared format. + #[must_use] + pub const fn spdx_specification_version(&self) -> &'static str { + self.format.spdx_specification_version() + } + + /// Return the exact manifest-backed SBOM artifact identity. + #[must_use] + pub const fn sbom_artifact(&self) -> &ReleaseArtifact { + &self.sbom_artifact + } + + /// Return described manifest artifact names in deterministic lexical order. + #[must_use] + pub fn described_artifact_names(&self) -> &[String] { + &self.described_artifact_names + } + + /// Return exact described artifact identities in deterministic lexical order. + /// + /// These name-and-digest identities are retained from the same admitted release manifest and + /// are suitable for later verification joins. Their presence alone does not authenticate + /// artifact bytes or prove that the SBOM content describes them. + #[must_use] + pub fn described_artifacts(&self) -> &[ReleaseArtifact] { + &self.described_artifacts + } +} + +/// Validation error for release-SBOM identity binding. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReleaseSbomBindingError { + /// The named SBOM artifact is absent from the release manifest. + UnknownSbomArtifact, + /// No release artifacts were declared as described by the SBOM. + MissingDescribedArtifacts, + /// The SBOM omitted at least one non-SBOM artifact from the same release manifest. + IncompleteDescribedArtifacts, + /// The SBOM artifact was also declared as an artifact that the SBOM describes. + SelfDescribedSbomArtifact, + /// A described artifact name is absent from the release manifest. + UnknownDescribedArtifact, + /// A described artifact name was repeated. + DuplicateDescribedArtifact, +} + +impl fmt::Display for ReleaseSbomBindingError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnknownSbomArtifact => formatter + .write_str("release SBOM artifact is not present in the bound release manifest"), + Self::MissingDescribedArtifacts => { + formatter.write_str("release SBOM must describe at least one release artifact") + } + Self::IncompleteDescribedArtifacts => { + formatter.write_str("release SBOM must describe every non-SBOM release artifact") + } + Self::SelfDescribedSbomArtifact => { + formatter.write_str("release SBOM must not describe its own artifact") + } + Self::UnknownDescribedArtifact => formatter.write_str( + "release SBOM describes an artifact that is not present in the bound release manifest", + ), + Self::DuplicateDescribedArtifact => { + formatter.write_str("release SBOM repeats a described release artifact") + } + } + } +} + +impl Error for ReleaseSbomBindingError {} + /// Deterministic, bounded identity manifest for one OriginWeave release candidate. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReleaseManifest { diff --git a/crates/originweave-core/tests/release_sbom_binding.rs b/crates/originweave-core/tests/release_sbom_binding.rs new file mode 100644 index 000000000..61cc7a073 --- /dev/null +++ b/crates/originweave-core/tests/release_sbom_binding.rs @@ -0,0 +1,288 @@ +use std::error::Error; + +use originweave_core::release_manifest::{ + ReleaseArtifact, ReleaseBuildIdentity, ReleaseChannel, ReleaseManifest, ReleaseSbomBinding, + ReleaseSbomBindingError, ReleaseSbomFormat, +}; + +const SOURCE_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; +const CHROMIUM_REVISION: &str = "150.0.7871.129"; + +fn sha256_digest(hex_digit: char) -> String { + format!("sha256:{}", hex_digit.to_string().repeat(64)) +} + +fn release_manifest() -> Result> { + ReleaseManifest::new( + SOURCE_COMMIT, + CHROMIUM_REVISION, + ReleaseChannel::Stable, + ReleaseBuildIdentity::new("1.97.1", &sha256_digest('9'))?, + vec![ + ReleaseArtifact::new("originweave-linux-x86_64.tar.zst", &sha256_digest('a'))?, + ReleaseArtifact::new("originweave-native-host.bin", &sha256_digest('b'))?, + ReleaseArtifact::new("originweave.spdx.jsonld", &sha256_digest('c'))?, + ], + ) + .map_err(Into::into) +} + +#[test] +fn spdx_sbom_binding_requires_manifest_backed_sbom_and_described_artifacts() +-> Result<(), Box> { + let manifest = release_manifest()?; + let binding = ReleaseSbomBinding::new( + &manifest, + "originweave.spdx.jsonld", + ReleaseSbomFormat::Spdx30JsonLd, + vec![ + "originweave-native-host.bin", + "originweave-linux-x86_64.tar.zst", + ], + )?; + + assert_eq!(binding.format(), ReleaseSbomFormat::Spdx30JsonLd); + assert_eq!(binding.spdx_specification_version(), "3.0.1"); + assert_eq!(binding.sbom_artifact().name(), "originweave.spdx.jsonld"); + assert_eq!(binding.sbom_artifact().sha256_digest(), sha256_digest('c')); + assert_eq!( + binding.described_artifact_names(), + [ + "originweave-linux-x86_64.tar.zst", + "originweave-native-host.bin", + ] + ); + Ok(()) +} + +#[test] +fn spdx_sbom_binding_retains_exact_described_artifact_identity() -> Result<(), Box> { + let original_manifest = release_manifest()?; + let changed_manifest = ReleaseManifest::new( + SOURCE_COMMIT, + CHROMIUM_REVISION, + ReleaseChannel::Stable, + ReleaseBuildIdentity::new("1.97.1", &sha256_digest('9'))?, + vec![ + ReleaseArtifact::new("originweave-linux-x86_64.tar.zst", &sha256_digest('d'))?, + ReleaseArtifact::new("originweave-native-host.bin", &sha256_digest('b'))?, + ReleaseArtifact::new("originweave.spdx.jsonld", &sha256_digest('c'))?, + ], + )?; + let described_names = vec![ + "originweave-native-host.bin", + "originweave-linux-x86_64.tar.zst", + ]; + + let original_binding = ReleaseSbomBinding::new( + &original_manifest, + "originweave.spdx.jsonld", + ReleaseSbomFormat::Spdx30JsonLd, + described_names.clone(), + )?; + let changed_binding = ReleaseSbomBinding::new( + &changed_manifest, + "originweave.spdx.jsonld", + ReleaseSbomFormat::Spdx30JsonLd, + described_names, + )?; + + assert_ne!(original_binding, changed_binding); + Ok(()) +} + +#[test] +fn spdx_sbom_binding_retains_exact_release_manifest_identity() -> Result<(), Box> { + let original_manifest = release_manifest()?; + let changed_source_manifest = ReleaseManifest::new( + "89abcdef0123456789abcdef0123456789abcdef", + CHROMIUM_REVISION, + ReleaseChannel::Stable, + ReleaseBuildIdentity::new("1.97.1", &sha256_digest('9'))?, + original_manifest.artifacts().iter().cloned(), + )?; + let changed_channel_manifest = ReleaseManifest::new( + SOURCE_COMMIT, + CHROMIUM_REVISION, + ReleaseChannel::Beta, + ReleaseBuildIdentity::new("1.97.1", &sha256_digest('9'))?, + original_manifest.artifacts().iter().cloned(), + )?; + let described_names = vec![ + "originweave-native-host.bin", + "originweave-linux-x86_64.tar.zst", + ]; + + let original_binding = ReleaseSbomBinding::new( + &original_manifest, + "originweave.spdx.jsonld", + ReleaseSbomFormat::Spdx30JsonLd, + described_names.clone(), + )?; + let changed_source_binding = ReleaseSbomBinding::new( + &changed_source_manifest, + "originweave.spdx.jsonld", + ReleaseSbomFormat::Spdx30JsonLd, + described_names.clone(), + )?; + let changed_channel_binding = ReleaseSbomBinding::new( + &changed_channel_manifest, + "originweave.spdx.jsonld", + ReleaseSbomFormat::Spdx30JsonLd, + described_names, + )?; + + assert_ne!(original_binding, changed_source_binding); + assert_ne!(original_binding, changed_channel_binding); + Ok(()) +} + +#[test] +fn spdx_sbom_binding_exposes_exact_bound_identities() -> Result<(), Box> { + let manifest = release_manifest()?; + let binding = ReleaseSbomBinding::new( + &manifest, + "originweave.spdx.jsonld", + ReleaseSbomFormat::Spdx30JsonLd, + vec![ + "originweave-native-host.bin", + "originweave-linux-x86_64.tar.zst", + ], + )?; + + assert_eq!(binding.release_manifest(), &manifest); + assert_eq!(binding.described_artifacts().len(), 2); + assert_eq!( + binding.described_artifacts()[0].name(), + "originweave-linux-x86_64.tar.zst" + ); + assert_eq!( + binding.described_artifacts()[0].sha256_digest(), + sha256_digest('a') + ); + assert_eq!( + binding.described_artifacts()[1].name(), + "originweave-native-host.bin" + ); + assert_eq!( + binding.described_artifacts()[1].sha256_digest(), + sha256_digest('b') + ); + Ok(()) +} + +#[test] +fn spdx_sbom_binding_fails_closed_on_missing_or_ambiguous_manifest_identity() +-> Result<(), Box> { + let manifest = release_manifest()?; + + assert_eq!( + ReleaseSbomBinding::new( + &manifest, + "missing.spdx.jsonld", + ReleaseSbomFormat::Spdx30JsonLd, + vec!["originweave-linux-x86_64.tar.zst"], + ), + Err(ReleaseSbomBindingError::UnknownSbomArtifact) + ); + assert_eq!( + ReleaseSbomBinding::new( + &manifest, + "originweave.spdx.jsonld", + ReleaseSbomFormat::Spdx30JsonLd, + Vec::<&str>::new(), + ), + Err(ReleaseSbomBindingError::MissingDescribedArtifacts) + ); + assert_eq!( + ReleaseSbomBinding::new( + &manifest, + "originweave.spdx.jsonld", + ReleaseSbomFormat::Spdx30JsonLd, + vec!["originweave-linux-x86_64.tar.zst"], + ), + Err(ReleaseSbomBindingError::IncompleteDescribedArtifacts) + ); + assert_eq!( + ReleaseSbomBinding::new( + &manifest, + "originweave.spdx.jsonld", + ReleaseSbomFormat::Spdx30JsonLd, + vec!["not-in-release.bin"], + ), + Err(ReleaseSbomBindingError::UnknownDescribedArtifact) + ); + assert_eq!( + ReleaseSbomBinding::new( + &manifest, + "originweave.spdx.jsonld", + ReleaseSbomFormat::Spdx30JsonLd, + vec![ + "originweave-linux-x86_64.tar.zst", + "originweave-linux-x86_64.tar.zst", + ], + ), + Err(ReleaseSbomBindingError::DuplicateDescribedArtifact) + ); + assert_eq!( + ReleaseSbomBinding::new( + &manifest, + "originweave.spdx.jsonld", + ReleaseSbomFormat::Spdx30JsonLd, + vec!["ORIGINWEAVE-LINUX-X86_64.TAR.ZST"], + ), + Err(ReleaseSbomBindingError::UnknownDescribedArtifact) + ); + Ok(()) +} + +#[test] +fn spdx_sbom_binding_rejects_self_description() -> Result<(), Box> { + let manifest = release_manifest()?; + + assert_eq!( + ReleaseSbomBinding::new( + &manifest, + "originweave.spdx.jsonld", + ReleaseSbomFormat::Spdx30JsonLd, + vec!["originweave.spdx.jsonld"], + ), + Err(ReleaseSbomBindingError::SelfDescribedSbomArtifact) + ); + Ok(()) +} + +#[test] +fn spdx_sbom_binding_errors_are_standard_source_free_rust_errors() { + let errors = [ + ( + ReleaseSbomBindingError::UnknownSbomArtifact, + "release SBOM artifact is not present in the bound release manifest", + ), + ( + ReleaseSbomBindingError::MissingDescribedArtifacts, + "release SBOM must describe at least one release artifact", + ), + ( + ReleaseSbomBindingError::IncompleteDescribedArtifacts, + "release SBOM must describe every non-SBOM release artifact", + ), + ( + ReleaseSbomBindingError::SelfDescribedSbomArtifact, + "release SBOM must not describe its own artifact", + ), + ( + ReleaseSbomBindingError::UnknownDescribedArtifact, + "release SBOM describes an artifact that is not present in the bound release manifest", + ), + ( + ReleaseSbomBindingError::DuplicateDescribedArtifact, + "release SBOM repeats a described release artifact", + ), + ]; + + for (error, expected) in errors { + assert_eq!(error.to_string(), expected); + assert!(Error::source(&error).is_none()); + } +} diff --git a/crates/originweave-core/tests/release_sbom_completeness.rs b/crates/originweave-core/tests/release_sbom_completeness.rs new file mode 100644 index 000000000..5a11ac475 --- /dev/null +++ b/crates/originweave-core/tests/release_sbom_completeness.rs @@ -0,0 +1,54 @@ +use std::error::Error; + +use originweave_core::release_manifest::{ + ReleaseArtifact, ReleaseBuildIdentity, ReleaseChannel, ReleaseManifest, ReleaseSbomBinding, + ReleaseSbomBindingError, ReleaseSbomFormat, +}; + +const SOURCE_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; +const CHROMIUM_REVISION: &str = "150.0.7871.129"; + +fn sha256_digest(hex_digit: char) -> String { + format!("sha256:{}", hex_digit.to_string().repeat(64)) +} + +#[test] +fn release_sbom_binding_rejects_incomplete_manifest_inventory() -> Result<(), Box> { + let manifest = ReleaseManifest::new( + SOURCE_COMMIT, + CHROMIUM_REVISION, + ReleaseChannel::Stable, + ReleaseBuildIdentity::new("1.97.1", &sha256_digest('9'))?, + vec![ + ReleaseArtifact::new("originweave-linux-x86_64.tar.zst", &sha256_digest('a'))?, + ReleaseArtifact::new("originweave-native-host.bin", &sha256_digest('b'))?, + ReleaseArtifact::new("originweave.spdx.jsonld", &sha256_digest('c'))?, + ], + )?; + + let complete_binding = ReleaseSbomBinding::new( + &manifest, + "originweave.spdx.jsonld", + ReleaseSbomFormat::Spdx30JsonLd, + vec![ + "originweave-linux-x86_64.tar.zst", + "originweave-native-host.bin", + ], + )?; + assert_eq!(complete_binding.described_artifacts().len(), 2); + + assert_eq!( + ReleaseSbomBinding::new( + &manifest, + "originweave.spdx.jsonld", + ReleaseSbomFormat::Spdx30JsonLd, + vec!["originweave-linux-x86_64.tar.zst"], + ), + Err(ReleaseSbomBindingError::IncompleteDescribedArtifacts) + ); + assert_eq!( + ReleaseSbomBindingError::IncompleteDescribedArtifacts.to_string(), + "release SBOM must describe every non-SBOM release artifact" + ); + Ok(()) +} diff --git a/crates/originweave-core/tests/release_sbom_format.rs b/crates/originweave-core/tests/release_sbom_format.rs new file mode 100644 index 000000000..28e1879a0 --- /dev/null +++ b/crates/originweave-core/tests/release_sbom_format.rs @@ -0,0 +1,12 @@ +use originweave_core::release_manifest::ReleaseSbomFormat; + +#[test] +fn spdx_30_json_ld_exposes_required_global_context_identity() { + let format = ReleaseSbomFormat::Spdx30JsonLd; + + assert_eq!(format.spdx_specification_version(), "3.0.1"); + assert_eq!( + format.json_ld_context_uri(), + "https://spdx.org/rdf/3.0.1/spdx-context.jsonld" + ); +} diff --git a/docs/README.md b/docs/README.md index a9e520be9..6a7fd7a3a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ - [Conceptual ERD and durable domain model](erd/README.md) - [Data governance and privacy boundary](DATA_GOVERNANCE.md) - [Product and decision traceability](traceability/README.md) + - [Release SBOM JSON nesting-depth invariant](traceability/release-sbom-depth-256.md) - [Documentation fitness assessment](DOCUMENTATION_FITNESS.md) - [Dated active-PR maturity evidence (2026-08-10)](evidence/2026-08-10-active-pr-maturity.md) - [Active-PR maturity delta (2026-08-11)](evidence/2026-08-11-active-pr-maturity-delta.md) @@ -87,13 +88,14 @@ Proposed ADRs are reviewable architecture memory, not shipped behavior and not a The second group exists only on this documentation branch until the branch integrates. After integration, the heading remains useful historical provenance; it does not promote either ADR from Proposed to Accepted and it does not claim that the described runtime capability is implemented. -### Proposed decisions introduced by active feature work +### Active feature proposed decisions - [ADR 0015: Release manifest identity boundary](adr/0015-release-manifest-identity.md) - [ADR 0016: BAP task lifecycle and state authority](adr/0016-bap-task-lifecycle-authority.md) +- [ADR 0018: Release SBOM identity binding](adr/0018-release-sbom-identity.md) -ADR 0015 is active-PR architecture evidence for the release-manifest identity slice. ADR 0016 is owned by this active BAP lifecycle feature branch. Both remain Proposed. Their presence here makes the branch documentation graph complete without presenting either decision or implementation as protected-main truth before integration. +ADR 0015 is active-PR architecture evidence for the release-manifest identity slice. ADR 0016 belongs to the active BAP lifecycle feature branch. ADR 0018 is stacked active-PR architecture evidence for the release-SBOM identity slice. All remain Proposed. Their presence here makes the branch documentation graph complete without promoting either decision or implementation to protected-main truth before integration. After protected-main integration, retain this subsection only when it is intentionally serving as historical provenance; otherwise protected-main reconciliation must remove it. In either case, integration alone does not change these ADRs from Proposed or assert implementation maturity. -See the [ADR index](adr/README.md) for status rules, required decision structure, supersession rules, and active feature ADRs. The index and each ADR's own status metadata must agree; a PR body, chat transcript, automation prompt, or stale issue reference cannot change ADR status. +See the [ADR index](adr/README.md) for status rules, required decision structure, supersession rules, and active feature ADRs. The index and each ADR's own status metadata must agree; a PR body, chat transcript, automation prompt, or stale issue reference cannot change ADR status. \ No newline at end of file diff --git a/docs/adr/0018-release-sbom-identity.md b/docs/adr/0018-release-sbom-identity.md new file mode 100644 index 000000000..54825e628 --- /dev/null +++ b/docs/adr/0018-release-sbom-identity.md @@ -0,0 +1,192 @@ +# ADR 0018: Release SBOM identity binding and bounded JSON-LD envelope verification + +- Status: Proposed +- Date: 2026-08-23 + +## Context + +Issue #201 requires acquisition-grade release evidence, including a software bill of materials tied to the same immutable release identity buyers verify. ADR 0015 deliberately stops at a bounded release-manifest identity and does not claim SBOM content conformance, provenance, signing, publication, installation, update, rollback, or release authority. + +SPDX 3.0.1 defines JSON-LD serialization and separate structural and semantic validation requirements. The serialization prose requires the versioned global context `https://spdx.org/rdf/3.0.1/spdx-context.jsonld`, defines `spdxId` as an alias for `@id` and `type` as an alias for `@type`, requires Element nodes to be represented as top-level objects in `@graph`, and says additional namespace mappings may be defined in a separate context object. The same SPDX 3.0.1 specification requires structural validation against its official JSON Schema. That schema constrains top-level `@context` with a `const` equal to the exact versioned context string. + +Those two official 3.0.1 resources therefore expose an interoperability tension: the prose describes extension mappings that JSON-LD can represent with context composition, while the published structural schema accepts only the exact context string. More importantly for this preliminary verifier, an arbitrary inline JSON-LD context object is not merely a namespace map: JSON-LD control terms such as `@import`, `@base`, and `@vocab`, or redefinitions of SPDX aliases such as `type` and `spdxId`, can change downstream interpretation. Treating every inline object as a benign namespace map would therefore create semantic and remote-authority ambiguity before schema/ontology validation. + +RFC 8259 permits implementations to set limits on accepted JSON size and nesting depth. That latitude matters here because a byte ceiling alone is not a heap-allocation ceiling. The preceding verifier admitted compact valid documents that stayed well below 16 MiB while forcing large generic trees: first through more than 524,288 nested containers, then through more than 1,048,576 scalar array members with only a few containers. A release-facing hostile-input boundary therefore needs pre-materialization structural fan-out invariants in addition to byte, graph, and numeric-token bounds. + +OriginWeave separates four claims that must not be collapsed: + +1. the declared SPDX serialization identity; +2. the immutable release-manifest/SBOM artifact identity join, including the exact manifest-backed SBOM SHA-256 digest; +3. a narrow, bounded check of the actual JSON-LD serialization envelope over those exact candidate bytes; and +4. full SPDX content conformance and product-specific package/component completeness. + +This slice implements claims 1 through 3 only. Claim 4 remains a later independently reviewed release gate. + +## Decision drivers + +- SBOM identity must not drift away from the exact release manifest it supplements. +- The supported SPDX JSON-LD format must expose its exact specification version and required versioned global context identity rather than rely on an ambient or moving context. +- The preliminary envelope gate must not interpret arbitrary JSON-LD context controls or term redefinitions as harmless namespace declarations. +- The gate should remain structurally compatible with the official SPDX 3.0.1 JSON Schema until a reviewed schema-aware extension policy exists. +- Actual serialized bytes must not be accepted merely because metadata says they are SPDX 3.0.1 JSON-LD. +- A syntactically valid but substituted SPDX document must not satisfy release evidence when its bytes do not match the exact SBOM artifact digest already admitted by the release manifest. +- Envelope parsing must be bounded before deeper semantic use and before unbounded generic-tree materialization. Compact container or scalar/member fan-out must not bypass resource policy merely because the byte ceiling or top-level graph bound is still satisfied. +- Envelope parsing must reject malformed UTF-8, malformed JSON, duplicate JSON object members, non-finite JSON constants, unexpected top-level members, non-object graph entries, excessive graph cardinality, a nested-only `SpdxDocument`, and zero or multiple `SpdxDocument` objects. +- Error diagnostics must not reflect attacker- or supplier-controlled document bytes or release digest values. +- The SBOM artifact itself must already be an admitted release artifact with a manifest-bound SHA-256 identity. +- Every non-SBOM release artifact in the bound manifest must be represented exactly once in the described-artifact inventory. +- Empty, incomplete, duplicate, foreign, self-described, and case-drifted artifact identities must fail closed. +- Partial digest/envelope validation must not be represented as SPDX JSON Schema/OWL/SHACL conformance, cryptographic provenance, signing, publication, installation, update, rollback, or release authority. + +## Assumptions and authority boundaries + +- ADR 0015 remains the owning decision for release-manifest identity admission. +- `ReleaseSbomFormat::Spdx30JsonLd` identifies SPDX 3.0.1 serialized as JSON-LD and exposes the exact required global context URI. +- `ReleaseSbomBinding` can reference only exact, case-sensitive artifact names already present in one `ReleaseManifest`. +- The SBOM artifact is represented by the existing manifest-backed `ReleaseArtifact`, including its lowercase `sha256:` identity, and cannot also appear in the binding's described-artifact set. +- Every other artifact admitted by that release manifest must be retained in the described-artifact set exactly once. This is an OriginWeave release-inventory completeness rule, not a general SPDX modeling assertion. +- `scripts/release/validate_spdx_jsonld.py` consumes actual bytes for a narrow envelope check. It accepts at most 16 MiB, requires strict UTF-8 JSON, performs a string-aware lexical preflight before `json.loads`, caps opening JSON containers at 262,144 and total structural `[]{}:,` tokens at 1,048,576, requires exactly the top-level `@context` and `@graph` members, requires `@context` to equal the exact SPDX 3.0.1 global-context string, requires exactly one top-level `SpdxDocument`, and rejects any additional nested `SpdxDocument`. Context arrays and inline/remote context extensions fail closed at this stage. +- The lexical preflight counts structural punctuation only outside JSON strings. It is a resource-admission check, not a substitute JSON grammar; `json.loads` remains responsible for syntax after the preflight. The 262,144-container and 1,048,576-structure-token thresholds are provisional OriginWeave resource-policy ceilings rather than SPDX semantic limits. The container ceiling is deliberately reachable before the broader token ceiling for shallow fan-out. +- Release admission that composes those bytes with `ReleaseSbomBinding` uses `validate_release_spdx_3_0_1_jsonld_bytes` and supplies the exact canonical lowercase `sha256:` identity from `ReleaseSbomBinding::sbom_artifact()`. The helper recomputes SHA-256 from the bounded candidate bytes and rejects malformed expected identities or mismatches before promoting envelope evidence. +- Digest equality proves correspondence to the declared manifest artifact identity only. It does not authenticate who produced the manifest or bytes and does not replace provenance, signature, transparency, reproducibility, or release-approval checks. +- The exact-string context choice is deliberately narrower than the SPDX serialization prose because the official 3.0.1 JSON Schema itself uses that exact-string constraint and because this preliminary verifier does not implement complete JSON-LD context semantics or collision detection against the pinned SPDX context. +- The verifier does not fetch remote contexts or schemas, resolve external identifiers, validate the full SPDX JSON Schema, evaluate the SPDX ontology/SHACL constraints, infer package completeness, or grant any signing/release/update authority. +- Full conformance still requires validation of the same digest-bound serialized bytes against reviewed SPDX 3.0.1 structural and semantic resources plus OriginWeave product-completeness rules. +- The current Python implementation is provisional migration evidence. Release-facing parser/resource and secure-file admission remain scheduled for reconstruction under the Rust-owned `originweave-release` bounded context; Python must not become the permanent canonical security authority. + +## Options considered + +### Trust the declared SBOM format without reading bytes + +Rejected. A manifest can correctly identify an artifact while a different candidate file is supplied, or while the candidate bytes contain the wrong context, malformed JSON, an ambiguous duplicate-key object, or multiple `SpdxDocument` definitions. + +### Validate an SPDX envelope without binding the bytes to the declared artifact digest + +Rejected for release admission. A completely valid SPDX document for another release could pass the envelope gate and be associated with the wrong `ReleaseSbomBinding`. The release-facing helper therefore hashes the exact bounded candidate bytes and compares the canonical `sha256:` identity to the manifest-backed SBOM artifact before returning release evidence. + +### Bound only raw bytes and top-level graph cardinality + +Rejected. A compact document can keep both measures within their limits while encoding hundreds of thousands of nested containers or more than a million scalar members. A generic JSON parser then materializes that tree before the top-level graph check runs. The preliminary boundary therefore needs both opening-container and broader structure-token budgets before materialization, and the final Rust owner must enforce an equivalent or stricter invariant through a bounded or streaming parse path. + +### Admit a nested-only `SpdxDocument` + +Rejected. SPDX 3.0.1 places Element nodes, including `SpdxDocument`, at the top-level `@graph`. Recursively finding one document below another graph object proves only that a matching string exists somewhere in the JSON tree; it does not satisfy the serialization structure. The envelope gate therefore requires exactly one top-level `SpdxDocument` and separately rejects any additional nested document. + +### Admit the exact SPDX context plus arbitrary inline context objects + +Rejected. An inline JSON-LD object can contain `@import`, `@base`, `@vocab`, or term/alias redefinitions rather than only a new namespace prefix. Without a complete pinned-context collision model, accepting those objects would allow the preliminary gate to count raw `type` values under semantics that later JSON-LD processing may reinterpret. + +### Require `@context` to be only the SPDX global-context string + +Selected for this preliminary gate. The official SPDX 3.0.1 JSON Schema constrains `@context` to that exact string, this representation is used by the specification's normative example, and the rule prevents context-array semantic or remote-authority ambiguity. SPDX prose support for additional namespace mappings is retained as an explicit interoperability follow-up rather than silently approximated with an unsafe object-type check. + +### Fetch the JSON-LD context or schema during the first verifier + +Rejected. Ambient network retrieval would introduce moving external authority and availability into a release gate before pinned schema/ontology identities and offline verification are designed. The first verifier uses fixed local constants and no network access. + +### Treat a general-purpose JSON parse as SPDX conformance + +Rejected. JSON syntax alone does not establish the required SPDX context, document cardinality or placement, JSON Schema validity, ontology/SHACL validity, or SBOM completeness. + +### Store an arbitrary SBOM path or URL next to the manifest + +Rejected. An ambient path or URL can drift from the bounded release inventory and does not guarantee that the SBOM itself is one of the artifacts buyers can hash against the manifest. + +### Accept only a selected subset of manifest artifacts + +Rejected. A non-empty manifest-backed subset can look well formed while silently omitting another release artifact. OriginWeave therefore requires every non-SBOM manifest artifact to appear exactly once in the described-artifact inventory. + +### Allow the SBOM artifact to describe itself + +Rejected for OriginWeave's release identity contract. A self-referential described-artifact set makes the artifact-to-SBOM join circular at the exact-digest boundary verification must evaluate. This is an OriginWeave integrity rule, not a claim that SPDX generally forbids such a relationship. + +## Decision + +OriginWeave adopts the following release-SBOM boundary: + +1. The initial format is `ReleaseSbomFormat::Spdx30JsonLd`, reporting SPDX specification version `3.0.1` and global context URI `https://spdx.org/rdf/3.0.1/spdx-context.jsonld`. +2. The named SBOM artifact must exactly match an artifact admitted by the same `ReleaseManifest`. +3. At least one distinct described release artifact is required. +4. Every non-SBOM artifact admitted by the same release manifest must appear exactly once in the described-artifact inventory. +5. The SBOM artifact itself cannot be one of its described artifact identities. +6. Foreign, duplicate, incomplete, and case-drifted described artifact identities fail closed. +7. Described artifact identities are stored deterministically and the manifest's existing bound transitively limits the inventory. +8. Release-facing validation of actual candidate bytes uses `validate_release_spdx_3_0_1_jsonld_bytes` with the exact `sha256:` identity from the manifest-backed SBOM artifact. The expected digest must be `sha256:` followed by 64 lowercase hexadecimal digits; the verifier recomputes SHA-256 over the same bounded bytes and a mismatch fails closed with a value-redacted typed error. +9. Only after exact digest correspondence is established do those candidate bytes pass through the narrow `validate_spdx_3_0_1_jsonld_bytes` envelope contract. The lower-level function remains available for composition and testing but is not by itself release-artifact correspondence evidence. +10. The envelope verifier admits only non-empty strict-UTF-8 payloads no larger than 16 MiB. Before `json.loads`, it applies a string-aware structural preflight that admits at most 524,288 opening JSON containers and 1,048,576 total `[]{}:,` structural tokens, while ignoring that punctuation inside JSON strings. It then rejects duplicate JSON keys and non-finite constants, requires exactly the top-level `@context` and `@graph` members, requires `@context` to equal the exact versioned SPDX 3.0.1 context string, allows at most 65,536 graph objects, requires object entries with string `type`, requires exactly one top-level raw `SpdxDocument` under the pinned alias semantics, and rejects any additional nested `SpdxDocument`. +11. Validation errors expose stable error codes and generic diagnostics without reflecting document bytes or expected digest values. +12. Digest/envelope success is not a certificate of complete SPDX JSON Schema validity, semantic ontology/SHACL validity, package/component completeness, producer authenticity, provenance, signatures, publication, installation, update, rollback, or release authority. +13. No verifier fallback may substitute another SPDX version, discard the required pinned context or expected artifact digest, reinterpret an unvalidated context extension, discard duplicate keys, silently truncate a graph, bypass either structural resource budget, fetch an ambient remote resource, or convert malformed or mismatched content into success. +14. The eventual Rust-owned `originweave-release` implementation must preserve or strengthen the byte/container/structure-token/graph/numeric and diagnostic limits before unbounded generic-tree materialization. The provisional Python scanner is migration evidence, not the permanent canonical parser/security boundary. + +## Consequences + +Downstream release tooling receives an exact versioned serialization identity, a non-circular and release-inventory-complete identity join, and a bounded first check over the exact bytes named by that join. A syntactically valid SPDX document for a different artifact cannot satisfy the release-facing helper merely because its envelope is valid. A candidate also cannot pass this layer with the wrong SPDX context, a context array that can alter JSON-LD interpretation, an extra top-level field, malformed JSON, duplicate members, a non-object graph entry, an unbounded graph, excessive container or scalar/member fan-out, a nested-only `SpdxDocument`, or zero/multiple `SpdxDocument` objects. + +The exact-context rule can reject a serialization that the SPDX 3.0.1 prose intends to allow through additional namespace mappings. That false negative is accepted at this preliminary gate because the same version's official structural schema currently requires the exact string and because OriginWeave does not yet possess a reviewed, schema-aware rule capable of proving that an extension object is namespace-only and non-colliding. Support for such mappings must be added only together with pinned full structural/semantic validation or another equally strong proof of interpretation. + +The structural limits can reject a JSON document that is syntactically valid and within the byte ceiling. That false negative is also accepted at this release-security boundary: RFC 8259 permits implementation resource limits, and OriginWeave prefers a bounded failure over generic-tree heap amplification. The exact ceilings are provisional and may change only with measured realistic SBOM corpus and resource evidence; they are not SPDX modeling requirements. + +The boundary remains intentionally narrower than a commercial SBOM generator or complete verifier. The official SPDX 3.0.1 specification requires structural validation against the JSON Schema and semantic validation against the OWL ontology/SHACL constraints; those checks, package/component completeness, root-element rules, producer/provenance authentication, signing, and integrated release acceptance remain separate work. Digest/envelope success must never be presented as full SPDX conformance or signed supply-chain provenance. + +## Failure and degraded behavior + +Manifest-binding failures occur before a binding is produced. Release-facing byte verification rejects a malformed expected digest or candidate-byte digest mismatch with deterministic value-redacted error codes. Envelope failures likewise return deterministic redacted error codes and no document-controlled value is echoed into the diagnostic. Oversized input, excessive opening-container count, and excessive broader structural fan-out are rejected before JSON tree materialization. Invalid UTF-8, invalid JSON, duplicate keys, missing or incorrect required context, context arrays/extensions, invalid top-level shape, non-object graph entries, excessive graph cardinality, nested-only documents, and invalid `SpdxDocument` cardinality all fail closed. + +Failure at either layer does not authorize release through another path. There is no alternate digest, alternate version fallback, network context fallback, permissive parse mode, resource-limit bypass, or silent default success. + +## Security / privacy / governance impact + +The boundary reduces supply-chain ambiguity without introducing ambient network authority. Exact SHA-256 correspondence prevents a different but otherwise valid SPDX document from being promoted under the manifest-backed SBOM identity. Pre-materialization container and structure-token admission plus byte, graph, and numeric bounds constrain memory exposure before deeper validation; duplicate-key rejection prevents parser interpretation drift; top-level `SpdxDocument` placement prevents a nested object from masquerading as the serialization's document element; and the pinned exact SPDX context prevents version drift, remote `@import`, `@base`/`@vocab` mutation, and term/alias rebinding from being treated as already-admitted envelope semantics. Redacted diagnostics prevent supplier-controlled SBOM bytes or expected digest values from being reflected into CI/release logs. The verifier introduces no secrets, personal data, signing material, privileged network access, reviewer authority, or release authority. + +## Tests and acceptance evidence + +The Rust identity tests must continue to prove exact SPDX specification/context identity, manifest-backed SBOM admission, complete coverage of every non-SBOM manifest artifact, deterministic ordering, and typed fail-closed binding behavior. + +The provisional Python release-verifier contract tests must prove: + +- exact candidate bytes match the canonical lowercase manifest-backed `sha256:` identity; +- a valid but substituted SPDX document fails closed on digest mismatch without reflecting document-controlled bytes; +- malformed, uppercase, truncated, or prefixless expected digests fail closed; +- exact SPDX 3.0.1 context and one top-level `SpdxDocument` are admitted; +- a compact payload below the 16 MiB byte ceiling but above the opening-container budget fails before JSON tree materialization; +- a compact scalar-array payload below the byte ceiling but above the broader structure-token budget also fails before JSON tree materialization; +- bracket/brace/comma/colon characters and escaped quote/backslash content inside JSON strings do not consume the structural budgets; +- a nested-only `SpdxDocument` and a nested second `SpdxDocument` fail closed; +- context arrays, including apparently simple namespace mappings, fail closed until schema-aware extension validation exists; +- `@import`, `@base`, `@vocab`, `type`, and `spdxId` context overrides fail closed; +- wrong context, additional remote context entries, and unexpected top-level members fail closed; +- duplicate JSON keys, malformed UTF-8, non-finite constants, invalid graph entries, excessive graph size, and zero/multiple documents fail closed; and +- hostile external bytes never appear in diagnostics. + +The final Rust Release migration must reproduce both parser-amplification RED classes before implementing its bounded or streaming parser. Repository Python contracts, Rust 1.97.1 formatting/check/tests/Clippy/rustdoc, exact Rust production function/line/region/branch coverage, and any applicable browser/security gates must pass on the unchanged exact head. Predecessor evidence does not transfer. + +## Migration and rollback + +This remains a pre-release branch with no protected-main persisted SBOM schema migration. The verifier has no network or durable state. Before acceptance it can be revised or withdrawn with its owning branch. If a durable external release/SBOM format depends on this boundary, incompatible digest, context, size, container, structure-token, graph, or error-contract changes require explicit versioning and migration rather than silent parser broadening. + +## Open follow-ups + +- Generate deterministic SPDX 3.0.1 SBOM content for supported release packages. +- Validate the same digest-bound candidate bytes against reviewed, immutable SPDX 3.0.1 JSON Schema and OWL/SHACL resources using offline-verifiable identities. +- Reconcile the SPDX 3.0.1 prose allowance for additional namespace mappings with the official 3.0.1 JSON Schema's exact-string `@context` constraint before admitting context composition. +- Migrate the release-facing hostile JSON/resource boundary into `originweave-release` Rust with a bounded or streaming parser and preserve both pre-materialization resource contracts. +- Define package/component completeness and root-element rules for OriginWeave distribution artifacts. +- Compose the digest-bound SBOM result with SLSA provenance, signing identity, timestamps, platform packages, reproducibility evidence, and updater trust. +- Complete issue #201's integrated release acceptance and rollback/freeze-protection architecture. + +## Supersession / reversal conditions + +Supersede this ADR when a versioned external release/SBOM specification replaces the in-process binding and envelope contract, when OriginWeave adopts a different SBOM standard under a reviewed migration, or when a stronger integrated provenance model subsumes these boundaries. Reversal must preserve fail-closed manifest-backed identity and must not make metadata or partial parsing equivalent to release authority. + +## References + +Bray, T. (2017). *The JavaScript Object Notation (JSON) Data Interchange Format* (RFC 8259; STD 90). Internet Engineering Task Force. https://www.rfc-editor.org/rfc/rfc8259 + +SPDX Workgroup. (2026). *SPDX specification 3.0.1*. The Linux Foundation. https://spdx.github.io/spdx-spec/v3.0.1/ + +SPDX Workgroup. (2026). *SPDX specification 3.0.1: Model and serializations*. The Linux Foundation. https://spdx.github.io/spdx-spec/v3.0.1/serializations/ + +SPDX Workgroup. (2026). *SPDX 3.0.1 JSON Schema*. The Linux Foundation. https://spdx.org/schema/3.0.1/spdx-json-schema.json + +World Wide Web Consortium. (2020, July 16). *JSON-LD 1.1: A JSON-based serialization for linked data*. https://www.w3.org/TR/json-ld11/ diff --git a/docs/adr/README.md b/docs/adr/README.md index 69994b9b1..a09d378df 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -57,14 +57,16 @@ Proposed ADR files are reviewable target architecture without becoming Accepted ADR 0013 and ADR 0014 exist only on this documentation branch until it integrates. After integration, this subsection remains historical provenance rather than an active-PR claim; both decisions remain Proposed until a later policy-compliant change explicitly changes their lifecycle. -### Proposed decisions introduced by active feature work +### Active feature proposed decisions | ADR | Decision | Status | Governs | |---|---|---|---| | [0015](0015-release-manifest-identity.md) | Release manifest identity boundary | Proposed | bounded release-candidate metadata identity, deterministic artifact ordering, cross-platform filename collision guards, and explicit exclusion of signing/publication/install/update authority | | [0016](0016-bap-task-lifecycle-authority.md) | BAP task lifecycle and state authority | Proposed | BAP task states, transitions, recovery validation, transition sequencing, and authority separation | +| [0018](0018-release-sbom-identity.md) | Release SBOM identity binding | Proposed | exact manifest-backed SPDX 3.0.1 SBOM artifact identity and described-artifact membership without content-conformance or release authority claims | -ADR 0015 is active-PR architecture evidence for the release-manifest identity slice. ADR 0016 belongs to the active BAP lifecycle feature branch. Both remain Proposed. Indexing them makes the branch documentation graph complete without promoting either decision or its implementation to protected-main truth. + +ADR 0015 is active-PR architecture evidence for the release-manifest identity slice. ADR 0016 belongs to the active BAP lifecycle feature branch. ADR 0018 is stacked active-PR architecture evidence for the release-SBOM identity slice. All remain Proposed. Indexing them makes the branch documentation graph complete without promoting either decision or implementation to protected-main truth. After protected-main integration, retain this subsection only when it is intentionally serving as historical provenance; otherwise protected-main reconciliation must remove it. In either case, integration alone does not change these ADRs from Proposed or assert implementation maturity. @@ -142,4 +144,4 @@ Material external standards or research belong in APA 7th format in [`../doctori - [`../traceability/README.md`](../traceability/README.md) maps requirements and decisions to implementation and evidence. - [`../DOCUMENTATION_FITNESS.md`](../DOCUMENTATION_FITNESS.md) records semantic completeness and stale/current findings across the graph. -If these artifacts disagree about current implementation, protected-main source, executable tests, built/released artifacts, configuration/migrations, and protected-main operational evidence appropriate to the claim define implementation truth. Accepted ADRs explain governing design decisions; they do not upgrade missing behavior into shipped behavior. The disagreement is a documentation or implementation defect that must be repaired rather than silently rationalized from conversation history. \ No newline at end of file +If these artifacts disagree about current implementation, protected-main source, executable tests, built/released artifacts, configuration/migrations, and protected-main operational evidence appropriate to the claim define implementation truth. Accepted ADRs explain governing design decisions; they do not upgrade missing behavior into shipped behavior. The disagreement is a documentation or implementation defect that must be repaired rather than silently rationalized from conversation history. diff --git a/docs/doctoring.md b/docs/doctoring.md index 73b34cfd4..94e29d2bd 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -86,6 +86,12 @@ The test-only rcgen 0.14.8 dependency creates a local CA and deterministic certi Microsoft's Win32 filename guidance requires applications not to assume case sensitivity and reserves device basenames including `CON`, `PRN`, `AUX`, `NUL`, `COM1` through `COM9`, and `LPT1` through `LPT9`, including those names followed by extensions. The same documentation identifies non-ASCII superscript-digit device aliases and also documents `COM0` as a possible `Global??` Win32 namespace symlink. Separately, Microsoft's current OneDrive and SharePoint restrictions reject `COM0` through `COM9` and `LPT0` through `LPT9` and additional names including `desktop.ini`. OriginWeave therefore adopts a bounded release-artifact identity deny set: ASCII-case-folded duplicate names and `CON`, `PRN`, `AUX`, `NUL`, `COM0` through `COM9`, and `LPT0` through `LPT9`, including extensions, are rejected. The ASCII-only grammar separately excludes the superscript aliases. This bounded filename rule is not a complete OneDrive or SharePoint synchronization-compatibility guarantee; packaging or synchronization validation for a target service must separately enforce that service's full current restrictions. These checks preserve the original artifact spelling and remain identity hygiene only; they do not grant signing, publication, installation, update, rollback, or release authority. +### Release SBOM identity + +SPDX 3.0.1 defines JSON-LD as an RDF serialization for SPDX data and requires conformant JSON-LD to pass both the versioned JSON Schema and the SPDX ontology/SHACL constraints. Its serialization prose requires the global context `https://spdx.org/rdf/3.0.1/spdx-context.jsonld`, defines `spdxId` and `type` as aliases for JSON-LD `@id` and `@type`, and requires Element nodes—including `SpdxDocument`—to be represented as top-level objects under `@graph`. It also says additional namespace mappings may be defined in a separate context object. The official SPDX 3.0.1 JSON Schema simultaneously constrains top-level `@context` to the exact global-context string. Because an arbitrary inline JSON-LD context object can contain semantic controls such as `@import`, `@base`, or `@vocab`, or redefine SPDX aliases, OriginWeave does not approximate “namespace mapping” by accepting every object. The preliminary envelope gate therefore requires exactly one top-level `SpdxDocument`, rejects a nested-only document as well as any additional nested document, follows the official schema's exact-string context identity, and rejects context composition until a reviewed schema-aware validator can reconcile the prose/schema tension without granting ambient or redefinition authority. This gate deliberately does not claim full content-level conformance: it binds a declared SPDX 3.0.1 JSON-LD SBOM artifact identity and exact release-artifact names to one immutable `ReleaseManifest`, while generation, full structural/semantic validation, package/component completeness, signatures, provenance, publication, installation, update, rollback, and release authority remain separate reviewed boundaries. + +RFC 8259 §9 permits implementations to set limits on accepted JSON size and nesting depth. OriginWeave applies that implementation latitude narrowly to hostile release evidence. A 16 MiB byte ceiling and 65,536 top-level graph-object ceiling did not prevent compact valid documents from forcing a large generic JSON tree: one reproduction exceeded 262,144 shallow containers, and another exceeded 1,048,576 scalar array members while using only a few containers. The provisional Python envelope gate now scans structural punctuation outside JSON strings before `json.loads`, caps opening containers at 262,144 and total `[]{}:,` structure tokens at 1,048,576, and leaves grammar validation to the JSON parser. These are OriginWeave resource-policy ceilings rather than SPDX semantic limits. The final Rust-owned Release boundary must reproduce both amplification cases and enforce equivalent or stricter pre-materialization byte/container/member/graph/numeric bounds through a bounded or streaming parse path; Python remains migration evidence rather than canonical release/security authority. + ### Git source identity admission Git's protocol grammar distinguishes an ordinary 40-hex object identifier from `zero-id = 40*"0"`. The pack protocol uses that all-zero sentinel to represent absent refs and create/delete reference state rather than a concrete source object. OriginWeave therefore requires release-manifest `source_commit` and WARC PROV `software_commit_sha` values to be canonical lowercase 40-hex identifiers with at least one nonzero digit. This is an identity-admission rule only: it prevents a null Git sentinel from being represented as a concrete software revision, but does not prove repository reachability, GitHub authenticity, commit trust, provenance, or release authority. @@ -130,6 +136,8 @@ Berners-Lee, T., Fielding, R., & Masinter, L. (2005). *Uniform resource identifi Bonica, R., Cotton, M., Haberman, B., & Vegoda, L. (2017). *Updates to the special-purpose IP address registries* (RFC 8190). Internet Engineering Task Force. https://doi.org/10.17487/RFC8190 +Bray, T. (2017). *The JavaScript Object Notation (JSON) Data Interchange Format* (RFC 8259; STD 90). Internet Engineering Task Force. https://doi.org/10.17487/RFC8259 + Chromium Authors. (n.d.). *Proxy support in Chrome* [Source documentation]. Chromium. https://chromium.googlesource.com/chromium/src/+/a3e71ebfa307d8760eb68b777e2998a869940092/net/docs/proxy.md Chromium Authors. (2026). *URL canonicalizer unit tests* [Source code]. Chromium. https://chromium.googlesource.com/chromium/src/+/446d05d21720f0b3505ec21057b3e9f909784262/url/url_canon_unittest.cc @@ -192,6 +200,12 @@ Sakana AI. (2026, June 22). *Sakana Fugu: One model to command them all*. https: Sheffer, Y., Saint-Andre, P., & Fossati, T. (2022). *Recommendations for secure use of Transport Layer Security (TLS) and Datagram Transport Layer Security (DTLS)* (RFC 9325). Internet Engineering Task Force. https://doi.org/10.17487/RFC9325 +SPDX Workgroup. (2026). *SPDX specification 3.0.1*. The Linux Foundation. https://spdx.github.io/spdx-spec/v3.0.1/ + +SPDX Workgroup. (2026). *SPDX specification 3.0.1: Model and serializations*. The Linux Foundation. https://spdx.github.io/spdx-spec/v3.0.1/serializations/ + +SPDX Workgroup. (2026). *SPDX 3.0.1 JSON Schema*. The Linux Foundation. https://spdx.org/schema/3.0.1/spdx-json-schema.json + The Rust Project Developers. (2026). *Ipv4Addr in std::net* (Rust 1.97.1) [Software documentation]. https://doc.rust-lang.org/stable/std/net/struct.Ipv4Addr.html The Rust Project Developers. (2026). *Ipv6Addr in std::net* (Rust 1.97.1) [Software documentation]. https://doc.rust-lang.org/stable/std/net/struct.Ipv6Addr.html @@ -212,4 +226,4 @@ World Wide Web Consortium. (2026, June 1). *WebDriver BiDi* (W3C Working Draft). Xu, J., Sun, Q., Schwendeman, P., Nielsen, S., Cetin, E., & Tang, Y. (2025). *TRINITY: An evolved LLM coordinator* [Preprint]. arXiv. https://doi.org/10.48550/arXiv.2512.04695 -Zhou, S., Xu, F. F., Zhu, H., Zhou, X., Lo, R., Sridhar, A., Cheng, X., Ou, T., Bisk, Y., Fried, D., Alon, U., & Neubig, G. (2023). *WebArena: A realistic web environment for building autonomous agents*. arXiv. https://doi.org/10.48550/arXiv.2307.13854 \ No newline at end of file +Zhou, S., Xu, F. F., Zhu, H., Zhou, X., Lo, R., Sridhar, A., Cheng, X., Ou, T., Bisk, Y., Fried, D., Alon, U., & Neubig, G. (2023). *WebArena: A realistic web environment for building autonomous agents*. arXiv. https://doi.org/10.48550/arXiv.2307.13854 diff --git a/docs/traceability/release-sbom-depth-256.md b/docs/traceability/release-sbom-depth-256.md new file mode 100644 index 000000000..a1bbf686e --- /dev/null +++ b/docs/traceability/release-sbom-depth-256.md @@ -0,0 +1,22 @@ +# Release SBOM JSON nesting-depth invariant + +- Status: Proposed +- Owning issue: #201 +- Active repair: #221 +- Intended Release owner: `originweave-release` after the #240 owner lineage is integrated + +PR #221 currently sets `MAX_SPDX_JSON_NESTING_DEPTH = 256` in the provisional SPDX 3.0.1 JSON-LD verifier. The string-aware preflight rejects depth 257 with `too_deep_json_structure` before `json.loads`, admits the exact depth-256 boundary to normal JSON grammar validation, and does not count bracket-looking characters inside JSON strings toward the depth budget. + +This limit is an OriginWeave resource-admission policy, not an SPDX semantic requirement. RFC 8259 section 9 permits JSON implementations to set a maximum nesting depth. The final Rust-owned Release implementation must preserve or deliberately strengthen the same explicit pre-materialization depth invariant together with the existing byte, container, structure-token/member, graph, and numeric limits; generic parser recursion must not become the effective boundary. + +The depth lineage is review `5097073272`, test-only candidate `16a2e1eedf121c6e89eb9019db0906e8ea49f0ce`, and production/test repair `93f1d13e38c07dbe1217bbfa50d18d5eb696f094`. The test-only candidate did not acquire a hosted runner, so it is candidate test-first evidence rather than an observed hosted RED. + +As of September 3, 2026, #221 still targets SPDX 3.0.1. SPDX 3.1-RC1 is a release candidate and does not implicitly change this contract; adopting it requires an explicit version, schema, serialization, and ontology migration. + +## References + +Bray, T. (2017). *The JavaScript Object Notation (JSON) Data Interchange Format* (RFC 8259; STD 90). Internet Engineering Task Force. https://www.rfc-editor.org/rfc/rfc8259 + +SPDX Workgroup. (2026). *SPDX specification 3.0.1: Model and serializations*. The Linux Foundation. https://spdx.github.io/spdx-spec/v3.0.1/serializations/ + +SPDX Workgroup. (2026). *SPDX specification 3.1-RC1: Model and serializations*. The Linux Foundation. https://spdx.github.io/spdx-spec/v3.1-RC1/serializations/ diff --git a/docs/traceability/release-sbom-owner-migration.md b/docs/traceability/release-sbom-owner-migration.md new file mode 100644 index 000000000..b5f99c8e6 --- /dev/null +++ b/docs/traceability/release-sbom-owner-migration.md @@ -0,0 +1,92 @@ +# Release SBOM canonical-owner migration + +- Status: Proposed +- Owning gap: #201 +- Repair PR: #221 +- Release-context owner candidate: #240 + +This record is migration evidence for active pre-GA work. Active PR metadata is **not shipped truth**; protected `main` remains implementation truth until the dependency-ordered reconstruction, exact-head verification, and normal governance path complete. + +## Problem + +PR #221 currently carries release-specific SBOM identity and completeness semantics in `originweave-core` and implements release-facing digest, hostile JSON-LD envelope, and local file admission canonically in `scripts/release/validate_spdx_jsonld.py`. Four independently valid constraints now make that placement provisional: + +1. OriginWeave's first-party security/runtime boundary is Rust-first. Digest correspondence, bounded hostile JSON parsing, and descriptor-relative/no-follow file admission have practical Rust implementations, so Python cannot remain the canonical security boundary without an explicit no-practical-Rust rationale and removal condition. +2. Active PR #240 establishes `originweave-release` as the dedicated Release bounded context and moves release policy/evidence ownership out of `originweave-core`. Shipping #221 unchanged would therefore create competing canonical ownership. +3. The current Python secure-open path deliberately fails closed when POSIX-style `O_NOFOLLOW` plus descriptor-relative `os.open(..., dir_fd=...)` are unavailable. That is safe for the provisional verifier, but #201 is a cross-platform distribution/release track. The final Rust owner must preserve the same anti-symlink/reparse and identity-stability guarantees on each supported release platform instead of making a Unix-only primitive the commercial release contract. +4. A 16 MiB input-byte ceiling and 65,536 top-level `@graph` objects did not bound parser heap amplification. The first reproduction used a compact nested-container payload; a second reproduction showed that a roughly 2 MiB document with more than 1,048,576 scalar array members also passed the container-only preflight and was materialized by `json.loads`. The provisional boundary therefore now applies both a 524,288 opening-container ceiling and a 1,048,576 JSON structure-token ceiling before generic-tree materialization. The Rust migration must preserve an equivalent or stricter pre-materialization resource invariant rather than deserialize hostile input into an unbounded generic tree first. + +## Target ownership + +`ReleaseManifest` remains a stable cross-context identity contract in `originweave-core`. The Release context depends inward on that contract; core must not depend outward on Release. + +After the #240 owner lineage is available for integration, `originweave-release` is the proposed canonical owner for: + +- SPDX serialization identity and SBOM binding; +- manifest-to-SBOM described-artifact completeness policy; +- release-facing SHA-256 correspondence; +- bounded hostile JSON-LD envelope admission; +- identity-stable local SBOM file admission; and +- later full SPDX structural/semantic conformance and its release evidence. + +There is **no compatibility shim** that makes core depend on Release, and there is **no duplicated release API** split across `originweave-core` and `originweave-release`. The active #240 branch is evidence of the intended owner boundary, not shipped truth and not authority to copy its tree wholesale. + +`scripts/release/validate_spdx_jsonld.py` may remain only as a **thin conformance/fixture harness** after the Rust-owned admission boundary exists. Python must not remain the canonical source of release/security authority. + +## Invariants that reconstruction must preserve + +The Rust-owned replacement must preserve or strengthen the currently reviewed fail-closed behavior: + +- maximum 16 MiB candidate input before unbounded parsing/allocation; +- a pre-materialization opening-container budget equivalent to or stricter than the provisional 262,144-container ceiling; +- a broader pre-materialization JSON structure-token budget equivalent to or stricter than the provisional 1,048,576-token ceiling so scalar/member fan-out is bounded as well as container count; +- bracket/brace/comma/colon text inside JSON strings must not consume structural budgets, while malformed JSON still fails in the grammar parser; +- strict UTF-8 and JSON admission; +- duplicate object-key and non-finite numeric rejection; +- exact SPDX 3.0.1 global context identity for this preliminary gate; +- top-level `@graph` object shape and bounded graph cardinality; +- exactly one top-level `SpdxDocument` and rejection of any additional nested `SpdxDocument`; +- canonical lowercase `sha256:` manifest/SBOM digest correspondence; +- value-redacted diagnostics that do not echo hostile document bytes, expected digest values, or candidate paths; +- regular-file admission through an already-opened, identity-stable handle rather than path-only trust; +- on POSIX-capable targets, descriptor-relative `openat`-class traversal with no-follow semantics and identity revalidation before/during/after the bounded read; +- on Windows targets, use reviewed handle-based traversal/admission that prevents ordinary reparse-point following. The concrete leaf/reparse admission primitive must include `CreateFileW` with `FILE_FLAG_OPEN_REPARSE_POINT`; identity checks must use `GetFileInformationByHandleEx(FileIdInfo)` / `FILE_ID_INFO`, comparing the `VolumeSerialNumber` plus `FileId` for the opened handle before and after the bounded read. `CreateFileW` alone is not treated as proof that parent-directory swaps are impossible; the final implementation must use an equivalent handle-relative parent traversal or another reviewed mechanism and prove it with junction/reparse and parent-swap regressions; and +- fail-closed behavior on a platform where equivalent anti-redirection and identity-stability guarantees cannot be established. Portability must not silently degrade to lexical/path-only checks. + +Digest/envelope success remains correspondence evidence only. It is not full SPDX JSON Schema/OWL/SHACL conformance, SLSA provenance, reproducibility, signature verification, or release approval. + +## Dependency-ordered repair + +1. Preserve #221's validated manifest-join, digest, envelope, hostile-input, file-admission tests and exact historical evidence as reconstruction input. Do not close the PR or discard its unique delta. +2. Resolve the dedicated Release owner lineage through #240 without treating its active branch as protected-main truth. Read intervening changes and reconstruct non-destructively rather than force-rebasing or copying the whole branch. +3. On the resulting `originweave-release` lineage, add the smallest realistic failing Rust test for each migrated canonical invariant and observe the failure before production implementation. Include both compact nested-container and scalar/member fan-out cases that remain well below the byte ceiling, Linux/macOS symlink-swap and parent-replacement cases, plus Windows reparse-point/junction and handle-identity cases for every platform claimed by #201. +4. Implement the smallest Rust-owned admission boundary that depends inward on the core `ReleaseManifest` identity contract. Parse hostile JSON through a bounded or streaming path that enforces byte, container, structure-token/member, graph, numeric, and diagnostic limits before an unbounded generic tree can be materialized. Keep platform-specific secure-open mechanics behind Release-owned adapters; do not introduce a core-to-release dependency, source copy, cross-context SQL, generic JSON authority, path-only portability fallback, or permissive fallback. +5. Reduce `scripts/release/validate_spdx_jsonld.py` to a thin conformance/fixture harness only after Rust owns the release decision boundary and parity regressions prove the behavior. +6. Update ADR 0018, `ARCHITECTURE.md`, `CHANGELOG.md`, doctoring/TRACEABILITY, and the product/technical gap baseline to name the final owner and distinguish protected-main truth from active-PR evidence. +7. Require unchanged exact-head Rust 1.97.1 formatting/check/tests/strict Clippy/rustdoc, 100% owned-production function/line/region/branch coverage, applicable security/review gates, current live-base verification, and supported-platform parity before any readiness transition. + +## Current exact evidence + +The migration contract was introduced test-first on #221 after reviews `5095297316` (Rust-first boundary) and `5095598850` (DDD owner conflict). The predecessor #221 head was `f341ab946beb509f53a9cc8cc52fc20d650faa87`; the test-only owner-migration contract is commit `f3a519e986dc6bda46fb03ac3768ea2bef7a131d`. Review `5096271206` adds the cross-platform secure-open finding after confirming that the provisional Python implementation explicitly rejects platforms without POSIX-style no-follow and descriptor-relative open support. Active #240 was last independently read at `47ab81b721aca246948607754be1807b9d4c8dda` and was not exact-head GREEN. + +The parser-amplification repair was performed test-first in two passes. Test-only `e9c43d9940c03df4ead17f1e2b0b80efa5fcfd0f` exposed nested-container amplification below the byte ceiling; `3e2b4a39b0bb22309f70cf88aaa88a536ec25d1d` added the first string-aware opening-container preflight and `0317263f6e412c2040b4bdfe578781c6fa24a11c` covered escaped-string handling. Follow-up review found that a flat nested scalar array could still create more than one million parsed values with only a handful of containers. Test-only `cbd0d9629d4d5ce42d217c47c54e5f6d33e83255` exposes that scalar fan-out; production `d301f8f79e34f3f09744b0c38c71a54e58ad6283` adds the broader JSON structure-token budget; and `96e8dbd2ad0414481b0ff6ff0e841751147fb780` separates the container-specific and scalar-specific edge cases using the production-exported limits. These are provisional Python hardening commits while the Rust owner migration remains dependency-ordered; they are not evidence that the canonical Rust release boundary exists. + +This document is the causal documentation fix for the missing durable owner-migration, parser-resource, and portability contracts. It does not claim that the Rust migration itself has occurred, that Windows/macOS parity has been implemented, that #240 has shipped, or that #221 is exact-head GREEN. + +## Standards traceability + +Bray, T. (2017). *The JavaScript Object Notation (JSON) Data Interchange Format* (RFC 8259; STD 90). Internet Engineering Task Force. https://www.rfc-editor.org/rfc/rfc8259 + +SPDX Workgroup. (2026). *SPDX specification 3.0.1*. The Linux Foundation. https://spdx.github.io/spdx-spec/v3.0.1/ + +SPDX Workgroup. (2026). *SPDX specification 3.0.1: Model and serializations*. The Linux Foundation. https://spdx.github.io/spdx-spec/v3.0.1/serializations/ + +SPDX Workgroup. (2026). *SpdxDocument*. The Linux Foundation. https://spdx.github.io/spdx-spec/v3.0.1/model/Core/Classes/SpdxDocument/ + +The Open Group. (2024). *open, openat — open file* (POSIX.1-2024). https://pubs.opengroup.org/onlinepubs/9799919799/functions/open.html + +Microsoft. (n.d.). *CreateFileW function (fileapi.h)*. Microsoft Learn. Retrieved September 3, 2026, from https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew + +Microsoft. (2024). *GetFileInformationByHandleEx function (winbase.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-getfileinformationbyhandleex + +Microsoft. (2024). *FILE_ID_INFO structure (winbase.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/winbase/ns-winbase-file_id_info diff --git a/scripts/release/validate_spdx_jsonld.py b/scripts/release/validate_spdx_jsonld.py new file mode 100755 index 000000000..e45129aee --- /dev/null +++ b/scripts/release/validate_spdx_jsonld.py @@ -0,0 +1,464 @@ +#!/usr/bin/env python3 +"""Bounded, fail-closed SPDX 3.0.1 JSON-LD envelope verification. + +This verifier deliberately checks only the serialization envelope needed before deeper +schema/ontology validation: the exact versioned global context identity required by the +official SPDX 3.0.1 JSON Schema, a bounded top-level JSON object, a bounded object-only +``@graph``, and exactly one top-level ``SpdxDocument`` element. A release-facing helper +additionally binds candidate bytes to the canonical SHA-256 identity already declared by the +release manifest. It does not claim full SPDX structural or semantic conformance, artifact +authenticity, SBOM completeness, provenance, signing, publication, installation, update, or +rollback authority. +""" + +from __future__ import annotations + +import argparse +import contextvars +import errno +import hashlib +import hmac +import json +import math +import os +import pathlib +import stat +import sys +from collections.abc import Iterable +from typing import Any + +SPDX_3_0_1_CONTEXT = "https://spdx.org/rdf/3.0.1/spdx-context.jsonld" +MAX_SPDX_JSONLD_BYTES = 16 * 1024 * 1024 +MAX_SPDX_GRAPH_OBJECTS = 65_536 +MAX_SPDX_JSON_CONTAINERS = 262_144 +MAX_SPDX_JSON_STRUCTURE_TOKENS = 1_048_576 +MAX_SPDX_JSON_NESTING_DEPTH = 256 +MAX_SPDX_JSON_NUMBER_TOKEN_BYTES = 4096 +_EXPECTED_PARENT_IDENTITIES: contextvars.ContextVar[tuple[tuple[int, int], ...]] = ( + contextvars.ContextVar("spdx_expected_parent_identities", default=()) +) + + +class SpdxJsonLdEnvelopeError(ValueError): + """Typed, value-redacted failure for an SPDX JSON-LD envelope check.""" + + def __init__(self, code: str) -> None: + self.code = code + super().__init__(f"SPDX JSON-LD envelope validation failed: {code}") + + +class _DuplicateJsonKey(ValueError): + """Internal sentinel used to distinguish duplicate JSON object members.""" + + +def _object_without_duplicates(pairs: Iterable[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise _DuplicateJsonKey + result[key] = value + return result + + +def _reject_nonfinite_constant(_value: str) -> None: + raise ValueError("non-finite JSON number") + + +def _bounded_json_integer(value: str) -> int: + """Parse one JSON integer only within the fixed numeric-token resource budget.""" + + if len(value) > MAX_SPDX_JSON_NUMBER_TOKEN_BYTES: + raise ValueError("JSON number token exceeds resource budget") + return int(value) + + +def _finite_json_float(value: str) -> float: + """Parse one bounded JSON float only when its binary representation remains finite.""" + + if len(value) > MAX_SPDX_JSON_NUMBER_TOKEN_BYTES: + raise ValueError("JSON number token exceeds resource budget") + parsed = float(value) + if not math.isfinite(parsed): + raise ValueError("non-finite JSON number") + return parsed + + +def _reject_excessive_json_structure(text: str) -> None: + """Reject hostile JSON fan-out and depth before the standard parser materializes the tree. + + The byte-size limit alone does not bound heap amplification: a compact JSON string can + encode hundreds of thousands of containers, more than a million scalar/object members, or + enough nested containers to make interpreter recursion the effective resource boundary. + This lexical preflight counts structure only outside JSON strings. Opening containers have + a tighter dedicated aggregate budget, current nesting has an independent depth budget, and + all brackets, braces, commas, and colons share a broader structure-token budget that also + constrains scalar fan-out. Grammar validation still belongs to ``json.loads`` immediately + after this resource check. + """ + + container_count = 0 + current_depth = 0 + structure_token_count = 0 + in_string = False + escaped = False + for character in text: + if in_string: + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == '"': + in_string = False + continue + + if character == '"': + in_string = True + continue + if character in "[{": + container_count += 1 + current_depth += 1 + if current_depth > MAX_SPDX_JSON_NESTING_DEPTH: + raise SpdxJsonLdEnvelopeError("too_deep_json_structure") + if container_count > MAX_SPDX_JSON_CONTAINERS: + raise SpdxJsonLdEnvelopeError("too_many_json_containers") + elif character in "]}": + current_depth -= 1 + if character in "[]{}:,": + structure_token_count += 1 + if structure_token_count > MAX_SPDX_JSON_STRUCTURE_TOKENS: + raise SpdxJsonLdEnvelopeError("too_many_json_structure_tokens") + + +def _has_required_spdx_context(context: Any) -> bool: + """Require the exact SPDX 3.0.1 context identity used by the official JSON Schema. + + The SPDX 3.0.1 prose permits additional namespace mappings, but the same version's + normative structural-validation resource constrains ``@context`` to this exact string. + This preliminary gate therefore fails closed on context arrays rather than interpreting + inline JSON-LD controls or term redefinitions before a reviewed schema-aware validator + can establish their semantics. + """ + + return context == SPDX_3_0_1_CONTEXT + + +def _count_spdx_documents(value: Any) -> int: + """Count SPDX document objects at any depth without recursive interpreter use. + + SPDX 3.0.1 requires Element nodes to live at the top-level ``@graph``. The recursive + count is therefore a defense against a nested ``SpdxDocument`` being accepted in addition + to the one required top-level document. The walk is iterative and remains bounded by the + already size-bounded parsed input. + """ + + count = 0 + pending = [value] + while pending: + current = pending.pop() + if isinstance(current, dict): + if current.get("type") == "SpdxDocument": + count += 1 + if count > 1: + return count + pending.extend(current.values()) + elif isinstance(current, list): + pending.extend(current) + return count + + +def validate_spdx_3_0_1_jsonld_bytes(payload: bytes) -> dict[str, int | str]: + """Validate the bounded SPDX 3.0.1 JSON-LD serialization envelope. + + External document bytes are never included in raised errors or retained by their + exception chain. Successful validation proves only the narrow envelope contract + documented by this module; callers must still perform the official SPDX JSON Schema and + OWL/SHACL validation before claiming SPDX conformance. Excessive JSON nesting is rejected + by the product-owned lexical preflight before the generic parser becomes the resource + boundary. + """ + + if not isinstance(payload, bytes) or not payload or len(payload) > MAX_SPDX_JSONLD_BYTES: + raise SpdxJsonLdEnvelopeError("invalid_size") + + invalid_utf8 = False + try: + text = payload.decode("utf-8", errors="strict") + except UnicodeDecodeError: + invalid_utf8 = True + text = "" + if invalid_utf8: + # UnicodeDecodeError retains the complete input bytes in its ``object`` attribute. + # Raise only after leaving the handler so the public error keeps no payload-bearing + # ``__cause__`` or ``__context__`` reference. + raise SpdxJsonLdEnvelopeError("invalid_utf8") + + _reject_excessive_json_structure(text) + + parse_error_code: str | None = None + try: + decoded = json.loads( + text, + object_pairs_hook=_object_without_duplicates, + parse_constant=_reject_nonfinite_constant, + parse_int=_bounded_json_integer, + parse_float=_finite_json_float, + ) + except _DuplicateJsonKey: + parse_error_code = "duplicate_key" + decoded = None + except (json.JSONDecodeError, RecursionError, ValueError): + parse_error_code = "invalid_json" + decoded = None + if parse_error_code is not None: + # JSONDecodeError retains the complete decoded document in its ``doc`` attribute. + # Do not chain parser exceptions that could therefore turn private SBOM bytes into + # logging, tracing, or support-bundle data downstream. + raise SpdxJsonLdEnvelopeError(parse_error_code) + + if not isinstance(decoded, dict) or set(decoded) != {"@context", "@graph"}: + raise SpdxJsonLdEnvelopeError("invalid_top_level") + if not _has_required_spdx_context(decoded["@context"]): + raise SpdxJsonLdEnvelopeError("invalid_context") + + graph = decoded["@graph"] + if not isinstance(graph, list): + raise SpdxJsonLdEnvelopeError("invalid_graph") + if len(graph) > MAX_SPDX_GRAPH_OBJECTS: + raise SpdxJsonLdEnvelopeError("too_many_graph_objects") + + for element in graph: + if not isinstance(element, dict) or not isinstance(element.get("type"), str): + raise SpdxJsonLdEnvelopeError("invalid_graph_object") + + top_level_document_count = sum( + 1 for element in graph if element.get("type") == "SpdxDocument" + ) + document_count = _count_spdx_documents(graph) + if top_level_document_count != 1 or document_count != 1: + raise SpdxJsonLdEnvelopeError("invalid_document_count") + + return { + "context": SPDX_3_0_1_CONTEXT, + "graph_object_count": len(graph), + "spdx_document_count": document_count, + } + + +def _valid_expected_sha256_digest(value: object) -> bool: + """Return whether a release-artifact digest uses the canonical manifest spelling.""" + + if not isinstance(value, str) or not value.startswith("sha256:") or len(value) != 71: + return False + return all(character in "0123456789abcdef" for character in value[7:]) + + +def validate_release_spdx_3_0_1_jsonld_bytes( + payload: bytes, + expected_sha256_digest: str, +) -> dict[str, int | str]: + """Validate exact release SBOM bytes against their manifest-backed digest and envelope. + + The expected digest is inert identity evidence supplied by the already-admitted release + manifest. Candidate bytes must remain within the same bounded input contract, and a valid + but substituted SPDX document fails before its contents can be promoted as release evidence. + Diagnostics never reflect document-controlled values or the expected digest. Digest equality + does not authenticate the manifest or grant signing, publication, installation, or update + authority. + """ + + if not isinstance(payload, bytes) or not payload or len(payload) > MAX_SPDX_JSONLD_BYTES: + raise SpdxJsonLdEnvelopeError("invalid_size") + if not _valid_expected_sha256_digest(expected_sha256_digest): + raise SpdxJsonLdEnvelopeError("invalid_expected_digest") + + actual_sha256_digest = "sha256:" + hashlib.sha256(payload).hexdigest() + if not hmac.compare_digest(actual_sha256_digest, expected_sha256_digest): + raise SpdxJsonLdEnvelopeError("digest_mismatch") + + summary = validate_spdx_3_0_1_jsonld_bytes(payload) + summary["artifact_sha256"] = actual_sha256_digest + return summary + + +def _path_components(path: pathlib.Path) -> tuple[str, list[str]]: + """Return the direct lexical anchor and components used by the secure open walk.""" + + components = list(path.parts) + if path.is_absolute(): + anchor = path.anchor + components = components[1:] + else: + anchor = "." + if not components or any(component in {"", ".", ".."} for component in components): + raise OSError(errno.ELOOP, "indirect path component is not permitted") + return anchor, components + + +def _direct_parent_identities(path: pathlib.Path) -> tuple[tuple[int, int], ...]: + """Capture the exact directory identities that own one direct candidate path.""" + + anchor, components = _path_components(path) + current = pathlib.Path(anchor) + identities: list[tuple[int, int]] = [] + for component in [None, *components[:-1]]: + if component is not None: + current = current / component + parent_stat = current.lstat() + if not stat.S_ISDIR(parent_stat.st_mode): + raise SpdxJsonLdEnvelopeError("invalid_file_type") + identities.append((parent_stat.st_dev, parent_stat.st_ino)) + return tuple(identities) + + +def _require_expected_directory_identity( + descriptor: int, + expected_identity: tuple[int, int], +) -> None: + """Fail the descriptor walk when an admitted directory identity has changed.""" + + opened_stat = os.fstat(descriptor) + if not stat.S_ISDIR(opened_stat.st_mode) or ( + opened_stat.st_dev, + opened_stat.st_ino, + ) != expected_identity: + raise OSError(errno.ELOOP, "direct path parent identity changed") + + +def _nonblocking_read_opener(path: str, flags: int) -> int: + """Open one candidate through an identity-bound descriptor-relative component walk. + + Opening every ancestor relative to an already-open directory descriptor prevents a + pathname swap from redirecting a later component through a transient symlink. Every + opened directory must also retain the identity captured by the caller before leaf + admission, so replacing a parent with a different real directory cannot preserve trust + merely by hard-linking the same leaf inode into the replacement. Platforms that cannot + provide both ``O_NOFOLLOW`` and descriptor-relative ``os.open`` fail closed instead of + silently weakening this direct-path release boundary. + """ + + nofollow = getattr(os, "O_NOFOLLOW", 0) + directory = getattr(os, "O_DIRECTORY", 0) + cloexec = getattr(os, "O_CLOEXEC", 0) + if not nofollow or not directory or os.open not in os.supports_dir_fd: + raise OSError(errno.ENOTSUP, "secure descriptor-relative open is unavailable") + + candidate = pathlib.Path(path) + anchor, components = _path_components(candidate) + expected_parent_identities = _EXPECTED_PARENT_IDENTITIES.get() + if len(expected_parent_identities) != len(components): + raise OSError(errno.ELOOP, "direct path parent identity is unavailable") + + directory_flags = os.O_RDONLY | directory | nofollow | cloexec + parent_fd = os.open(anchor, directory_flags) + try: + _require_expected_directory_identity(parent_fd, expected_parent_identities[0]) + for index, component in enumerate(components[:-1], start=1): + next_fd = os.open(component, directory_flags, dir_fd=parent_fd) + os.close(parent_fd) + parent_fd = next_fd + _require_expected_directory_identity(parent_fd, expected_parent_identities[index]) + return os.open( + components[-1], + flags | getattr(os, "O_NONBLOCK", 0) | nofollow | cloexec, + dir_fd=parent_fd, + ) + finally: + os.close(parent_fd) + + +def _read_bounded(path: pathlib.Path) -> bytes: + """Read one bounded direct regular-file candidate with stable path-owner identities.""" + + read_error_code: str | None = None + redacted_error: OSError | None = None + try: + expected_parent_identities = _direct_parent_identities(path) + candidate_stat = path.lstat() + if not stat.S_ISREG(candidate_stat.st_mode): + raise SpdxJsonLdEnvelopeError("invalid_file_type") + identity_token = _EXPECTED_PARENT_IDENTITIES.set(expected_parent_identities) + try: + with open(path, "rb", opener=_nonblocking_read_opener) as source: + opened_stat = os.fstat(source.fileno()) + if not stat.S_ISREG(opened_stat.st_mode): + raise SpdxJsonLdEnvelopeError("invalid_file_type") + if (candidate_stat.st_dev, candidate_stat.st_ino) != ( + opened_stat.st_dev, + opened_stat.st_ino, + ): + raise SpdxJsonLdEnvelopeError("invalid_file_type") + if _direct_parent_identities(path) != expected_parent_identities: + raise SpdxJsonLdEnvelopeError("invalid_file_type") + payload = source.read(MAX_SPDX_JSONLD_BYTES + 1) + if _direct_parent_identities(path) != expected_parent_identities: + raise SpdxJsonLdEnvelopeError("invalid_file_type") + final_stat = path.lstat() + if not stat.S_ISREG(final_stat.st_mode) or ( + final_stat.st_dev, + final_stat.st_ino, + ) != (opened_stat.st_dev, opened_stat.st_ino): + raise SpdxJsonLdEnvelopeError("invalid_file_type") + if _direct_parent_identities(path) != expected_parent_identities: + raise SpdxJsonLdEnvelopeError("invalid_file_type") + finally: + _EXPECTED_PARENT_IDENTITIES.reset(identity_token) + except SpdxJsonLdEnvelopeError: + raise + except OSError as error: + redacted_error = OSError(error.errno, "release SBOM file operation failed") + read_error_code = ( + "invalid_file_type" + if error.errno in {errno.ELOOP, errno.ENOTDIR} + else "read_failed" + ) + except ValueError: + redacted_error = OSError(errno.EINVAL, "release SBOM file operation failed") + read_error_code = "read_failed" + if read_error_code is not None: + # Raise only after leaving the original handler. Otherwise Python retains the + # path-bearing source error as implicit ``__context__`` even when an explicit + # redacted ``__cause__`` is supplied. + raise SpdxJsonLdEnvelopeError(read_error_code) from redacted_error + if not payload or len(payload) > MAX_SPDX_JSONLD_BYTES: + raise SpdxJsonLdEnvelopeError("invalid_size") + return payload + + +def main(argv: list[str] | None = None) -> int: + """Validate one local SPDX JSON-LD artifact without echoing untrusted values.""" + + parser = argparse.ArgumentParser( + description=( + "Validate a bounded SPDX 3.0.1 JSON-LD serialization envelope, optionally " + "binding it to an exact manifest-backed SHA-256 identity." + ) + ) + parser.add_argument("document", type=pathlib.Path) + parser.add_argument( + "--expected-sha256", + help=( + "require the exact manifest-backed sha256: artifact identity before promoting " + "release evidence" + ), + ) + arguments = parser.parse_args(argv) + + try: + payload = _read_bounded(arguments.document) + if arguments.expected_sha256 is None: + summary = validate_spdx_3_0_1_jsonld_bytes(payload) + else: + summary = validate_release_spdx_3_0_1_jsonld_bytes( + payload, + arguments.expected_sha256, + ) + except SpdxJsonLdEnvelopeError as error: + print(error, file=sys.stderr) + return 2 + + print(json.dumps(summary, sort_keys=True, separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_release_sbom_owner_migration_contract.py b/tests/test_release_sbom_owner_migration_contract.py new file mode 100644 index 000000000..989add737 --- /dev/null +++ b/tests/test_release_sbom_owner_migration_contract.py @@ -0,0 +1,47 @@ +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +MIGRATION = ROOT / "docs/traceability/release-sbom-owner-migration.md" + + +class ReleaseSbomOwnerMigrationContractTests(unittest.TestCase): + """Keep the pre-GA release/SBOM owner repair explicit and non-duplicative.""" + + def test_release_sbom_migration_keeps_one_canonical_owner(self): + """The repair plan must converge SBOM policy and verification on one Release owner.""" + self.assertTrue(MIGRATION.is_file()) + text = MIGRATION.read_text(encoding="utf-8") + required = ( + "`originweave-release`", + "`ReleaseManifest`", + "`originweave-core`", + "`scripts/release/validate_spdx_jsonld.py`", + "thin conformance/fixture harness", + "Rust-owned", + "no compatibility shim", + "no duplicated release API", + "#221", + "#240", + ) + for token in required: + self.assertIn(token, text) + self.assertIn("Proposed", text) + self.assertIn("not shipped truth", text) + + def test_windows_secure_open_contract_names_non_reparse_and_identity_primitives(self): + """Windows parity must name the concrete non-reparse and handle-identity primitives.""" + text = MIGRATION.read_text(encoding="utf-8") + required = ( + "FILE_FLAG_OPEN_REPARSE_POINT", + "GetFileInformationByHandleEx", + "FILE_ID_INFO", + "VolumeSerialNumber", + "FileId", + ) + for token in required: + self.assertIn(token, text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_spdx_cli_digest_contract.py b/tests/test_release_spdx_cli_digest_contract.py new file mode 100644 index 000000000..bd006fe6b --- /dev/null +++ b/tests/test_release_spdx_cli_digest_contract.py @@ -0,0 +1,79 @@ +"""Regression contract for manifest-backed SPDX validation through the release CLI.""" + +from __future__ import annotations + +import contextlib +import hashlib +import io +import json +import pathlib +import runpy +import tempfile +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +VALIDATOR = ROOT / "scripts" / "release" / "validate_spdx_jsonld.py" +CONTEXT = "https://spdx.org/rdf/3.0.1/spdx-context.jsonld" + + +class ReleaseSpdxCliDigestContractTests(unittest.TestCase): + """Require release CLI callers to opt into exact manifest-backed digest evidence.""" + + @classmethod + def setUpClass(cls) -> None: + namespace = runpy.run_path(str(VALIDATOR), run_name="spdx_cli_digest_contract") + cls.main = staticmethod(namespace["main"]) + + @staticmethod + def _payload() -> bytes: + return json.dumps( + { + "@context": CONTEXT, + "@graph": [{"type": "SpdxDocument", "spdxId": "release-cli"}], + }, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + + @staticmethod + def _digest(payload: bytes) -> str: + return "sha256:" + hashlib.sha256(payload).hexdigest() + + def test_cli_can_bind_direct_file_to_manifest_backed_digest(self) -> None: + payload = self._payload() + expected_digest = self._digest(payload) + + with tempfile.TemporaryDirectory(prefix="originweave-spdx-cli-") as directory: + document = pathlib.Path(directory) / "release.spdx.jsonld" + document.write_bytes(payload) + output = io.StringIO() + with contextlib.redirect_stdout(output): + status = self.main( + [str(document), "--expected-sha256", expected_digest] + ) + + self.assertEqual(status, 0) + summary = json.loads(output.getvalue()) + self.assertEqual(summary["artifact_sha256"], expected_digest) + self.assertEqual(summary["spdx_document_count"], 1) + + def test_cli_digest_mismatch_fails_closed_without_reflecting_identity_or_path(self) -> None: + payload = self._payload() + wrong_digest = "sha256:" + "0" * 64 + + with tempfile.TemporaryDirectory(prefix="originweave-spdx-cli-secret-") as directory: + document = pathlib.Path(directory) / "customer-secret-release.spdx.jsonld" + document.write_bytes(payload) + error_output = io.StringIO() + with contextlib.redirect_stderr(error_output): + status = self.main([str(document), "--expected-sha256", wrong_digest]) + + diagnostic = error_output.getvalue() + self.assertEqual(status, 2) + self.assertIn("digest_mismatch", diagnostic) + self.assertNotIn(wrong_digest, diagnostic) + self.assertNotIn("customer-secret-release.spdx.jsonld", diagnostic) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_spdx_digest_binding_contract.py b/tests/test_release_spdx_digest_binding_contract.py new file mode 100644 index 000000000..da0723932 --- /dev/null +++ b/tests/test_release_spdx_digest_binding_contract.py @@ -0,0 +1,77 @@ +"""Regression contract binding validated SPDX bytes to the declared release artifact digest.""" + +from __future__ import annotations + +import hashlib +import json +import pathlib +import runpy +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +VALIDATOR = ROOT / "scripts" / "release" / "validate_spdx_jsonld.py" +CONTEXT = "https://spdx.org/rdf/3.0.1/spdx-context.jsonld" + + +class ReleaseSpdxDigestBindingContractTests(unittest.TestCase): + """Prevent a valid but substituted SPDX document from satisfying release evidence.""" + + @classmethod + def setUpClass(cls) -> None: + namespace = runpy.run_path(str(VALIDATOR), run_name="spdx_digest_binding_contract") + cls.validate_bound = staticmethod(namespace["validate_release_spdx_3_0_1_jsonld_bytes"]) + cls.error_type = namespace["SpdxJsonLdEnvelopeError"] + + @staticmethod + def _payload(marker: str = "release-a") -> bytes: + return json.dumps( + { + "@context": CONTEXT, + "@graph": [{"type": "SpdxDocument", "spdxId": marker}], + }, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + + @staticmethod + def _digest(payload: bytes) -> str: + return "sha256:" + hashlib.sha256(payload).hexdigest() + + def test_exact_candidate_bytes_match_the_manifest_backed_sbom_digest(self) -> None: + payload = self._payload() + expected_digest = self._digest(payload) + + summary = self.validate_bound(payload, expected_digest) + + self.assertEqual(summary["artifact_sha256"], expected_digest) + self.assertEqual(summary["spdx_document_count"], 1) + + def test_valid_but_substituted_document_fails_closed_on_digest_mismatch(self) -> None: + declared = self._payload("declared-release") + substituted = self._payload("substituted-release-secret-marker") + + with self.assertRaises(self.error_type) as captured: + self.validate_bound(substituted, self._digest(declared)) + + self.assertEqual(captured.exception.code, "digest_mismatch") + self.assertNotIn("substituted-release-secret-marker", str(captured.exception)) + + def test_expected_digest_must_use_canonical_lowercase_sha256_identity(self) -> None: + payload = self._payload() + valid = self._digest(payload) + invalid_digests = ( + valid.removeprefix("sha256:"), + "SHA256:" + valid.removeprefix("sha256:"), + "sha256:" + valid.removeprefix("sha256:").upper(), + "sha256:" + "0" * 63, + ) + + for digest in invalid_digests: + with self.subTest(digest=digest): + with self.assertRaises(self.error_type) as captured: + self.validate_bound(payload, digest) + self.assertEqual(captured.exception.code, "invalid_expected_digest") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_spdx_error_redaction_contract.py b/tests/test_release_spdx_error_redaction_contract.py new file mode 100644 index 000000000..5ca92f31d --- /dev/null +++ b/tests/test_release_spdx_error_redaction_contract.py @@ -0,0 +1,85 @@ +"""Regression tests for payload-safe SPDX parser failure diagnostics.""" + +from __future__ import annotations + +import errno +import pathlib +import runpy +import tempfile +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +VALIDATOR = ROOT / "scripts" / "release" / "validate_spdx_jsonld.py" +CONTEXT = "https://spdx.org/rdf/3.0.1/spdx-context.jsonld" + + +class ReleaseSpdxErrorRedactionContractTests(unittest.TestCase): + """Ensure raised validation errors retain no external document payload object.""" + + @classmethod + def setUpClass(cls) -> None: + cls.namespace = runpy.run_path(str(VALIDATOR), run_name="release_spdx_error_redaction") + cls.validate = staticmethod(cls.namespace["validate_spdx_3_0_1_jsonld_bytes"]) + cls.read_bounded = staticmethod(cls.namespace["_read_bounded"]) + cls.error_type = cls.namespace["SpdxJsonLdEnvelopeError"] + + def _assert_payload_free_error(self, payload: bytes, expected_code: str) -> None: + with self.assertRaises(self.error_type) as captured: + self.validate(payload) + error = captured.exception + self.assertEqual(error.code, expected_code) + self.assertIsNone(error.__cause__) + self.assertIsNone(error.__context__) + + def test_invalid_utf8_does_not_survive_in_exception_chain(self) -> None: + marker = b"buyer-secret-marker-must-not-survive-validation" + self._assert_payload_free_error(marker + b"\xff", "invalid_utf8") + + def test_malformed_json_does_not_survive_in_exception_chain(self) -> None: + marker = "buyer-secret-marker-must-not-survive-validation" + payload = ( + '{"@context":"' + + CONTEXT + + '","@graph":[{"type":"SpdxDocument","private":"' + + marker + + '",}]}' + ).encode("utf-8") + self._assert_payload_free_error(payload, "invalid_json") + + def test_failed_file_path_does_not_survive_in_exception_source(self) -> None: + marker = "buyer-secret-path-marker-must-not-survive-validation" + with tempfile.TemporaryDirectory() as temporary_directory: + missing_path = pathlib.Path(temporary_directory) / marker + with self.assertRaises(self.error_type) as captured: + self.read_bounded(missing_path) + + error = captured.exception + self.assertEqual(error.code, "read_failed") + self.assertIsInstance(error.__cause__, OSError) + self.assertEqual(error.__cause__.errno, errno.ENOENT) + self.assertIsNone(error.__cause__.filename) + self.assertNotIn(marker, str(error.__cause__)) + self.assertNotIn(marker, repr(error.__cause__)) + self.assertIsNone(error.__cause__.__context__) + self.assertIsNone(error.__context__) + + def test_embedded_nul_path_fails_through_redacted_typed_boundary(self) -> None: + marker = "buyer-secret-nul-path-marker-must-not-survive-validation" + malformed_path = pathlib.Path("\0" + marker) + + with self.assertRaises(self.error_type) as captured: + self.read_bounded(malformed_path) + + error = captured.exception + self.assertEqual(error.code, "read_failed") + self.assertIsInstance(error.__cause__, OSError) + self.assertEqual(error.__cause__.errno, errno.EINVAL) + self.assertIsNone(error.__cause__.filename) + self.assertNotIn(marker, str(error.__cause__)) + self.assertNotIn(marker, repr(error.__cause__)) + self.assertIsNone(error.__cause__.__context__) + self.assertIsNone(error.__context__) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_spdx_jsonld_envelope_contract.py b/tests/test_release_spdx_jsonld_envelope_contract.py new file mode 100644 index 000000000..fe9d0f2a5 --- /dev/null +++ b/tests/test_release_spdx_jsonld_envelope_contract.py @@ -0,0 +1,287 @@ +"""Fail-closed contract tests for bounded SPDX 3.0.1 JSON-LD envelope verification.""" + +from __future__ import annotations + +import json +import pathlib +import runpy +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +VALIDATOR = ROOT / "scripts" / "release" / "validate_spdx_jsonld.py" +CONTEXT = "https://spdx.org/rdf/3.0.1/spdx-context.jsonld" + + +class ReleaseSpdxJsonLdEnvelopeContractTests(unittest.TestCase): + """Verify exact serialization identity without promoting partial checks to SPDX conformance.""" + + @classmethod + def setUpClass(cls) -> None: + cls.namespace = runpy.run_path(str(VALIDATOR), run_name="release_spdx_envelope_contract") + cls.validate = staticmethod(cls.namespace["validate_spdx_3_0_1_jsonld_bytes"]) + cls.error_type = cls.namespace["SpdxJsonLdEnvelopeError"] + cls.max_bytes = cls.namespace["MAX_SPDX_JSONLD_BYTES"] + cls.max_graph_objects = cls.namespace["MAX_SPDX_GRAPH_OBJECTS"] + cls.max_containers = cls.namespace["MAX_SPDX_JSON_CONTAINERS"] + cls.max_structure_tokens = cls.namespace["MAX_SPDX_JSON_STRUCTURE_TOKENS"] + + @staticmethod + def _payload(graph: list[object], *, context: object = CONTEXT) -> bytes: + return json.dumps( + {"@context": context, "@graph": graph}, + separators=(",", ":"), + ensure_ascii=True, + ).encode("utf-8") + + def _assert_error_code(self, payload: bytes, expected_code: str) -> Exception: + with self.assertRaises(self.error_type) as captured: + self.validate(payload) + self.assertEqual(captured.exception.code, expected_code) + return captured.exception + + def test_exact_context_and_single_document_are_admitted(self) -> None: + summary = self.validate( + self._payload( + [ + {"type": "CreationInfo", "@id": "_:creation", "specVersion": "3.0.1"}, + { + "type": "SpdxDocument", + "spdxId": "https://example.invalid/spdx/document", + "creationInfo": "_:creation", + }, + ] + ) + ) + + self.assertEqual(summary["context"], CONTEXT) + self.assertEqual(summary["graph_object_count"], 2) + self.assertEqual(summary["spdx_document_count"], 1) + + def test_context_array_is_rejected_until_schema_aware_validation(self) -> None: + self._assert_error_code( + self._payload( + [{"type": "SpdxDocument"}], + context=[CONTEXT, {"buyer": "https://example.invalid/spdx/buyer#"}], + ), + "invalid_context", + ) + + def test_inline_context_cannot_import_or_rebind_spdx_semantics(self) -> None: + hostile_contexts = [ + [CONTEXT, {"@import": "https://example.invalid/moving-context.jsonld"}], + [CONTEXT, {"@base": "https://example.invalid/base/"}], + [CONTEXT, {"@vocab": "https://example.invalid/vocab#"}], + [CONTEXT, {"type": "https://example.invalid/NotSpdxDocument"}], + [CONTEXT, {"spdxId": "https://example.invalid/not-id"}], + ] + + for context in hostile_contexts: + with self.subTest(context=context): + self._assert_error_code( + self._payload([{"type": "SpdxDocument"}], context=context), + "invalid_context", + ) + + def test_context_extensions_cannot_add_ambient_remote_authority(self) -> None: + self._assert_error_code( + self._payload( + [{"type": "SpdxDocument"}], + context=[CONTEXT, "https://example.invalid/moving-context.jsonld"], + ), + "invalid_context", + ) + self._assert_error_code( + self._payload([{"type": "SpdxDocument"}], context=[CONTEXT, None]), + "invalid_context", + ) + + def test_context_must_match_exact_spdx_3_0_1_identity(self) -> None: + self._assert_error_code( + self._payload( + [{"type": "SpdxDocument"}], + context="https://spdx.org/rdf/3.1/spdx-context.jsonld", + ), + "invalid_context", + ) + + def test_top_level_object_must_contain_only_context_and_graph(self) -> None: + payload = json.dumps( + {"@context": CONTEXT, "@graph": [{"type": "SpdxDocument"}], "extra": True}, + separators=(",", ":"), + ).encode("utf-8") + self._assert_error_code(payload, "invalid_top_level") + + def test_duplicate_json_keys_fail_closed(self) -> None: + payload = ( + '{"@context":"' + + CONTEXT + + '","@context":"' + + CONTEXT + + '","@graph":[{"type":"SpdxDocument"}]}' + ).encode("utf-8") + self._assert_error_code(payload, "duplicate_key") + + def test_graph_must_be_bounded_object_array(self) -> None: + self._assert_error_code( + self._payload([{"type": "SpdxDocument"}, "not-an-object"]), + "invalid_graph_object", + ) + oversized_graph = [{"type": "Package"}] * (self.max_graph_objects + 1) + self._assert_error_code(self._payload(oversized_graph), "too_many_graph_objects") + + def test_shallow_container_explosion_fails_before_json_materialization(self) -> None: + shallow_container_count = self.max_containers + 1 + payload = ( + '{"@context":"' + + CONTEXT + + '","@graph":[{"type":"SpdxDocument","nested":' + + "[" + + "[]," * shallow_container_count + + "[]]" + + "}]}" + ).encode("utf-8") + + self.assertLess(len(payload), self.max_bytes) + self._assert_error_code(payload, "too_many_json_containers") + + def test_scalar_fan_out_fails_before_json_materialization(self) -> None: + scalar_count = self.max_structure_tokens + 1 + payload = ( + '{"@context":"' + + CONTEXT + + '","@graph":[{"type":"SpdxDocument","nested":[' + + "0," * scalar_count + + "0]}]}" + ).encode("utf-8") + + self.assertLess(len(payload), self.max_bytes) + self._assert_error_code(payload, "too_many_json_structure_tokens") + + def test_structure_preflight_ignores_structural_tokens_inside_strings(self) -> None: + marker = '[{"nested":[]}]\\buyer"quote' + summary = self.validate(self._payload([{"type": "SpdxDocument", "note": marker}])) + + self.assertEqual(summary["graph_object_count"], 1) + self.assertEqual(summary["spdx_document_count"], 1) + + def test_exactly_one_spdx_document_is_required(self) -> None: + self._assert_error_code(self._payload([{"type": "Package"}]), "invalid_document_count") + self._assert_error_code( + self._payload( + [ + {"type": "SpdxDocument"}, + {"type": "SpdxDocument"}, + ] + ), + "invalid_document_count", + ) + + def test_spdx_document_must_be_a_top_level_graph_element(self) -> None: + self._assert_error_code( + self._payload( + [ + { + "type": "Bundle", + "element": [ + { + "type": "SpdxDocument", + "spdxId": "https://example.invalid/spdx/nested-document", + } + ], + } + ] + ), + "invalid_document_count", + ) + + def test_nested_second_spdx_document_fails_closed(self) -> None: + self._assert_error_code( + self._payload( + [ + {"type": "CreationInfo", "@id": "_:creation", "specVersion": "3.0.1"}, + { + "type": "SpdxDocument", + "spdxId": "https://example.invalid/spdx/document", + "creationInfo": "_:creation", + "element": [ + { + "type": "SpdxDocument", + "spdxId": "https://example.invalid/spdx/nested-document", + "creationInfo": "_:creation", + } + ], + }, + ] + ), + "invalid_document_count", + ) + + def test_invalid_utf8_and_nonfinite_json_fail_closed(self) -> None: + self._assert_error_code(b"\xff", "invalid_utf8") + payload = ( + '{"@context":"' + + CONTEXT + + '","@graph":[{"type":"SpdxDocument","score":NaN}]}' + ).encode("utf-8") + self._assert_error_code(payload, "invalid_json") + + overflow_payload = ( + '{"@context":"' + + CONTEXT + + '","@graph":[{"type":"SpdxDocument","score":1e400}]}' + ).encode("utf-8") + self._assert_error_code(overflow_payload, "invalid_json") + + def test_oversized_numeric_tokens_fail_closed_before_conversion(self) -> None: + oversized_integer = "9" * 4097 + oversized_float = "1." + ("0" * 4095) + "1" + for numeric_token in (oversized_integer, oversized_float): + with self.subTest(token_kind="float" if "." in numeric_token else "integer"): + payload = ( + '{"@context":"' + + CONTEXT + + '","@graph":[{"type":"SpdxDocument","score":' + + numeric_token + + "}]}" + ).encode("utf-8") + self._assert_error_code(payload, "invalid_json") + + def test_excessive_json_nesting_fails_with_typed_redacted_error(self) -> None: + marker = "buyer-secret-marker-must-not-reach-release-diagnostics" + depth = 10_000 + payload = ( + '{"@context":"' + + CONTEXT + + '","@graph":[{"type":"SpdxDocument","nested":' + + "[" * depth + + '"' + + marker + + '"' + + "]" * depth + + "}]}" + ).encode("utf-8") + + error = self._assert_error_code(payload, "too_deep_json_structure") + self.assertNotIn(marker, str(error)) + + def test_empty_and_oversized_payloads_fail_before_semantic_use(self) -> None: + self._assert_error_code(b"", "invalid_size") + self._assert_error_code(b" " * (self.max_bytes + 1), "invalid_size") + + def test_external_bytes_are_never_reflected_in_errors(self) -> None: + marker = "buyer-secret-marker-must-not-reach-release-diagnostics" + payload = self._payload( + [ + { + "type": "SpdxDocument", + "unexpected": marker, + } + ], + context=marker, + ) + error = self._assert_error_code(payload, "invalid_context") + self.assertNotIn(marker, str(error)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_spdx_jsonld_file_type_contract.py b/tests/test_release_spdx_jsonld_file_type_contract.py new file mode 100644 index 000000000..bf0db5d75 --- /dev/null +++ b/tests/test_release_spdx_jsonld_file_type_contract.py @@ -0,0 +1,137 @@ +"""Regression contract for bounded SPDX JSON-LD release input file types.""" + +from __future__ import annotations + +import os +import pathlib +import runpy +import tempfile +import threading +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +VALIDATOR = ROOT / "scripts" / "release" / "validate_spdx_jsonld.py" + + +class ReleaseSpdxJsonLdFileTypeContractTests(unittest.TestCase): + """Prevent streaming/indirect files from bypassing the bounded release-input contract.""" + + def test_fifo_candidate_is_rejected_before_document_bytes_are_accepted(self) -> None: + """A named pipe is not an immutable bounded release artifact input.""" + + if not hasattr(os, "mkfifo"): + self.fail("the release verifier file-type regression requires POSIX mkfifo support") + + namespace = runpy.run_path(str(VALIDATOR), run_name="spdx_file_type_contract") + read_bounded = namespace["_read_bounded"] + envelope_error = namespace["SpdxJsonLdEnvelopeError"] + + with tempfile.TemporaryDirectory(prefix="originweave-spdx-fifo-") as directory: + candidate = pathlib.Path(directory) / "candidate.spdx.jsonld" + os.mkfifo(candidate) + + writer_started = threading.Event() + + def write_candidate() -> None: + writer_started.set() + with candidate.open("wb") as sink: + sink.write(b"{}") + + writer = threading.Thread(target=write_candidate, daemon=True) + writer.start() + self.assertTrue(writer_started.wait(timeout=1.0)) + + try: + with self.assertRaises(envelope_error) as captured: + read_bounded(candidate) + finally: + if writer.is_alive(): + with candidate.open("rb", buffering=0) as release_reader: + release_reader.read(2) + writer.join(timeout=1.0) + + self.assertFalse(writer.is_alive(), "FIFO writer remained blocked after rejection") + self.assertEqual(captured.exception.code, "invalid_file_type") + + def test_symlink_candidate_is_rejected_instead_of_following_indirect_release_input(self) -> None: + """A symlink is not the regular local artifact path whose bytes are being admitted.""" + + namespace = runpy.run_path(str(VALIDATOR), run_name="spdx_symlink_contract") + read_bounded = namespace["_read_bounded"] + envelope_error = namespace["SpdxJsonLdEnvelopeError"] + + with tempfile.TemporaryDirectory(prefix="originweave-spdx-symlink-") as directory: + root = pathlib.Path(directory) + target = root / "actual.spdx.jsonld" + target.write_bytes(b"{}") + candidate = root / "candidate.spdx.jsonld" + candidate.symlink_to(target.name) + + with self.assertRaises(envelope_error) as captured: + read_bounded(candidate) + + self.assertEqual(captured.exception.code, "invalid_file_type") + + def test_candidate_path_replaced_after_open_is_rejected(self) -> None: + """The admitted descriptor must still own the candidate path after the bounded read.""" + + namespace = runpy.run_path(str(VALIDATOR), run_name="spdx_leaf_swap_contract") + read_bounded = namespace["_read_bounded"] + envelope_error = namespace["SpdxJsonLdEnvelopeError"] + original_opener = namespace["_nonblocking_read_opener"] + + with tempfile.TemporaryDirectory(prefix="originweave-spdx-leaf-swap-") as directory: + root = pathlib.Path(directory) + candidate = root / "candidate.spdx.jsonld" + displaced = root / "displaced.spdx.jsonld" + candidate.write_bytes(b'{"@context":"https://spdx.org/rdf/3.0.1/spdx-context.jsonld","@graph":[{"type":"SpdxDocument"}]}') + + def swap_after_open(path: str, flags: int) -> int: + descriptor = original_opener(path, flags) + candidate.rename(displaced) + candidate.write_bytes(b"{}") + return descriptor + + read_bounded.__globals__["_nonblocking_read_opener"] = swap_after_open + + with self.assertRaises(envelope_error) as captured: + read_bounded(candidate) + + self.assertEqual(captured.exception.code, "invalid_file_type") + self.assertEqual(candidate.read_bytes(), b"{}") + self.assertTrue(displaced.exists()) + + def test_parent_directory_replaced_with_same_leaf_inode_is_rejected(self) -> None: + """A release path must retain its admitted parent-directory identity through opening.""" + + namespace = runpy.run_path(str(VALIDATOR), run_name="spdx_parent_identity_contract") + read_bounded = namespace["_read_bounded"] + envelope_error = namespace["SpdxJsonLdEnvelopeError"] + original_opener = namespace["_nonblocking_read_opener"] + + with tempfile.TemporaryDirectory(prefix="originweave-spdx-parent-identity-") as directory: + root = pathlib.Path(directory) + parent = root / "candidate-parent" + displaced_parent = root / "displaced-parent" + parent.mkdir() + candidate = parent / "candidate.spdx.jsonld" + candidate.write_bytes(b'{"@context":"https://spdx.org/rdf/3.0.1/spdx-context.jsonld","@graph":[{"type":"SpdxDocument"}]}') + original_inode = candidate.stat().st_ino + + def replace_parent_before_open(path: str, flags: int) -> int: + parent.rename(displaced_parent) + parent.mkdir() + os.link(displaced_parent / candidate.name, parent / candidate.name) + self.assertEqual((parent / candidate.name).stat().st_ino, original_inode) + return original_opener(path, flags) + + read_bounded.__globals__["_nonblocking_read_opener"] = replace_parent_before_open + + with self.assertRaises(envelope_error) as captured: + read_bounded(candidate) + + self.assertEqual(captured.exception.code, "invalid_file_type") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_spdx_jsonld_nesting_depth_contract.py b/tests/test_release_spdx_jsonld_nesting_depth_contract.py new file mode 100644 index 000000000..97a8f37bd --- /dev/null +++ b/tests/test_release_spdx_jsonld_nesting_depth_contract.py @@ -0,0 +1,57 @@ +"""Regression contract for bounded JSON nesting before SPDX tree materialization.""" + +from __future__ import annotations + +import pathlib +import runpy +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +VALIDATOR = ROOT / "scripts" / "release" / "validate_spdx_jsonld.py" + + +class ReleaseSpdxJsonLdNestingDepthContractTests(unittest.TestCase): + """Require a product-owned nesting budget before the generic JSON parser.""" + + @staticmethod + def _namespace(run_name: str) -> dict[str, object]: + """Load the exact validator module without importing repository-global state.""" + + return runpy.run_path(str(VALIDATOR), run_name=run_name) + + def test_nesting_at_product_budget_is_admitted_by_preflight(self) -> None: + """The exact product depth ceiling must remain usable rather than off by one.""" + + namespace = self._namespace("spdx_nesting_depth_boundary_contract") + max_depth = namespace["MAX_SPDX_JSON_NESTING_DEPTH"] + preflight = namespace["_reject_excessive_json_structure"] + + text = "[" * max_depth + "0" + "]" * max_depth + preflight(text) + + def test_nesting_beyond_product_budget_fails_in_preflight(self) -> None: + """Deep valid JSON must fail before parser recursion becomes the resource boundary.""" + + namespace = self._namespace("spdx_nesting_depth_contract") + max_depth = namespace["MAX_SPDX_JSON_NESTING_DEPTH"] + preflight = namespace["_reject_excessive_json_structure"] + envelope_error = namespace["SpdxJsonLdEnvelopeError"] + + text = "[" * (max_depth + 1) + "0" + "]" * (max_depth + 1) + with self.assertRaises(envelope_error) as captured: + preflight(text) + + self.assertEqual(captured.exception.code, "too_deep_json_structure") + + def test_structural_characters_inside_strings_do_not_consume_depth_budget(self) -> None: + """Bracket-looking string content must remain inert for the nesting budget.""" + + namespace = self._namespace("spdx_nesting_string_contract") + max_depth = namespace["MAX_SPDX_JSON_NESTING_DEPTH"] + preflight = namespace["_reject_excessive_json_structure"] + + preflight('"' + ("[" * (max_depth + 1)) + '"') + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_spdx_jsonld_parent_swap_contract.py b/tests/test_release_spdx_jsonld_parent_swap_contract.py new file mode 100644 index 000000000..000f711f8 --- /dev/null +++ b/tests/test_release_spdx_jsonld_parent_swap_contract.py @@ -0,0 +1,113 @@ +"""Regression contract for ancestor-swap races in SPDX release input paths.""" + +from __future__ import annotations + +import os +import pathlib +import runpy +import tempfile +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +VALIDATOR = ROOT / "scripts" / "release" / "validate_spdx_jsonld.py" + + +class ReleaseSpdxJsonLdParentSwapContractTests(unittest.TestCase): + """Require path traversal to remain direct for the actual open operation.""" + + @unittest.skipUnless(hasattr(os, "symlink") and hasattr(os, "link"), "requires links") + def test_parent_swap_to_symlink_during_open_fails_closed(self) -> None: + """Pre/post pathname checks must not miss a transient ancestor symlink.""" + + namespace = runpy.run_path(str(VALIDATOR), run_name="spdx_parent_swap_contract") + read_bounded = namespace["_read_bounded"] + envelope_error = namespace["SpdxJsonLdEnvelopeError"] + original_opener = namespace["_nonblocking_read_opener"] + + with tempfile.TemporaryDirectory(prefix="originweave-spdx-parent-swap-") as directory: + root = pathlib.Path(directory) + direct_directory = root / "direct" + direct_directory.mkdir() + actual_directory = root / "actual" + actual_directory.mkdir() + + actual_candidate = actual_directory / "candidate.spdx.jsonld" + actual_candidate.write_bytes(b"{\"indirect\":true}") + direct_candidate = direct_directory / "candidate.spdx.jsonld" + os.link(actual_candidate, direct_candidate) + parked_directory = root / "direct-parked" + + def swapping_opener(path: str, flags: int) -> int: + direct_directory.rename(parked_directory) + direct_directory.symlink_to(actual_directory.name, target_is_directory=True) + try: + return original_opener(path, flags) + finally: + direct_directory.unlink() + parked_directory.rename(direct_directory) + + read_bounded.__globals__["_nonblocking_read_opener"] = swapping_opener + try: + with self.assertRaises(envelope_error) as captured: + read_bounded(direct_candidate) + finally: + read_bounded.__globals__["_nonblocking_read_opener"] = original_opener + + self.assertEqual(captured.exception.code, "invalid_file_type") + + @unittest.skipUnless(hasattr(os, "link"), "requires hard links") + def test_parent_swap_after_post_read_identity_probe_fails_closed(self) -> None: + """A final leaf check must not outlive its admitted parent identity.""" + + namespace = runpy.run_path( + str(VALIDATOR), run_name="spdx_parent_post_read_swap_contract" + ) + read_bounded = namespace["_read_bounded"] + envelope_error = namespace["SpdxJsonLdEnvelopeError"] + original_parent_identities = namespace["_direct_parent_identities"] + + with tempfile.TemporaryDirectory( + prefix="originweave-spdx-parent-post-read-swap-" + ) as directory: + root = pathlib.Path(directory) + direct_directory = root / "direct" + direct_directory.mkdir() + replacement_directory = root / "replacement" + replacement_directory.mkdir() + parked_directory = root / "direct-parked" + + direct_candidate = direct_directory / "candidate.spdx.jsonld" + direct_candidate.write_bytes(b"stable-release-sbom-bytes") + replacement_candidate = replacement_directory / direct_candidate.name + os.link(direct_candidate, replacement_candidate) + + probe_count = 0 + swapped = False + + def swap_after_post_read_probe(path: pathlib.Path) -> tuple[tuple[int, int], ...]: + nonlocal probe_count, swapped + identities = original_parent_identities(path) + probe_count += 1 + if probe_count == 3: + direct_directory.rename(parked_directory) + replacement_directory.rename(direct_directory) + swapped = True + return identities + + read_bounded.__globals__["_direct_parent_identities"] = swap_after_post_read_probe + try: + with self.assertRaises(envelope_error) as captured: + read_bounded(direct_candidate) + finally: + read_bounded.__globals__["_direct_parent_identities"] = ( + original_parent_identities + ) + if swapped: + direct_directory.rename(replacement_directory) + parked_directory.rename(direct_directory) + + self.assertEqual(captured.exception.code, "invalid_file_type") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_spdx_jsonld_parent_symlink_contract.py b/tests/test_release_spdx_jsonld_parent_symlink_contract.py new file mode 100644 index 000000000..28b0b5c15 --- /dev/null +++ b/tests/test_release_spdx_jsonld_parent_symlink_contract.py @@ -0,0 +1,39 @@ +"""Regression contract for indirect SPDX JSON-LD release parent paths.""" + +from __future__ import annotations + +import pathlib +import runpy +import tempfile +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +VALIDATOR = ROOT / "scripts" / "release" / "validate_spdx_jsonld.py" + + +class ReleaseSpdxJsonLdParentSymlinkContractTests(unittest.TestCase): + """Require every admitted release-artifact path component to be direct.""" + + def test_symlinked_parent_directory_is_rejected_as_indirect_release_input(self) -> None: + """An ancestor symlink may not redirect release-artifact path authority.""" + + namespace = runpy.run_path(str(VALIDATOR), run_name="spdx_parent_symlink_contract") + read_bounded = namespace["_read_bounded"] + envelope_error = namespace["SpdxJsonLdEnvelopeError"] + + with tempfile.TemporaryDirectory(prefix="originweave-spdx-parent-symlink-") as directory: + root = pathlib.Path(directory) + actual_directory = root / "actual" + actual_directory.mkdir() + (actual_directory / "candidate.spdx.jsonld").write_bytes(b"{}") + alias_directory = root / "alias" + alias_directory.symlink_to(actual_directory.name, target_is_directory=True) + + with self.assertRaises(envelope_error) as captured: + read_bounded(alias_directory / "candidate.spdx.jsonld") + + self.assertEqual(captured.exception.code, "invalid_file_type") + + +if __name__ == "__main__": + unittest.main()