diff --git a/CHANGELOG.md b/CHANGELOG.md index f747adeae..fd26dc8b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ All notable changes to OriginWeave are documented in this file. The format follo - 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. + +- Added a fail-closed release-manifest identity primitive that binds an exact lowercase source commit, bounded canonical Chromium revision, explicit release channel, the exact repository-pinned Rust 1.97.1 toolchain, exact lowercase dependency-lock SHA-256 evidence, and deterministic bounded artifact leaf names with lowercase SHA-256 digests; build identity remains metadata rather than reproducibility proof, moving toolchain aliases and alternate versions fail closed, and artifact identity is unique under ASCII case folding so case-only names cannot collide on case-insensitive target filesystems, without granting signing, publication, installation, update, rollback, or release authority. - Bound explicit extension-to-Agent grants to exclusive trusted-time expiry in addition to extension identity, session, browsing context, and canonical origin, so a same-origin grant cannot be reused at or after the deadline. - Bound explicit extension-to-Agent grants to the exact canonical origin in addition to extension identity, session, and browsing context, so a same-session navigation or port change cannot reuse the grant. - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. @@ -71,6 +73,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Security - 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. - Raw secrets are rejected and secret-capable actions require an opaque broker handle. - Crawler mode is read-only, must pair with the public-crawl purpose, and fails closed without an applicable robots-policy decision. diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index e33a7e7e5..663a9a4bd 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -7,6 +7,9 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] +/// Fail-closed identity binding for release artifacts. +pub mod release_manifest; + use std::collections::BTreeSet; use std::fmt; use std::net::{Ipv4Addr, Ipv6Addr}; diff --git a/crates/originweave-core/src/release_manifest.rs b/crates/originweave-core/src/release_manifest.rs new file mode 100644 index 000000000..2611163d8 --- /dev/null +++ b/crates/originweave-core/src/release_manifest.rs @@ -0,0 +1,361 @@ +//! 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. + +use std::collections::BTreeSet; +use std::error::Error; +use std::fmt; + +/// Maximum number of artifacts admitted by one release manifest. +pub const MAX_RELEASE_ARTIFACTS: usize = 64; +/// Maximum UTF-8 byte length admitted for one canonical artifact leaf name. +pub const MAX_RELEASE_ARTIFACT_NAME_BYTES: usize = 128; +/// Maximum UTF-8 byte length admitted for one Chromium revision token. +pub const MAX_RELEASE_REVISION_BYTES: usize = 128; + +/// Buyer-visible release channel bound by a release manifest. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReleaseChannel { + /// Stable release channel. + Stable, + /// Beta release channel. + Beta, + /// Development release channel. + Development, +} + +/// Exact build identity retained by one release manifest. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReleaseBuildIdentity { + rust_toolchain: String, + dependency_lock_sha256: String, +} + +impl ReleaseBuildIdentity { + /// Construct build identity from the exact repository-pinned Rust toolchain and lock digest. + /// + /// The Rust toolchain must match the protected repository baseline exactly; moving aliases + /// and alternate versions fail closed. The dependency-lock digest must use the exact + /// `sha256:` prefix followed by 64 lowercase hexadecimal digits. Constructing this value does + /// not prove reproducibility or authenticate the build environment; it only prevents those + /// two identity fields from being omitted or represented ambiguously in a release manifest. + pub fn new( + rust_toolchain: &str, + dependency_lock_sha256: &str, + ) -> Result { + if !valid_toolchain(rust_toolchain) { + return Err(ReleaseBuildIdentityError::InvalidRustToolchain); + } + if !valid_sha256_digest(dependency_lock_sha256) { + return Err(ReleaseBuildIdentityError::InvalidDependencyLockDigest); + } + Ok(Self { + rust_toolchain: rust_toolchain.to_owned(), + dependency_lock_sha256: dependency_lock_sha256.to_owned(), + }) + } + + /// Return the exact repository-pinned Rust toolchain token. + #[must_use] + pub fn rust_toolchain(&self) -> &str { + &self.rust_toolchain + } + + /// Return the exact lowercase `sha256:` dependency-lock digest. + #[must_use] + pub fn dependency_lock_sha256(&self) -> &str { + &self.dependency_lock_sha256 + } +} + +/// Validation error for release build-identity evidence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReleaseBuildIdentityError { + /// Rust toolchain does not match the exact repository-pinned baseline. + InvalidRustToolchain, + /// Dependency-lock digest is not a canonical lowercase SHA-256 digest. + InvalidDependencyLockDigest, +} + +impl fmt::Display for ReleaseBuildIdentityError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidRustToolchain => formatter + .write_str("release Rust toolchain must match the exact repository-pinned baseline"), + Self::InvalidDependencyLockDigest => formatter.write_str( + "release dependency lock digest must be sha256: followed by 64 lowercase hexadecimal digits", + ), + } + } +} + +impl Error for ReleaseBuildIdentityError {} + +/// One canonical release artifact identity. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReleaseArtifact { + name: String, + sha256_digest: String, +} + +impl ReleaseArtifact { + /// Construct one artifact from a canonical leaf name and lowercase SHA-256 digest. + /// + /// The digest must use the exact `sha256:` prefix followed by 64 lowercase hexadecimal + /// digits. Artifact names are ASCII leaf names and cannot contain path separators, + /// traversal-like double dots, leading or trailing punctuation, or Windows reserved device + /// basenames (including those basenames followed by extensions). + pub fn new(name: &str, sha256_digest: &str) -> Result { + if !valid_artifact_name(name) { + return Err(ReleaseArtifactError::InvalidName); + } + if !valid_sha256_digest(sha256_digest) { + return Err(ReleaseArtifactError::InvalidDigest); + } + Ok(Self { + name: name.to_owned(), + sha256_digest: sha256_digest.to_owned(), + }) + } + + /// Return the canonical artifact leaf name. + #[must_use] + pub fn name(&self) -> &str { + &self.name + } + + /// Return the canonical lowercase `sha256:` artifact digest. + #[must_use] + pub fn sha256_digest(&self) -> &str { + &self.sha256_digest + } +} + +/// Validation error for one release artifact. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReleaseArtifactError { + /// The artifact name is not a canonical bounded leaf name. + InvalidName, + /// The artifact digest is not a canonical lowercase SHA-256 digest. + InvalidDigest, +} + +impl fmt::Display for ReleaseArtifactError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidName => { + formatter.write_str("release artifact name is not a canonical bounded leaf name") + } + Self::InvalidDigest => formatter.write_str( + "release artifact digest must be sha256: followed by 64 lowercase hexadecimal digits", + ), + } + } +} + +impl Error for ReleaseArtifactError {} + +/// Deterministic, bounded identity manifest for one OriginWeave release candidate. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReleaseManifest { + source_commit: String, + chromium_revision: String, + channel: ReleaseChannel, + build_identity: ReleaseBuildIdentity, + artifacts: Vec, +} + +impl ReleaseManifest { + /// Construct an inert release manifest from exact identity evidence. + /// + /// Source identity is a full 40-digit lowercase Git commit SHA. Chromium revision is a + /// bounded canonical ASCII token, Rust toolchain identity is the exact repository pin, and + /// dependency-lock identity is a canonical lowercase SHA-256 digest. Artifact names must be + /// unique under ASCII case folding so one manifest cannot bind two names that collide on a + /// case-insensitive target filesystem; original spelling is preserved and artifacts are + /// sorted deterministically before storage. Constructing this value does not authenticate any + /// artifact, prove reproducibility, or authorize release or installation. + pub fn new( + source_commit: &str, + chromium_revision: &str, + channel: ReleaseChannel, + build_identity: ReleaseBuildIdentity, + artifacts: I, + ) -> Result + where + I: IntoIterator, + { + if !valid_source_commit(source_commit) { + return Err(ReleaseManifestError::InvalidSourceCommit); + } + if !valid_revision(chromium_revision) { + return Err(ReleaseManifestError::InvalidChromiumRevision); + } + + let mut admitted = Vec::new(); + let mut artifact_names = BTreeSet::new(); + for artifact in artifacts { + if admitted.len() >= MAX_RELEASE_ARTIFACTS { + return Err(ReleaseManifestError::TooManyArtifacts); + } + if !artifact_names.insert(artifact.name.to_ascii_lowercase()) { + return Err(ReleaseManifestError::DuplicateArtifactName); + } + admitted.push(artifact); + } + if admitted.is_empty() { + return Err(ReleaseManifestError::MissingArtifacts); + } + admitted.sort_by(|left, right| left.name.cmp(&right.name)); + + Ok(Self { + source_commit: source_commit.to_owned(), + chromium_revision: chromium_revision.to_owned(), + channel, + build_identity, + artifacts: admitted, + }) + } + + /// Return the exact lowercase source commit bound by this manifest. + #[must_use] + pub fn source_commit(&self) -> &str { + &self.source_commit + } + + /// Return the canonical Chromium revision token bound by this manifest. + #[must_use] + pub fn chromium_revision(&self) -> &str { + &self.chromium_revision + } + + /// Return the release channel bound by this manifest. + #[must_use] + pub const fn channel(&self) -> ReleaseChannel { + self.channel + } + + /// Return the exact build identity bound by this manifest. + #[must_use] + pub const fn build_identity(&self) -> &ReleaseBuildIdentity { + &self.build_identity + } + + /// Return artifacts sorted deterministically by canonical name. + #[must_use] + pub fn artifacts(&self) -> &[ReleaseArtifact] { + &self.artifacts + } +} + +/// Validation error for release-manifest identity evidence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReleaseManifestError { + /// Source commit is not a non-null full lowercase 40-hex Git object identity. + InvalidSourceCommit, + /// Chromium revision is not a canonical bounded release token. + InvalidChromiumRevision, + /// No release artifacts were supplied. + MissingArtifacts, + /// Artifact inventory exceeds the bounded release-manifest limit. + TooManyArtifacts, + /// Artifact inventory repeats an ASCII-case-folded artifact name. + DuplicateArtifactName, +} + +impl fmt::Display for ReleaseManifestError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidSourceCommit => formatter.write_str( + "release source commit must be a non-null 40-digit lowercase Git object identity", + ), + Self::InvalidChromiumRevision => { + formatter.write_str("Chromium revision must be a canonical bounded release token") + } + Self::MissingArtifacts => { + formatter.write_str("release manifest must contain at least one artifact") + } + Self::TooManyArtifacts => { + formatter.write_str("release manifest exceeds the artifact-count limit") + } + Self::DuplicateArtifactName => { + formatter.write_str("release manifest contains a duplicate artifact name") + } + } + } +} + +impl Error for ReleaseManifestError {} + +fn valid_artifact_name(name: &str) -> bool { + if name.is_empty() + || name.len() > MAX_RELEASE_ARTIFACT_NAME_BYTES + || !name.is_ascii() + || name.contains("..") + || windows_reserved_device_basename(name) + { + return false; + } + let bytes = name.as_bytes(); + bytes[0].is_ascii_alphanumeric() + && bytes[bytes.len() - 1].is_ascii_alphanumeric() + && bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b'-')) +} + +fn windows_reserved_device_basename(name: &str) -> bool { + let basename = match name.find('.') { + Some(dot_index) => &name[..dot_index], + None => name, + }; + + if basename.eq_ignore_ascii_case("CON") + || basename.eq_ignore_ascii_case("PRN") + || basename.eq_ignore_ascii_case("AUX") + || basename.eq_ignore_ascii_case("NUL") + { + return true; + } + + let bytes = basename.as_bytes(); + bytes.len() == 4 + && (basename[..3].eq_ignore_ascii_case("COM") || basename[..3].eq_ignore_ascii_case("LPT")) + && bytes[3].is_ascii_digit() +} + +fn valid_sha256_digest(digest: &str) -> bool { + let Some(hex) = digest.strip_prefix("sha256:") else { + return false; + }; + hex.len() == 64 + && hex + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} + +fn valid_source_commit(source_commit: &str) -> bool { + source_commit.len() == 40 + && source_commit + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + && source_commit.bytes().any(|byte| byte != b'0') +} + +fn valid_revision(revision: &str) -> bool { + if revision.is_empty() || revision.len() > MAX_RELEASE_REVISION_BYTES || !revision.is_ascii() { + return false; + } + let bytes = revision.as_bytes(); + bytes[0].is_ascii_alphanumeric() + && bytes[bytes.len() - 1].is_ascii_alphanumeric() + && bytes.iter().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b'-' | b'+' | b':' | b'@') + }) +} + +fn valid_toolchain(toolchain: &str) -> bool { + toolchain == "1.97.1" +} diff --git a/crates/originweave-core/tests/release_manifest.rs b/crates/originweave-core/tests/release_manifest.rs new file mode 100644 index 000000000..531b13685 --- /dev/null +++ b/crates/originweave-core/tests/release_manifest.rs @@ -0,0 +1,266 @@ +use std::error::Error; + +use originweave_core::release_manifest::{ + MAX_RELEASE_ARTIFACT_NAME_BYTES, MAX_RELEASE_ARTIFACTS, MAX_RELEASE_REVISION_BYTES, + ReleaseArtifact, ReleaseArtifactError, ReleaseBuildIdentity, ReleaseBuildIdentityError, + ReleaseChannel, ReleaseManifest, ReleaseManifestError, +}; + +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 build_identity() -> Result { + ReleaseBuildIdentity::new("1.97.1", &sha256_digest('9')) +} + +#[test] +fn release_manifest_binds_and_canonicalizes_exact_artifact_evidence() -> Result<(), Box> +{ + let runtime = ReleaseArtifact::new("originweave-linux-x86_64.tar.zst", &sha256_digest('a'))?; + let sbom = ReleaseArtifact::new("originweave.spdx.json", &sha256_digest('b'))?; + + let manifest = ReleaseManifest::new( + SOURCE_COMMIT, + CHROMIUM_REVISION, + ReleaseChannel::Stable, + build_identity()?, + vec![sbom, runtime], + )?; + + assert_eq!(manifest.source_commit(), SOURCE_COMMIT); + assert_eq!(manifest.chromium_revision(), CHROMIUM_REVISION); + assert_eq!(manifest.channel(), ReleaseChannel::Stable); + assert_eq!(manifest.build_identity().rust_toolchain(), "1.97.1"); + assert_eq!( + manifest.build_identity().dependency_lock_sha256(), + sha256_digest('9') + ); + assert_eq!(manifest.artifacts().len(), 2); + assert_eq!( + manifest.artifacts()[0].name(), + "originweave-linux-x86_64.tar.zst" + ); + assert_eq!(manifest.artifacts()[0].sha256_digest(), sha256_digest('a')); + assert_eq!(manifest.artifacts()[1].name(), "originweave.spdx.json"); + assert_eq!(manifest.artifacts()[1].sha256_digest(), sha256_digest('b')); + + let punctuation = ReleaseArtifact::new("originweave_cli-x86_64.bin", &sha256_digest('7'))?; + let punctuation_manifest = ReleaseManifest::new( + SOURCE_COMMIT, + "chromium_150-0+build:1@stable", + ReleaseChannel::Development, + build_identity()?, + vec![punctuation], + )?; + assert_eq!( + punctuation_manifest.chromium_revision(), + "chromium_150-0+build:1@stable" + ); + Ok(()) +} + +#[test] +fn release_artifact_rejects_ambiguous_names_and_noncanonical_digests() { + let valid_digest = sha256_digest('c'); + let overlong_name = "a".repeat(MAX_RELEASE_ARTIFACT_NAME_BYTES + 1); + + for invalid_name in [ + "", + ".hidden", + "artifact/child.bin", + "artifact\\child.bin", + "artifact..bin", + "artifact-.bin-", + "artifact-µ.bin", + "CON", + "con.zip", + "PRN.tar.zst", + "aux.bin", + "NUL.spdx.json", + "COM0", + "com0.zip", + "COM1", + "com9.zip", + "LPT0", + "lpt0.tar.zst", + "LPT1", + "lpt9.tar.zst", + overlong_name.as_str(), + ] { + assert_eq!( + ReleaseArtifact::new(invalid_name, &valid_digest), + Err(ReleaseArtifactError::InvalidName), + "reserved or ambiguous artifact name must fail closed: {invalid_name}" + ); + } + + for valid_name in ["console.bin", "com10.bin", "lpt10.tar.zst", "data.bin"] { + assert!( + ReleaseArtifact::new(valid_name, &valid_digest).is_ok(), + "non-device artifact name must remain admissible: {valid_name}" + ); + } + + for invalid_digest in [ + "", + "sha256:abc", + "SHA256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "sha256:gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg", + ] { + assert_eq!( + ReleaseArtifact::new("originweave.bin", invalid_digest), + Err(ReleaseArtifactError::InvalidDigest) + ); + } +} + +#[test] +fn release_manifest_rejects_invalid_identity_missing_duplicate_and_unbounded_evidence() +-> Result<(), Box> { + let artifact = ReleaseArtifact::new("originweave.bin", &sha256_digest('d'))?; + + for invalid_commit in [ + "", + "0000000000000000000000000000000000000000", + "0123456789abcdef0123456789abcdef0123456", + "0123456789ABCDEF0123456789ABCDEF01234567", + "g123456789abcdef0123456789abcdef01234567", + ] { + assert_eq!( + ReleaseManifest::new( + invalid_commit, + CHROMIUM_REVISION, + ReleaseChannel::Development, + build_identity()?, + vec![artifact.clone()], + ), + Err(ReleaseManifestError::InvalidSourceCommit) + ); + } + + let overlong_revision = "a".repeat(MAX_RELEASE_REVISION_BYTES + 1); + for invalid_revision in [ + "", + " chromium-150", + "chromium 150", + "chromium/150", + "chromium-150-", + "chromium-µ", + overlong_revision.as_str(), + ] { + assert_eq!( + ReleaseManifest::new( + SOURCE_COMMIT, + invalid_revision, + ReleaseChannel::Beta, + build_identity()?, + vec![artifact.clone()], + ), + Err(ReleaseManifestError::InvalidChromiumRevision) + ); + } + + assert_eq!( + ReleaseManifest::new( + SOURCE_COMMIT, + CHROMIUM_REVISION, + ReleaseChannel::Stable, + build_identity()?, + Vec::new(), + ), + Err(ReleaseManifestError::MissingArtifacts) + ); + + let duplicate = ReleaseArtifact::new("originweave.bin", &sha256_digest('e'))?; + assert_eq!( + ReleaseManifest::new( + SOURCE_COMMIT, + CHROMIUM_REVISION, + ReleaseChannel::Stable, + build_identity()?, + vec![artifact.clone(), duplicate], + ), + Err(ReleaseManifestError::DuplicateArtifactName) + ); + + let case_collision = ReleaseArtifact::new("ORIGINWEAVE.BIN", &sha256_digest('6'))?; + assert_eq!( + ReleaseManifest::new( + SOURCE_COMMIT, + CHROMIUM_REVISION, + ReleaseChannel::Stable, + build_identity()?, + vec![artifact.clone(), case_collision], + ), + Err(ReleaseManifestError::DuplicateArtifactName) + ); + + let mut too_many = Vec::new(); + for index in 0..=MAX_RELEASE_ARTIFACTS { + too_many.push(ReleaseArtifact::new( + &format!("artifact-{index}.bin"), + &sha256_digest('f'), + )?); + } + assert_eq!( + ReleaseManifest::new( + SOURCE_COMMIT, + CHROMIUM_REVISION, + ReleaseChannel::Stable, + build_identity()?, + too_many, + ), + Err(ReleaseManifestError::TooManyArtifacts) + ); + Ok(()) +} + +#[test] +fn release_manifest_errors_are_standard_source_free_rust_errors() { + let artifact_errors = [ + ( + ReleaseArtifactError::InvalidName, + "release artifact name is not a canonical bounded leaf name", + ), + ( + ReleaseArtifactError::InvalidDigest, + "release artifact digest must be sha256: followed by 64 lowercase hexadecimal digits", + ), + ]; + for (error, expected) in artifact_errors { + assert_eq!(error.to_string(), expected); + assert!(Error::source(&error).is_none()); + } + + let manifest_errors = [ + ( + ReleaseManifestError::InvalidSourceCommit, + "release source commit must be a non-null 40-digit lowercase Git object identity", + ), + ( + ReleaseManifestError::InvalidChromiumRevision, + "Chromium revision must be a canonical bounded release token", + ), + ( + ReleaseManifestError::MissingArtifacts, + "release manifest must contain at least one artifact", + ), + ( + ReleaseManifestError::TooManyArtifacts, + "release manifest exceeds the artifact-count limit", + ), + ( + ReleaseManifestError::DuplicateArtifactName, + "release manifest contains a duplicate artifact name", + ), + ]; + for (error, expected) in manifest_errors { + assert_eq!(error.to_string(), expected); + assert!(Error::source(&error).is_none()); + } +} diff --git a/crates/originweave-core/tests/release_manifest_build_identity.rs b/crates/originweave-core/tests/release_manifest_build_identity.rs new file mode 100644 index 000000000..fd9a2852e --- /dev/null +++ b/crates/originweave-core/tests/release_manifest_build_identity.rs @@ -0,0 +1,74 @@ +use std::error::Error; + +use originweave_core::release_manifest::{ReleaseBuildIdentity, ReleaseBuildIdentityError}; + +fn sha256_digest(hex_digit: char) -> String { + format!("sha256:{}", hex_digit.to_string().repeat(64)) +} + +#[test] +fn release_build_identity_binds_exact_toolchain_and_dependency_lock() -> Result<(), Box> +{ + let identity = ReleaseBuildIdentity::new("1.97.1", &sha256_digest('a'))?; + + assert_eq!(identity.rust_toolchain(), "1.97.1"); + assert_eq!(identity.dependency_lock_sha256(), sha256_digest('a')); + Ok(()) +} + +#[test] +fn release_build_identity_rejects_ambiguous_or_unbounded_evidence() { + let digest = sha256_digest('b'); + let overlong_toolchain = "a".repeat(65); + + for invalid_toolchain in [ + "", + "stable", + "beta", + "nightly", + "1.98.0", + " 1.97.1", + "1.97.1 ", + "rust 1.97.1", + "1.97.1/nightly", + "1.97.1-µ", + overlong_toolchain.as_str(), + ] { + assert_eq!( + ReleaseBuildIdentity::new(invalid_toolchain, &digest), + Err(ReleaseBuildIdentityError::InvalidRustToolchain), + ); + } + + for invalid_digest in [ + "", + "sha256:abc", + "SHA256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "sha256:gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg", + ] { + assert_eq!( + ReleaseBuildIdentity::new("1.97.1", invalid_digest), + Err(ReleaseBuildIdentityError::InvalidDependencyLockDigest), + ); + } +} + +#[test] +fn release_build_identity_errors_are_standard_source_free_rust_errors() { + let errors = [ + ( + ReleaseBuildIdentityError::InvalidRustToolchain, + "release Rust toolchain must match the exact repository-pinned baseline", + ), + ( + ReleaseBuildIdentityError::InvalidDependencyLockDigest, + "release dependency lock digest must be sha256: followed by 64 lowercase hexadecimal digits", + ), + ]; + + for (error, expected) in errors { + assert_eq!(error.to_string(), expected); + assert!(Error::source(&error).is_none()); + } +} diff --git a/docs/README.md b/docs/README.md index 1ea57ad29..a9e520be9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -89,10 +89,11 @@ The second group exists only on this documentation branch until the branch integ ### Proposed decisions introduced by active feature work +- [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 0016 is owned by this active BAP lifecycle feature branch and remains Proposed. Its presence here makes the branch documentation graph complete without presenting the decision or implementation as protected-main truth before integration. +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. -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 ADR 0016 from Proposed or assert implementation maturity. +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. diff --git a/docs/adr/0015-release-manifest-identity.md b/docs/adr/0015-release-manifest-identity.md new file mode 100644 index 000000000..367668513 --- /dev/null +++ b/docs/adr/0015-release-manifest-identity.md @@ -0,0 +1,118 @@ +# ADR 0015: Release manifest identity boundary + +- Status: Proposed +- Date: 2026-08-23 + +## Context + +Issue #201 requires commercial OriginWeave releases to bind an exact OriginWeave source identity, Chromium revision, release channel, build identity, and the artifacts buyers install or verify. That larger release lifecycle will later add signing, SBOM/SLSA provenance, updater trust, rollback, platform support, and operational acceptance. Those authorities do not yet exist in the current source tree. + +A smaller durable boundary is nevertheless required before packaging work can safely compose: one deterministic, bounded manifest identity for a release candidate. Without an explicit contract, equivalent release inventories can be represented differently, exact build inputs can be omitted or represented ambiguously, moving toolchain aliases can resolve to different compiler identities over time, case-insensitive target filesystems can collapse distinct names, and host-specific device namespaces can reinterpret an apparent artifact leaf name. + +Git protocol grammar distinguishes a 40-zero `zero-id` from ordinary object identifiers, and push protocol uses that zero-id as the sentinel for absent/create/delete reference state. A release manifest that admitted the all-zero value as a concrete source commit could therefore claim an exact source identity that does not identify a real Git object. OriginWeave rejects that sentinel at admission rather than relying on later publication or provenance layers to reinterpret it. + +## Decision drivers + +- One release-candidate identity must not depend on caller ordering. +- The manifest must not omit or ambiguously represent the exact Rust toolchain and dependency-lock identity used for the candidate. +- The Rust toolchain field must not accept moving aliases or an alternate compiler version as though it were the repository-pinned build identity. +- Source commit identity must distinguish a concrete Git object identifier from Git's all-zero protocol sentinel. +- Artifact references must remain leaf identities rather than filesystem paths. +- The same manifest must avoid the explicitly modeled ASCII case-fold and Windows reserved-device basename collisions; that bounded rule is not a complete OneDrive or SharePoint synchronization-compatibility guarantee. +- Manifest construction must remain inert metadata admission and must not grant signing, publication, installation, update, rollback, or release authority. +- Inputs must be bounded and fail closed before later packaging, signing, or updater layers consume them. + +## Assumptions and authority boundaries + +- Source identity is a full 40-character lowercase Git commit SHA and must not be Git's all-zero `zero-id` sentinel. +- Chromium identity is a bounded canonical ASCII release token; this ADR does not claim that the token alone authenticates Chromium bytes. +- A release channel is explicit (`Stable`, `Beta`, or `Development`) metadata, not authorization to publish or promote a release. +- Rust toolchain identity is the exact protected-repository baseline `1.97.1`; moving aliases such as `stable`, `beta`, and `nightly`, and alternate versions, are not admissible release build identities. Binding the exact token does not prove that a build actually used it. +- Dependency-lock identity is an exact lowercase `sha256:` digest; binding that digest does not authenticate the dependency source, build environment, or resulting artifact. +- Every admitted artifact carries a bounded ASCII leaf name and an exact lowercase `sha256:` digest. +- The manifest contains no private signing material, credentials, secrets, installer authority, network authority, or update authority. +- Later release systems must independently authenticate the build, signer, provenance, platform package, updater metadata, and operational acceptance evidence. + +## Options considered + +### Caller-order manifest with host-local filenames + +Rejected. Caller ordering makes identity representation unstable, and host-local filename rules can produce collisions or device-name reinterpretation on another supported platform. + +### Canonical manifest that normalizes stored spelling + +Rejected for this slice. Destructively rewriting admitted artifact spelling would make the manifest differ from the exact release artifact identity that packaging and verification need to preserve. + +### Canonical admission with preserved spelling and collision guards + +Selected. Preserve the admitted artifact spelling, sort artifacts deterministically by that spelling, bind exact build-identity fields separately, require the repository-pinned Rust compiler identity, reject Git's all-zero source-identity sentinel, and reject names whose ASCII-case-folded identities collide or whose basenames fall within the explicitly modeled Windows reserved-device deny set. + +## Decision + +OriginWeave release-manifest admission is a deterministic, bounded, fail-closed identity contract: + +1. `source_commit` must be exactly 40 lowercase hexadecimal digits and must not be the all-zero Git `zero-id` sentinel. +2. `chromium_revision` must be a non-empty bounded canonical ASCII token. +3. `channel` must be an explicit `ReleaseChannel` variant. +4. `rust_toolchain` must equal the protected repository's exact pinned Rust toolchain `1.97.1`. Moving aliases (`stable`, `beta`, `nightly`) and alternate versions fail closed until the protected baseline and this binding decision are deliberately changed together. +5. `dependency_lock_sha256` must be exactly `sha256:` followed by 64 lowercase hexadecimal digits. +6. The artifact inventory must be non-empty and contain at most 64 entries. +7. Each artifact name must be a bounded ASCII leaf name containing only alphanumerics, `.`, `_`, and `-`; it cannot contain path separators, traversal-like `..`, leading punctuation, or trailing punctuation. +8. Artifact basenames `CON`, `PRN`, `AUX`, `NUL`, `COM0` through `COM9`, and `LPT0` through `LPT9` are rejected case-insensitively, including when followed by an extension. Microsoft Win32 filename guidance explicitly reserves `COM1`-`COM9` and `LPT1`-`LPT9` and documents `COM0` as a possible Win32 namespace symlink; Microsoft OneDrive and SharePoint also restrict additional names, including `desktop.ini`, that this validator does not model. OriginWeave deliberately enforces the listed reserved-device deny set as bounded filename identity hygiene. This 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. The artifact grammar already rejects the non-ASCII superscript-digit Win32 aliases. +9. Artifact names must be unique under ASCII case folding while their original admitted spelling is retained. +10. Each artifact digest must be exactly `sha256:` followed by 64 lowercase hexadecimal digits. +11. Admitted artifacts are stored in deterministic name order. +12. Validation errors remain typed, deterministic standard Rust errors. + +Constructing or possessing a valid manifest does **not** authenticate an artifact, prove that the claimed toolchain or dependency lock was used, prove reproducibility, prove provenance, verify a signature, establish a signing identity, authorize a release channel, publish software, install software, update software, roll software back, or satisfy release acceptance. + +## Consequences + +The release candidate receives one bounded build-and-artifact identity representation that is stable across caller order, records a concrete non-null Git source identity plus the exact repository-pinned Rust toolchain and dependency-lock evidence, and avoids the explicitly modeled ASCII case-fold and Windows reserved-device basename ambiguities. Packaging, signing, provenance, synchronization validation, and update layers can compose on top of this contract without inheriting ambient authority from it. + +Changing the protected Rust baseline now requires deliberate convergence of the repository toolchain pin, release-manifest admission contract, tests, and this ADR. A moving channel alias cannot silently change the compiler identity represented by a release manifest. + +The contract is intentionally narrower than issue #201's final release manifest. Additional fields such as adapter versions, build environment, signing identity, timestamp, SBOM/provenance references, and platform package identity remain future reviewed work rather than being inferred from this primitive. + +## Failure and degraded behavior + +Malformed, ambiguous, duplicate, unbounded, empty, null-sentinel, moving-alias, or alternate-version identity evidence fails closed before a manifest is produced. A source identity equal to Git's 40-zero `zero-id`, a Rust toolchain other than the exact protected baseline, or an invalid dependency-lock identity is rejected before a manifest can carry that evidence. There is no fallback that silently rewrites an invalid name, substitutes another source commit, toolchain, or dependency-lock digest, accepts an alternate digest representation, drops an artifact, or substitutes another release channel. + +A caller that cannot provide canonical evidence does not receive a release manifest. That failure is not converted into permission to sign, publish, install, or update through another path. + +## Security / privacy / governance impact + +The boundary reduces null-source-identity, omitted-build-identity, moving-toolchain ambiguity, path-confusion, and cross-platform filename ambiguity without introducing credentials or protected values. It does not alter GitHub governance, reviewer authority, release signing authority, or protected-main policy. Scheduled development agents remain unable to merge, tag, publish, sign, or change release authority. + +## Tests and acceptance evidence + +The owning `originweave-core` tests must cover valid deterministic ordering, exact artifact and dependency-lock digests, one concrete non-null 40-character source commit, rejection of Git's all-zero source sentinel, the exact pinned Rust toolchain, moving aliases and alternate toolchain versions, malformed toolchain inputs, identifier bounds, malformed names, path/traversal-like names, case-only collisions, `COM0`-`COM9` and `LPT0`-`LPT9` reserved-device basenames with and without extensions, neighboring admissible names such as `COM10` and `LPT10`, exact inventory bounds, duplicate names, channel and build-identity access, and deterministic standard error contracts. Documentation regression tests also keep this bounded filename claim distinct from broader OneDrive/SharePoint restrictions such as `desktop.ini`. + +Owned production function, line, region, and branch coverage remains exactly 100% on the unchanged reviewed head. CI/security/scanner evidence is exact-head evidence only; predecessor or model-only evidence cannot satisfy acceptance. + +## Migration and rollback + +This is a new admission primitive with no protected-main persisted manifest migration. If the contract proves incompatible before acceptance, the Proposed ADR and owning feature branch can be revised or withdrawn without granting legacy inputs grandfathered authority. + +After acceptance and external release artifacts depend on this schema, any incompatible identity change requires an explicit versioning/migration decision rather than silent parser broadening. A future Rust upgrade must change the repository baseline and release-manifest contract together rather than relying on a moving alias. + +## Open follow-ups + +- Complete issue #201's signed cross-platform distribution and updater trust architecture. +- Bind adapter, build-environment, SBOM/SLSA provenance, signing, timestamp, and platform-package evidence through separately reviewed contracts. +- Define manifest serialization/versioning before any durable external release-manifest format is promised. +- Add signing-key rotation, compromise response, update rollback/freeze protection, and platform release acceptance without transferring authority from this metadata type. + +## Supersession / reversal conditions + +Supersede this ADR when a versioned external release-manifest specification replaces the in-process identity primitive, when the protected Rust baseline changes under a reviewed migration, or when supported-platform packaging requires a stronger canonical filename identity. Reversal must preserve fail-closed build and artifact identity and cannot make possession of metadata equivalent to release authority. + +## References + +Git. (2025). *gitprotocol-common documentation (Git 2.50.0)*. https://git-scm.com/docs/gitprotocol-common/2.50.0 + +Git. (2026). *gitprotocol-pack documentation (Git 2.54.0)*. https://git-scm.com/docs/gitprotocol-pack/2.54.0 + +Microsoft. (n.d.). *Naming files, paths, and namespaces*. Microsoft Learn. Retrieved August 23, 2026, from https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file + +Microsoft. (n.d.). *Restrictions and limitations in OneDrive and SharePoint*. Microsoft Support. Retrieved August 23, 2026, from https://support.microsoft.com/en-US/onedrive/restrictions-and-limitations-in-onedrive-and-sharepoint diff --git a/docs/adr/README.md b/docs/adr/README.md index 5f9e2a878..69994b9b1 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -61,11 +61,12 @@ ADR 0013 and ADR 0014 exist only on this documentation branch until it integrate | 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 | -ADR 0016 belongs to the active BAP lifecycle feature branch. Indexing it makes the branch documentation graph complete while preserving its Proposed lifecycle and active-PR, non-protected-main maturity. +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. -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 ADR 0016 from Proposed or assert implementation maturity. +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. Other active feature PRs may contain additional Proposed ADRs. Those files are not part of this canonical documentation line until integrated or deliberately reconciled here. Historical PR checks, stale branch state, or chat decisions never transfer ADR acceptance across a changed head. diff --git a/docs/doctoring.md b/docs/doctoring.md index ec51daaf3..73b34cfd4 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -82,6 +82,14 @@ Credential-free TLS evidence records the canonical origin, TCP peers, reference The test-only rcgen 0.14.8 dependency creates a local CA and deterministic certificate-policy scenarios. It is not part of production arithmetic or trust. Tests cover trusted DNS identity, Common Name non-fallback, wrong name, untrusted root, expired and not-yet-valid validity, exact IPv4 and IPv6 SAN identity, TLS 1.2 and TLS 1.3, required and optional ALPN, and equality between TLS origin and TCP authority. +### Release artifact filename portability + +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. + +### 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. + ### Crawling policy RFC 9309 standardizes robots parsing, matching, error handling, and caching. It also states that robots rules are not access authorization. OriginWeave therefore requires robots evidence for public crawler mode while maintaining authentication, terms, rate, privacy, and retention policy as separate controls. @@ -140,6 +148,10 @@ Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110). Fugu Team, Sakana AI. (2026). *Sakana Fugu technical report* [Technical report]. arXiv. https://doi.org/10.48550/arXiv.2606.21228 +Git. (2025). *gitprotocol-common documentation (Git 2.50.0)*. https://git-scm.com/docs/gitprotocol-common/2.50.0 + +Git. (2026). *gitprotocol-pack documentation (Git 2.54.0)*. https://git-scm.com/docs/gitprotocol-pack/2.54.0 + Huston, G., & Buraglio, N. (2024). *Expanding the IPv6 documentation space* (RFC 9637). Internet Engineering Task Force. https://doi.org/10.17487/RFC9637 Internet Assigned Numbers Authority. (2025, October 9). *IPv4 special-purpose address space*. https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml @@ -156,6 +168,10 @@ Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *OAuth 2.0 securi Microsoft. (2025, July 25). *Azure IP address 168.63.129.16 overview*. Microsoft Learn. https://learn.microsoft.com/azure/virtual-network/what-is-ip-address-168-63-129-16 +Microsoft. (n.d.). *Naming files, paths, and namespaces*. Microsoft Learn. Retrieved August 23, 2026, from https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file + +Microsoft. (n.d.). *Restrictions and limitations in OneDrive and SharePoint*. Microsoft Support. Retrieved August 23, 2026, from https://support.microsoft.com/en-US/onedrive/restrictions-and-limitations-in-onedrive-and-sharepoint + Model Context Protocol. (2026, July 28). *Specification: 2026-07-28*. https://modelcontextprotocol.io/specification/2026-07-28 Nielsen, S., Cetin, E., Schwendeman, P., Sun, Q., Xu, J., & Tang, Y. (2025). *Learning to orchestrate agents in natural language with the Conductor* [Preprint]. arXiv. https://doi.org/10.48550/arXiv.2512.04388 @@ -196,4 +212,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 +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 diff --git a/tests/test_release_manifest_documentation_contract.py b/tests/test_release_manifest_documentation_contract.py new file mode 100644 index 000000000..3c0a4c18e --- /dev/null +++ b/tests/test_release_manifest_documentation_contract.py @@ -0,0 +1,56 @@ +"""Regression contracts for release-manifest documentation truth and source citations.""" + +from __future__ import annotations + +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +ADR_PATH = ROOT / "docs/adr/0015-release-manifest-identity.md" +CHANGELOG_PATH = ROOT / "CHANGELOG.md" +DOCTORING_PATH = ROOT / "docs/doctoring.md" + + +class ReleaseManifestDocumentationContractTests(unittest.TestCase): + """Keep release-manifest portability and Git-source claims narrower than the code proves.""" + + def test_sync_compatibility_claim_matches_the_actual_filename_validator(self) -> None: + """Reserved-device guards must not be presented as full OneDrive/SharePoint compatibility.""" + adr = ADR_PATH.read_text(encoding="utf-8") + changelog = CHANGELOG_PATH.read_text(encoding="utf-8") + doctoring = DOCTORING_PATH.read_text(encoding="utf-8") + + for text in (adr, changelog, doctoring): + with self.subTest(document=text[:40]): + self.assertIn("desktop.ini", text) + self.assertIn( + "not a complete OneDrive or SharePoint synchronization-compatibility guarantee", + text, + ) + + self.assertNotIn("common buyer synchronization paths", adr) + self.assertNotIn("does not become a device or synchronization conflict", adr) + self.assertNotIn( + "cannot become Windows device or Microsoft synchronization conflicts", + changelog, + ) + + def test_git_protocol_references_pin_the_verified_manual_revisions(self) -> None: + """Protocol evidence must resolve to the manual revisions that contain the cited rules.""" + expected_citations = ( + "Git. (2025). *gitprotocol-common documentation (Git 2.50.0)*.", + "Git. (2026). *gitprotocol-pack documentation (Git 2.54.0)*.", + ) + for path in (ADR_PATH, DOCTORING_PATH): + text = path.read_text(encoding="utf-8") + with self.subTest(path=path): + for citation in expected_citations: + self.assertIn(citation, text) + self.assertIn("gitprotocol-common/2.50.0", text) + self.assertIn("gitprotocol-pack/2.54.0", text) + self.assertNotIn("gitprotocol-common/2.55.0", text) + self.assertNotIn("gitprotocol-pack/2.55.0", text) + + +if __name__ == "__main__": + unittest.main()