diff --git a/.github/workflows/build-baseline.yml b/.github/workflows/build-baseline.yml index 13de8e648..3ba71dba2 100644 --- a/.github/workflows/build-baseline.yml +++ b/.github/workflows/build-baseline.yml @@ -411,7 +411,14 @@ jobs: supply-chain/supplemental-component-inventory.json - name: Validate release asset set run: python3 scripts/release/select_release_assets.py --output release-assets.txt - - name: Create draft release with complete assets, then publish + - name: Build receipt-bound updater manifest + run: | + python3 scripts/release/build_updater_manifest.py \ + --git-sha "${{ github.sha }}" \ + --repository "${{ github.repository }}" \ + --server-url "${{ github.server_url }}" \ + --output latest.json + - name: Create draft release, re-verify hosted bytes, then publish env: GH_TOKEN: ${{ secrets.BANDSCOPE_RELEASE_TOKEN }} RELEASE_TAG: ${{ github.ref_name }} @@ -426,6 +433,13 @@ jobs: exit 1 fi python3 scripts/release/select_release_assets.py --input release-assets.txt + python3 scripts/release/build_updater_manifest.py \ + --git-sha "${{ github.sha }}" \ + --repository "${{ github.repository }}" \ + --server-url "${{ github.server_url }}" \ + --output latest.json \ + --check + printf '%s\n' latest.json >> release-assets.txt mapfile -t release_assets < release-assets.txt (( ${#release_assets[@]} > 0 )) gh release create "$RELEASE_TAG" \ @@ -435,4 +449,32 @@ jobs: --title "BandScope ${RELEASE_TAG#v}" \ --verify-tag \ --repo "${{ github.repository }}" + + rm -rf draft-release-download + mkdir draft-release-download + gh release download "$RELEASE_TAG" \ + --dir draft-release-download \ + --repo "${{ github.repository }}" + python3 scripts/release/verify_hosted_release_assets.py \ + --local-root . \ + --hosted-root draft-release-download \ + --asset-list release-assets.txt + gh release edit "$RELEASE_TAG" --draft=false --repo "${{ github.repository }}" + + rm -rf published-release-download + mkdir published-release-download + gh release download "$RELEASE_TAG" \ + --dir published-release-download \ + --repo "${{ github.repository }}" + python3 scripts/release/verify_hosted_release_assets.py \ + --local-root . \ + --hosted-root published-release-download \ + --asset-list release-assets.txt + + gh release verify "$RELEASE_TAG" --repo "${{ github.repository }}" + while IFS= read -r asset; do + [ -n "$asset" ] || continue + gh release verify-asset "$RELEASE_TAG" "$asset" \ + --repo "${{ github.repository }}" + done < release-assets.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e743c2ff..393ee63ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,8 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false + - name: Validate Distribution HTTP dependency admission + run: python3 scripts/checks/verify_distribution_http_dependencies.py - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: "22.22.3" @@ -48,9 +50,62 @@ jobs: - name: Reject manifest or lockfile drift run: git diff --exit-code -- package.json package-lock.json + distribution-download-platform: + name: gate / ci / distribution-download / ${{ matrix.os }} + needs: lock-validation + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-2025 + - macos-15 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22.22.3" + package-manager-cache: false + - name: Test explicit UMX-HQ acquisition without external downloads + run: node --test scripts/release/download_umxhq.test.mjs scripts/release/download_umxhq_cli.test.mjs + - name: Install stable Rust toolchain + run: rustup toolchain install stable --profile minimal + - name: Test Distribution download staging and lease contracts + run: cargo +stable test --manifest-path apps/desktop/distribution-download/Cargo.toml --locked --all-targets + + distribution-owned-platform: + name: gate / ci / distribution-owned / ${{ matrix.os }} + needs: lock-validation + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-2025 + - macos-15 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Install stable Rust toolchain + run: rustup toolchain install stable --profile minimal + - name: Test Distribution state authority contracts + run: cargo +stable test --manifest-path apps/desktop/distribution-state/Cargo.toml --locked --all-targets + - name: Test Distribution metadata admission contracts + run: cargo +stable test --manifest-path apps/desktop/distribution-runtime/Cargo.toml --locked --all-targets + - name: Test Distribution transport contracts + run: cargo +stable test --manifest-path apps/desktop/distribution-transport/Cargo.toml --locked --all-targets + verify: name: ci / build-and-test - needs: lock-validation + needs: + - lock-validation + - distribution-download-platform + - distribution-owned-platform runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca0df5ac4..0c1764d4d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # ARCHITECTURE.md -Last updated: 2026-03-11 +Last updated: 2026-09-16 ## Brand source @@ -58,11 +58,34 @@ Last updated: 2026-03-11 ## Repository map - `apps/desktop` - desktop shell and user-facing React UI +- `apps/desktop/distribution-core` - Tauri-independent Rust security policy for updater release identity, anti-replay, target compatibility, and project-schema-aware rollback decisions +- `apps/desktop/distribution-runtime` - stateless Rust admission boundary for untrusted Tauri updater JSON; validates the complete four-target document including each platform's canonical standard-base64 outer signature envelope, then returns provisional metadata with the selected target's exact admitted URL/signature and cannot mutate freshness state +- `apps/desktop/distribution-transport` - deterministic Rust response-state bridge from the strict provisional target projection to bounded updater staging; consumes metadata-owner signature syntax guarantees and owns exact effective-URL checks plus one-hop release-asset redirect admission, but not sockets, TLS, metadata authentication, minisign verification or installation +- `apps/desktop/distribution-download` - network-library-independent Rust streaming/staging boundary for updater artifacts; owns expected-size/content-length/chunk/cumulative limits, exclusive temporary artifact lifecycle, descriptor-bound Unix cleanup, conservative Windows deferred stale-file reclamation, and read-only descriptor-bound verifier access, but not HTTP, signatures, digests or installation +- `apps/desktop/distribution-state` - Distribution-owned bounded highest-authenticated-release state; Unix uses a single-link append/sync log, while Windows publishes synchronized replacement snapshots to avoid mutating pre-existing hard-link aliases on stable Rust; it consumes `distribution-core` identity and never project bytes - `packages/shared-types` - stable cross-layer types shared by the UI and orchestration layer - `services/analysis-engine` - Python analysis service for source separation and music analysis - `scripts/harness` - fail-fast repo verification - `scripts/checks` - small doc and structure checks +## Distribution/update bounded context + +- Distribution owns commercial release identity, native signing/notarization admission, updater policy, immutable publication evidence, bounded updater artifact transport/storage admission, highest-seen update freshness state, and last-known-good installer recovery decisions. +- `apps/desktop/distribution-core` contains deterministic security decisions only. It does not fetch metadata, verify Tauri signatures, write project data, run installers, or manufacture signing/key authority. +- `apps/desktop/distribution-runtime` admits the current static updater JSON only as bounded provisional remote input. It rejects duplicate/unknown members, unexpected targets, mutable release URLs, invalid release-identity syntax, and any supported platform signature that is not a bounded canonical RFC 4648 standard-base64 outer envelope; retains the selected target's canonical URL/signature from that same strict parse for later transport consumption; and projects the fixed app-owned highest-seen path without creating or writing it. It deliberately has no `distribution-state` dependency, and retained transport fields remain provisional rather than authenticated authority. +- `apps/desktop/distribution-transport` consumes only that already-selected provisional projection and admits updater transport state without reparsing `raw_json` or duplicating signature-envelope syntax. A direct `200` must report the exact canonical initial URL. GitHub release-asset `302` handling is explicit and limited to one HTTPS hop to the current `release-assets.githubusercontent.com` egress allowlist; the follow-up must terminate in `200` at the exact admitted Location and redirect chaining fails closed. The CDN hostname is a product allowlist, not a claim that GitHub guarantees it permanently. The crate has no HTTP client, socket, installer, metadata-authentication, minisign-verification or freshness-state capability. +- Publication mirrors that outer signature-envelope contract after exact receipt binding: `scripts/release/build_updater_manifest.py` requires `.sig` bytes to be canonical standard base64 and the decoded envelope payload to be UTF-8 before static updater JSON can be emitted. This is publication admission only and does not replace Tauri's updater signature verification. +- `apps/desktop/distribution-download` owns the pure streaming/staging primitive used before artifact trust is established. It enforces a 2 GiB artifact ceiling, exact optional `Content-Length`, 1 MiB maximum caller chunk, cumulative overrun rejection before sink write, sink-error poisoning, exact-length completion, exclusive app-owned staging and descriptor-safe cleanup. A sealed artifact remains provisional; downstream verification reads the exact still-open descriptor through a positional `Read` wrapper and cannot obtain the underlying write-capable staging `File` through the public API. Unix cleanup unlinks the staging pathname only when the current direct regular-file `(dev, ino)` still matches the open descriptor, so an already-replaced basename is not deleted as if it were the owned artifact. Windows does not unlink a remembered pathname on `Drop` when stable Rust cannot prove descriptor/path identity; it closes the handle and leaves the unverified scratch file for the next staging attempt, which may reclaim a stale direct regular child only after acquiring the shared staging lease. This trades bounded scratch retention for avoiding deletion of an unrelated replacement object and is not trust promotion. It does not perform network I/O, authenticate metadata, verify signatures/digests, run installers or mutate freshness state. Commercial completion requires the production HTTP adapter to disable implicit redirects, report response state through `distribution-transport`, and route actual response chunks through this boundary rather than relying on Tauri's full-response buffering. +- `apps/desktop/distribution-state` persists only the highest authenticated release identity as a bounded ordered log under a sibling OS lease. It revalidates committed identities, rejects local version regression/equivocation, and recovers only a syntactically valid torn final-record prefix. Unix admits only a single-link state object and uses synchronized append/truncate repair. Windows does not depend on nightly-only link-count metadata: when mutation is required it writes the complete committed bounded log to a `create_new` sibling snapshot, synchronizes it, and replaces only the state pathname, preserving any pre-existing hard-link alias file record. This source-level design is not packaged Windows power-loss proof and does not claim protection from a same-user actor that ignores the advisory lease and races filesystem namespace changes. +- Tauri updater signatures authenticate downloaded updater artifact bytes. They do not, by themselves, authenticate the whole `Update.raw_json` response or BandScope's `sourceCommit`/digest extensions. Remote metadata therefore stays provisional until a canonical metadata-authentication path binds its release identity to trusted authority. +- Only after metadata authentication and updater artifact signature/digest/size binding may exact `version`, `sourceCommit`, updater SHA-256, target, and compatibility floor enter `distribution-core` and `distribution-state` as freshness authority. +- Stable-channel automatic update decisions use canonical numeric `MAJOR.MINOR.PATCH`. Prerelease/build ordering is not approximated; a future beta channel requires a separate ADR and canonical SemVer implementation. +- A release older than locally persisted highest-seen authenticated metadata is replay, and the same version with a different source commit or updater digest is equivocation. Neither may be silently downgraded into a normal update offer. +- Highest-seen release identity belongs to Distribution-owned app state and is recorded only after its metadata identity has authenticated authority; installation completion is not required, but syntactically valid remote JSON alone is insufficient. Project Persistence remains owner of project bytes and project-schema truth. +- Automatic rollback may use only a previously authenticated known-good installer whose version is older than the current installation and whose declared reader can open the current on-disk project schema. The decision core does not bypass project recovery or schema ownership. +- `release/updater-policy.json` remains fail-closed while organization-approved updater key/production endpoint authority is absent. No source code or test fixture is production authority. +- Traceability and claim boundaries live in `docs/traceability/updater-release-admission.md`, `docs/traceability/release-artifact-receipt.md`, `docs/traceability/updater-security-metadata.md`, `docs/traceability/updater-bounded-download.md`, `docs/traceability/updater-staging-path-identity.md`, `docs/traceability/updater-highest-seen-concurrency.md`, and `docs/traceability/updater-transport-policy.md`. + ## Product capability scope - BandScope is not only a shell around chord labels, stems, and ranges. @@ -96,10 +119,11 @@ Last updated: 2026-03-11 ## Harness decisions - The harness uses `npm` workspaces for JavaScript/TypeScript and `uv` for Python. -- The desktop app is scaffolded as `Tauri + Vite + React`, but initial verification keeps Rust packaging out of the default quickcheck path. +- The desktop app is scaffolded as `Tauri + Vite + React`. Full Tauri packaging remains outside the default quickcheck path, while security-critical Tauri-independent Rust bounded-context suites may be invoked from repository tests through a narrow validation boundary. - The desktop shell uses an explicit Tauri CSP that only allows self-hosted assets, inline styles, Tauri IPC, and loopback development traffic. - Mechanical gates focus on lint, typecheck, unit tests, coverage for Python, and documentation presence. - Python quality gates also require 100% docstring coverage via `package.json` script `check:python-docstrings`, enforced with Ruff rules `D100` through `D107` across tracked packages, modules, classes, nested classes, functions, methods (including `__init__`), `services/analysis-engine` tests, and repo-owned Python scripts. +- Distribution `distribution-core`, `distribution-runtime`, `distribution-transport`, `distribution-download`, and `distribution-state` Rust compilation denies warnings and missing public rustdoc; their standalone locked unit suites are invoked by the repository analysis test harness without adding Python production logic. - Mechanical gates also enforce security document presence, plan `Security Notes`, and basic forbidden-pattern checks. - Security context is part of architecture, not just implementation detail; docs and plans must record the trust boundary touched by risky changes. - Supply-chain controls are part of the bootstrap architecture, not a release-afterthought. diff --git a/apps/desktop/distribution-core/Cargo.lock b/apps/desktop/distribution-core/Cargo.lock new file mode 100644 index 000000000..f828c855e --- /dev/null +++ b/apps/desktop/distribution-core/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bandscope-distribution-core" +version = "0.1.0" diff --git a/apps/desktop/distribution-core/Cargo.toml b/apps/desktop/distribution-core/Cargo.toml new file mode 100644 index 000000000..4bad8e1c3 --- /dev/null +++ b/apps/desktop/distribution-core/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "bandscope-distribution-core" +version = "0.1.0" +edition = "2021" +description = "Pure Distribution/update anti-replay and rollback decision core for BandScope." +publish = false + +[workspace] + +[lints.rust] +unsafe_code = "forbid" +warnings = "deny" +missing_docs = "deny" diff --git a/apps/desktop/distribution-core/src/lib.rs b/apps/desktop/distribution-core/src/lib.rs new file mode 100644 index 000000000..8560e9368 --- /dev/null +++ b/apps/desktop/distribution-core/src/lib.rs @@ -0,0 +1,562 @@ +//! Pure Distribution/update security decisions for the BandScope desktop app. +//! +//! This crate deliberately has no networking, filesystem, Tauri, installer, or +//! signing capability. The caller must first authenticate updater metadata and +//! artifact signatures, then pass only validated release identity into this +//! decision core. Keeping the policy pure makes replay, rollback, target, and +//! project-schema decisions deterministic and testable on every platform. + +#![forbid(unsafe_code)] + +/// Maximum accepted updater target token length. +pub const MAX_TARGET_LENGTH: usize = 64; + +/// A canonical stable-channel release version. +/// +/// BandScope currently admits only numeric `MAJOR.MINOR.PATCH` releases in this +/// runtime security core. Prerelease/build metadata is rejected rather than +/// partially reimplementing SemVer ordering. A future beta channel must adopt +/// one canonical SemVer implementation under a separate release decision. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct StableVersion { + major: u64, + minor: u64, + patch: u64, +} + +impl StableVersion { + /// Parse an exact canonical stable `MAJOR.MINOR.PATCH` version. + pub fn parse(value: &str) -> Result { + let mut parts = value.split('.'); + let major = parse_numeric_component(parts.next().ok_or(UpdateRejection::InvalidVersion)?)?; + let minor = parse_numeric_component(parts.next().ok_or(UpdateRejection::InvalidVersion)?)?; + let patch = parse_numeric_component(parts.next().ok_or(UpdateRejection::InvalidVersion)?)?; + if parts.next().is_some() { + return Err(UpdateRejection::InvalidVersion); + } + Ok(Self { + major, + minor, + patch, + }) + } + + /// Return the three numeric components for diagnostics or persistence. + pub const fn components(self) -> (u64, u64, u64) { + (self.major, self.minor, self.patch) + } +} + +/// Exact release identity used for freshness and equivocation checks. +/// +/// `source_commit` and `artifact_sha256` are immutable evidence projected from +/// the Distribution release receipt. They are not a replacement for Tauri's +/// updater signature verification or GitHub release attestation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReleaseIdentity { + version: StableVersion, + source_commit: String, + artifact_sha256: String, +} + +impl ReleaseIdentity { + /// Construct a release identity from already-authenticated updater metadata. + pub fn new( + version: &str, + source_commit: &str, + artifact_sha256: &str, + ) -> Result { + let version = StableVersion::parse(version)?; + if !is_exact_lower_hex(source_commit, 40) { + return Err(UpdateRejection::InvalidSourceCommit); + } + if !is_exact_lower_hex(artifact_sha256, 64) { + return Err(UpdateRejection::InvalidArtifactDigest); + } + Ok(Self { + version, + source_commit: source_commit.to_owned(), + artifact_sha256: artifact_sha256.to_owned(), + }) + } + + /// Return the canonical release version. + pub const fn version(&self) -> StableVersion { + self.version + } + + /// Return the exact source commit carried by the release receipt. + pub fn source_commit(&self) -> &str { + &self.source_commit + } + + /// Return the exact updater bundle SHA-256 carried by the release receipt. + pub fn artifact_sha256(&self) -> &str { + &self.artifact_sha256 + } +} + +/// One authenticated update candidate for the current desktop target. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UpdateCandidate { + identity: ReleaseIdentity, + target: String, + minimum_supported_version: StableVersion, +} + +impl UpdateCandidate { + /// Construct a bounded candidate after signature and manifest verification. + pub fn new( + version: &str, + source_commit: &str, + artifact_sha256: &str, + target: &str, + minimum_supported_version: &str, + ) -> Result { + let identity = ReleaseIdentity::new(version, source_commit, artifact_sha256)?; + if !is_safe_target(target) { + return Err(UpdateRejection::InvalidTarget); + } + let minimum_supported_version = StableVersion::parse(minimum_supported_version)?; + if minimum_supported_version > identity.version { + return Err(UpdateRejection::MinimumExceedsCandidate); + } + Ok(Self { + identity, + target: target.to_owned(), + minimum_supported_version, + }) + } + + /// Return the exact release identity. + pub fn identity(&self) -> &ReleaseIdentity { + &self.identity + } + + /// Return the exact Tauri updater target key admitted for this candidate. + pub fn target(&self) -> &str { + &self.target + } + + /// Return the oldest installed version eligible for this automatic path. + pub const fn minimum_supported_version(&self) -> StableVersion { + self.minimum_supported_version + } +} + +/// Positive updater decisions returned by the pure policy core. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UpdateDecision { + /// Offer the forward update and persist this identity as highest-seen. + OfferAndRemember, + /// Offer a previously authenticated highest-seen release again. + OfferPreviouslySeen, + /// The candidate is exactly the currently installed version. + NoUpdate, +} + +/// Fail-closed reasons for update and rollback decisions. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UpdateRejection { + /// A version is not canonical stable `MAJOR.MINOR.PATCH`. + InvalidVersion, + /// The source commit is not exactly forty lowercase hexadecimal characters. + InvalidSourceCommit, + /// The updater artifact digest is not exactly sixty-four lowercase hex characters. + InvalidArtifactDigest, + /// The updater target token is empty, oversized, or contains unsafe characters. + InvalidTarget, + /// The release declares a minimum supported version newer than itself. + MinimumExceedsCandidate, + /// This installation is below the release's automatic-update compatibility floor. + ClientBelowMinimum, + /// The authenticated candidate targets another platform or architecture. + UnsupportedTarget, + /// The candidate release is older than the highest authenticated release seen locally. + Replay, + /// The same release version was observed with different immutable release identity. + Equivocation, + /// The candidate is older than the currently installed version. + Rollback, + /// A requested recovery target is not older than the current installation. + NotRollbackTarget, + /// The rollback target cannot read the current on-disk project schema. + IncompatibleProjectSchema, +} + +/// A previously trusted installer that may be considered for recovery. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RollbackTarget { + identity: ReleaseIdentity, + maximum_readable_project_schema: u32, +} + +impl RollbackTarget { + /// Construct last-known-good rollback metadata from trusted local evidence. + pub fn new( + version: &str, + source_commit: &str, + artifact_sha256: &str, + maximum_readable_project_schema: u32, + ) -> Result { + Ok(Self { + identity: ReleaseIdentity::new(version, source_commit, artifact_sha256)?, + maximum_readable_project_schema, + }) + } + + /// Return the rollback release identity. + pub fn identity(&self) -> &ReleaseIdentity { + &self.identity + } + + /// Return the newest project schema this rollback build can safely read. + pub const fn maximum_readable_project_schema(&self) -> u32 { + self.maximum_readable_project_schema + } +} + +/// A positive recovery decision after version and project-schema checks. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RollbackDecision { + /// The older known-good installer is compatible with current project data. + AllowKnownGood, +} + +/// Evaluate a signed/authenticated updater candidate without performing I/O. +/// +/// `highest_seen` must come from Distribution-owned durable state updated after +/// an updater response has passed signature/metadata admission, even when the +/// user defers installation. This prevents a later replay from becoming fresh +/// merely because the earlier update was not installed. +pub fn evaluate_candidate( + current_version: &str, + expected_target: &str, + candidate: &UpdateCandidate, + highest_seen: Option<&ReleaseIdentity>, +) -> Result { + let current_version = StableVersion::parse(current_version)?; + if candidate.target != expected_target { + return Err(UpdateRejection::UnsupportedTarget); + } + if current_version < candidate.minimum_supported_version { + return Err(UpdateRejection::ClientBelowMinimum); + } + + if let Some(highest_seen) = highest_seen { + if candidate.identity.version < highest_seen.version { + return Err(UpdateRejection::Replay); + } + if candidate.identity.version == highest_seen.version && candidate.identity != *highest_seen { + return Err(UpdateRejection::Equivocation); + } + } + + if candidate.identity.version < current_version { + return Err(UpdateRejection::Rollback); + } + if candidate.identity.version == current_version { + return Ok(UpdateDecision::NoUpdate); + } + if highest_seen.is_some_and(|seen| candidate.identity == *seen) { + return Ok(UpdateDecision::OfferPreviouslySeen); + } + Ok(UpdateDecision::OfferAndRemember) +} + +/// Evaluate whether an older known-good installer may be used for recovery. +/// +/// This function does not execute the rollback. Distribution must retain and +/// authenticate the installer separately, and Project Persistence remains the +/// owner of project bytes. The only shared input here is the current persisted +/// project schema number needed to prevent an unreadable automatic downgrade. +pub fn evaluate_rollback( + current_version: &str, + current_project_schema: u32, + target: &RollbackTarget, +) -> Result { + let current_version = StableVersion::parse(current_version)?; + if target.identity.version >= current_version { + return Err(UpdateRejection::NotRollbackTarget); + } + if target.maximum_readable_project_schema < current_project_schema { + return Err(UpdateRejection::IncompatibleProjectSchema); + } + Ok(RollbackDecision::AllowKnownGood) +} + +fn parse_numeric_component(value: &str) -> Result { + if value.is_empty() + || (value.len() > 1 && value.starts_with('0')) + || !value.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(UpdateRejection::InvalidVersion); + } + value + .parse::() + .map_err(|_| UpdateRejection::InvalidVersion) +} + +fn is_exact_lower_hex(value: &str, expected_length: usize) -> bool { + value.len() == expected_length + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} + +fn is_safe_target(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_TARGET_LENGTH + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.') + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SOURCE_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const SOURCE_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const DIGEST_A: &str = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const DIGEST_B: &str = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const TARGET: &str = "windows-x86_64"; + + fn candidate(version: &str, minimum: &str) -> UpdateCandidate { + UpdateCandidate::new(version, SOURCE_A, DIGEST_A, TARGET, minimum) + .expect("test candidate should be valid") + } + + fn identity(version: &str) -> ReleaseIdentity { + ReleaseIdentity::new(version, SOURCE_A, DIGEST_A) + .expect("test identity should be valid") + } + + #[test] + fn stable_version_accepts_only_canonical_numeric_triplets() { + let zero = StableVersion::parse("0.0.0").expect("zero version should parse"); + assert_eq!(zero.components(), (0, 0, 0)); + let release = StableVersion::parse("12.34.56").expect("release should parse"); + assert_eq!(release.components(), (12, 34, 56)); + let maximum = StableVersion::parse("18446744073709551615.0.1") + .expect("u64 maximum should parse"); + assert_eq!(maximum.components().0, u64::MAX); + + for invalid in [ + "", + "1", + "1.2", + "1.2.3.4", + "01.2.3", + "1.02.3", + "1.2.03", + "1.2.-3", + "1.2.3-alpha", + "1.2.3+build", + "v1.2.3", + " 1.2.3", + "1.2.3 ", + "18446744073709551616.0.0", + ] { + assert_eq!( + StableVersion::parse(invalid), + Err(UpdateRejection::InvalidVersion), + "{invalid} must fail closed" + ); + } + } + + #[test] + fn release_identity_requires_exact_lowercase_immutable_ids() { + let accepted = ReleaseIdentity::new("1.2.3", SOURCE_A, DIGEST_A) + .expect("canonical identity should be accepted"); + assert_eq!(accepted.version().components(), (1, 2, 3)); + assert_eq!(accepted.source_commit(), SOURCE_A); + assert_eq!(accepted.artifact_sha256(), DIGEST_A); + + assert_eq!( + ReleaseIdentity::new("1.2.3", "abc", DIGEST_A), + Err(UpdateRejection::InvalidSourceCommit) + ); + assert_eq!( + ReleaseIdentity::new( + "1.2.3", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + DIGEST_A, + ), + Err(UpdateRejection::InvalidSourceCommit) + ); + assert_eq!( + ReleaseIdentity::new("1.2.3", SOURCE_A, "abc"), + Err(UpdateRejection::InvalidArtifactDigest) + ); + assert_eq!( + ReleaseIdentity::new( + "1.2.3", + SOURCE_A, + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ), + Err(UpdateRejection::InvalidArtifactDigest) + ); + } + + #[test] + fn candidate_admission_bounds_target_and_version_floor() { + let accepted = candidate("2.0.0", "1.0.0"); + assert_eq!(accepted.identity().version().components(), (2, 0, 0)); + assert_eq!(accepted.target(), TARGET); + assert_eq!(accepted.minimum_supported_version().components(), (1, 0, 0)); + + for invalid_target in ["", "windows/x86_64", "windows x86_64"] { + assert_eq!( + UpdateCandidate::new("2.0.0", SOURCE_A, DIGEST_A, invalid_target, "1.0.0"), + Err(UpdateRejection::InvalidTarget) + ); + } + let oversized_target = "a".repeat(MAX_TARGET_LENGTH + 1); + assert_eq!( + UpdateCandidate::new("2.0.0", SOURCE_A, DIGEST_A, &oversized_target, "1.0.0"), + Err(UpdateRejection::InvalidTarget) + ); + assert_eq!( + UpdateCandidate::new("1.0.0", SOURCE_A, DIGEST_A, TARGET, "2.0.0"), + Err(UpdateRejection::MinimumExceedsCandidate) + ); + } + + #[test] + fn forward_candidate_is_offered_and_remembered() { + assert_eq!( + evaluate_candidate("1.0.0", TARGET, &candidate("1.1.0", "1.0.0"), None), + Ok(UpdateDecision::OfferAndRemember) + ); + } + + #[test] + fn exact_installed_candidate_is_not_reinstalled() { + assert_eq!( + evaluate_candidate("1.1.0", TARGET, &candidate("1.1.0", "1.0.0"), None), + Ok(UpdateDecision::NoUpdate) + ); + } + + #[test] + fn lower_candidate_is_rejected_as_rollback() { + assert_eq!( + evaluate_candidate("2.0.0", TARGET, &candidate("1.9.9", "1.0.0"), None), + Err(UpdateRejection::Rollback) + ); + } + + #[test] + fn highest_seen_version_rejects_replay_before_install() { + let highest = identity("2.0.0"); + assert_eq!( + evaluate_candidate( + "1.0.0", + TARGET, + &candidate("1.5.0", "1.0.0"), + Some(&highest), + ), + Err(UpdateRejection::Replay) + ); + } + + #[test] + fn same_version_with_different_release_identity_is_equivocation() { + let highest = identity("2.0.0"); + let conflicting = UpdateCandidate::new("2.0.0", SOURCE_B, DIGEST_B, TARGET, "1.0.0") + .expect("conflicting test candidate should still be structurally valid"); + assert_eq!( + evaluate_candidate("1.0.0", TARGET, &conflicting, Some(&highest)), + Err(UpdateRejection::Equivocation) + ); + } + + #[test] + fn exact_highest_seen_forward_release_can_be_reoffered() { + let highest = identity("2.0.0"); + assert_eq!( + evaluate_candidate( + "1.0.0", + TARGET, + &candidate("2.0.0", "1.0.0"), + Some(&highest), + ), + Ok(UpdateDecision::OfferPreviouslySeen) + ); + } + + #[test] + fn automatic_path_rejects_clients_below_release_floor() { + assert_eq!( + evaluate_candidate("0.9.0", TARGET, &candidate("2.0.0", "1.0.0"), None), + Err(UpdateRejection::ClientBelowMinimum) + ); + } + + #[test] + fn candidate_must_match_current_platform_target() { + assert_eq!( + evaluate_candidate( + "1.0.0", + "darwin-aarch64", + &candidate("2.0.0", "1.0.0"), + None, + ), + Err(UpdateRejection::UnsupportedTarget) + ); + } + + #[test] + fn malformed_current_version_fails_closed() { + assert_eq!( + evaluate_candidate("v1.0.0", TARGET, &candidate("2.0.0", "1.0.0"), None), + Err(UpdateRejection::InvalidVersion) + ); + } + + #[test] + fn compatible_older_known_good_installer_can_be_used_for_recovery() { + let target = RollbackTarget::new("1.5.0", SOURCE_A, DIGEST_A, 7) + .expect("rollback fixture should be valid"); + assert_eq!(target.identity().version().components(), (1, 5, 0)); + assert_eq!(target.maximum_readable_project_schema(), 7); + assert_eq!( + evaluate_rollback("2.0.0", 7, &target), + Ok(RollbackDecision::AllowKnownGood) + ); + } + + #[test] + fn rollback_rejects_project_schema_newer_than_target_reader() { + let target = RollbackTarget::new("1.5.0", SOURCE_A, DIGEST_A, 6) + .expect("rollback fixture should be valid"); + assert_eq!( + evaluate_rollback("2.0.0", 7, &target), + Err(UpdateRejection::IncompatibleProjectSchema) + ); + } + + #[test] + fn rollback_target_must_be_strictly_older() { + let same = RollbackTarget::new("2.0.0", SOURCE_A, DIGEST_A, 7) + .expect("rollback fixture should be valid"); + let newer = RollbackTarget::new("2.1.0", SOURCE_A, DIGEST_A, 7) + .expect("rollback fixture should be valid"); + assert_eq!( + evaluate_rollback("2.0.0", 7, &same), + Err(UpdateRejection::NotRollbackTarget) + ); + assert_eq!( + evaluate_rollback("2.0.0", 7, &newer), + Err(UpdateRejection::NotRollbackTarget) + ); + assert_eq!( + evaluate_rollback("2.0", 7, &same), + Err(UpdateRejection::InvalidVersion) + ); + } +} diff --git a/apps/desktop/distribution-download/Cargo.lock b/apps/desktop/distribution-download/Cargo.lock new file mode 100644 index 000000000..1bf273fcd --- /dev/null +++ b/apps/desktop/distribution-download/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bandscope-distribution-download" +version = "0.1.0" diff --git a/apps/desktop/distribution-download/Cargo.toml b/apps/desktop/distribution-download/Cargo.toml new file mode 100644 index 000000000..25d455d84 --- /dev/null +++ b/apps/desktop/distribution-download/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "bandscope-distribution-download" +version = "0.1.0" +edition = "2021" +description = "Bounded streaming updater-artifact admission for BandScope Distribution." +publish = false + +[workspace] + +[lints.rust] +unsafe_code = "forbid" +warnings = "deny" +missing_docs = "deny" diff --git a/apps/desktop/distribution-download/src/lib.rs b/apps/desktop/distribution-download/src/lib.rs new file mode 100644 index 000000000..6cbbbb878 --- /dev/null +++ b/apps/desktop/distribution-download/src/lib.rs @@ -0,0 +1,675 @@ +//! Resource-bounded streaming admission for BandScope updater artifacts. +//! +//! The current Tauri updater returns verified artifacts as an in-memory byte +//! vector. BandScope's commercial Distribution boundary needs an independent +//! streaming primitive and an exclusive app-owned staging sink before it can +//! claim bounded hostile-response handling. This crate owns byte-count and +//! temporary-file admission only. It does not perform HTTP, metadata +//! authentication, signature or digest verification, installation, rollback, +//! or project persistence. + +#![forbid(unsafe_code)] + +use std::fs::{self, File, OpenOptions, TryLockError}; +use std::io::{ErrorKind, Read, Write}; +use std::path::{Path, PathBuf}; + +/// Hard ceiling for one updater artifact accepted by the Distribution boundary. +pub const MAX_UPDATER_ARTIFACT_BYTES: u64 = 2 * 1024 * 1024 * 1024; +/// Largest single response chunk the adapter may hand to this boundary. +pub const MAX_DOWNLOAD_CHUNK_BYTES: usize = 1024 * 1024; +/// Largest product-owned staging filename accepted by this boundary. +pub const MAX_ARTIFACT_NAME_BYTES: usize = 180; + +const STAGING_LEASE_FILE_NAME: &str = ".bandscope-staging.lock"; + +/// Fail-closed reasons for bounded updater-artifact admission. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DownloadAdmissionError { + /// The expected artifact length is zero or exceeds the product ceiling. + InvalidExpectedSize, + /// A response `Content-Length`, when present, disagrees with authenticated metadata. + ContentLengthMismatch, + /// One caller-provided response chunk exceeds the bounded adapter contract. + ChunkTooLarge, + /// Accepting a chunk would exceed the authenticated artifact length. + ExceedsExpectedSize, + /// The destination sink failed while accepting artifact bytes. + SinkWriteFailed(ErrorKind), + /// A previous admission or sink failure poisoned this download attempt. + Poisoned, + /// The response ended before the authenticated artifact length was reached. + Incomplete, +} + +/// Fail-closed reasons for updater staging-file lifecycle operations. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StagingArtifactError { + /// The artifact name is not a bounded portable basename. + InvalidArtifactName, + /// The supplied staging directory cannot be inspected. + StagingDirectoryUnavailable(ErrorKind), + /// The staging root is not a direct, non-symlink directory. + InvalidStagingDirectory, + /// A live staging attempt already owns the scratch namespace lease. + ConcurrentAttempt, + /// A non-regular destination exists or another writer won the exclusive create race. + DestinationExists, + /// Exclusive staging-file creation, lease acquisition, or stale-regular cleanup failed. + CreateFailed(ErrorKind), + /// Flushing userspace buffers failed before sealing. + FlushFailed(ErrorKind), + /// Synchronizing staged bytes to the operating system failed. + SyncFailed(ErrorKind), + /// Descriptor-bound metadata could not be read after synchronization. + MetadataFailed(ErrorKind), + /// The staged descriptor is no longer a regular file. + NonRegularArtifact, + /// Descriptor size disagrees with the exact download receipt. + SizeMismatch, +} + +/// Byte-count evidence emitted only after an exactly sized stream completes. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct DownloadReceipt { + bytes_written: u64, +} + +impl DownloadReceipt { + /// Return the exact number of bytes admitted to the sink. + pub const fn bytes_written(self) -> u64 { + self.bytes_written + } +} + +/// Stateful byte-admission guard for one updater artifact response. +/// +/// The guard rejects overrun before writing the offending chunk. Any sink +/// failure or hostile overrun poisons the attempt so later chunks cannot turn a +/// partially failed response into a successful receipt. +#[derive(Debug)] +pub struct ArtifactDownloadAdmission { + expected_size_bytes: u64, + received_size_bytes: u64, + poisoned: bool, +} + +impl ArtifactDownloadAdmission { + /// Start one bounded download from authenticated expected size evidence. + /// + /// `response_content_length` is advisory transport metadata. When the HTTP + /// stack supplies it, it must match the authenticated expected size before + /// body streaming starts. `None` remains acceptable for chunked transfer; + /// cumulative admission still enforces the exact authenticated byte count. + pub fn new( + expected_size_bytes: u64, + response_content_length: Option, + ) -> Result { + if expected_size_bytes == 0 || expected_size_bytes > MAX_UPDATER_ARTIFACT_BYTES { + return Err(DownloadAdmissionError::InvalidExpectedSize); + } + if response_content_length.is_some_and(|length| length != expected_size_bytes) { + return Err(DownloadAdmissionError::ContentLengthMismatch); + } + Ok(Self { + expected_size_bytes, + received_size_bytes: 0, + poisoned: false, + }) + } + + /// Admit one already-bounded response chunk into the supplied sink. + /// + /// This method never allocates a copy of `chunk`. The network adapter must + /// itself stream bounded chunks rather than buffering the full response + /// before this boundary is called. + pub fn write_chunk( + &mut self, + sink: &mut W, + chunk: &[u8], + ) -> Result<(), DownloadAdmissionError> { + if self.poisoned { + return Err(DownloadAdmissionError::Poisoned); + } + if chunk.len() > MAX_DOWNLOAD_CHUNK_BYTES { + self.poisoned = true; + return Err(DownloadAdmissionError::ChunkTooLarge); + } + let chunk_size = u64::try_from(chunk.len()).map_err(|_| { + self.poisoned = true; + DownloadAdmissionError::ChunkTooLarge + })?; + let next_size = self + .received_size_bytes + .checked_add(chunk_size) + .ok_or_else(|| { + self.poisoned = true; + DownloadAdmissionError::ExceedsExpectedSize + })?; + if next_size > self.expected_size_bytes { + self.poisoned = true; + return Err(DownloadAdmissionError::ExceedsExpectedSize); + } + if let Err(error) = sink.write_all(chunk) { + self.poisoned = true; + return Err(DownloadAdmissionError::SinkWriteFailed(error.kind())); + } + self.received_size_bytes = next_size; + Ok(()) + } + + /// Return bytes handed successfully to the current sink. + pub const fn received_size_bytes(&self) -> u64 { + self.received_size_bytes + } + + /// Finish the response only when exactly the authenticated size was written. + pub fn finish(self) -> Result { + if self.poisoned { + return Err(DownloadAdmissionError::Poisoned); + } + if self.received_size_bytes != self.expected_size_bytes { + return Err(DownloadAdmissionError::Incomplete); + } + Ok(DownloadReceipt { + bytes_written: self.received_size_bytes, + }) + } +} + +/// Exclusive temporary artifact owned by the Distribution staging directory. +/// +/// Creation accepts one portable basename under an already-existing app-owned +/// non-symlink directory. A process-scoped exclusive lease is acquired before +/// any pre-existing regular artifact can be classified as stale. This directory +/// is an unverified scratch namespace: a regular child may be reclaimed only +/// while that lease is held, so a second cooperating process cannot unlink a +/// live attempt and mistake it for crash residue. Symlinks and other non-regular +/// children are never reclaimed. A later verified artifact owner must move +/// trusted bytes out of this staging namespace before retaining them across +/// launches. Unix removes a descriptor-owned pathname on drop after identity +/// confirmation. Windows deliberately defers pathname reclamation to the next +/// leased staging attempt because stable Rust does not expose an equivalent +/// by-handle file identity suitable for proving that a remembered pathname still +/// denotes the owned object. Callers cannot write the descriptor directly; +/// response bytes must pass through `ArtifactDownloadAdmission` via +/// `admit_chunk`. +#[derive(Debug)] +pub struct StagedArtifactFile { + file: Option, + staging_lease: Option, + path: PathBuf, + retain_on_drop: bool, +} + +impl StagedArtifactFile { + /// Create one new staging artifact, reclaiming only a stale regular child. + pub fn create( + staging_directory: &Path, + artifact_name: &str, + ) -> Result { + if !is_portable_artifact_name(artifact_name) { + return Err(StagingArtifactError::InvalidArtifactName); + } + let directory_metadata = fs::symlink_metadata(staging_directory) + .map_err(|error| StagingArtifactError::StagingDirectoryUnavailable(error.kind()))?; + if directory_metadata.file_type().is_symlink() || !directory_metadata.is_dir() { + return Err(StagingArtifactError::InvalidStagingDirectory); + } + + let staging_lease = acquire_staging_lease(staging_directory)?; + let path = staging_directory.join(artifact_name); + match fs::symlink_metadata(&path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(StagingArtifactError::DestinationExists); + } + fs::remove_file(&path) + .map_err(|error| StagingArtifactError::CreateFailed(error.kind()))?; + } + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => return Err(StagingArtifactError::CreateFailed(error.kind())), + } + + let file = match OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(&path) + { + Ok(file) => file, + Err(error) if error.kind() == ErrorKind::AlreadyExists => { + return Err(StagingArtifactError::DestinationExists); + } + Err(error) => return Err(StagingArtifactError::CreateFailed(error.kind())), + }; + + Ok(Self { + file: Some(file), + staging_lease: Some(staging_lease), + path, + retain_on_drop: false, + }) + } + + /// Return the direct child path reserved for this staging attempt. + pub fn path(&self) -> &Path { + &self.path + } + + /// Admit one response chunk through the byte-count guard into this file. + pub fn admit_chunk( + &mut self, + admission: &mut ArtifactDownloadAdmission, + chunk: &[u8], + ) -> Result<(), DownloadAdmissionError> { + let file = self + .file + .as_mut() + .expect("staged artifact descriptor remains present before seal"); + admission.write_chunk(file, chunk) + } + + /// Flush, synchronize, and descriptor-check an exactly downloaded artifact. + /// + /// A successful seal transfers cleanup responsibility and the staging lease + /// to a still-open `SealedArtifactFile` so later digest/signature verification + /// remains bound to the exact staged bytes rather than reopening an + /// attacker-selected path. Sealing is not trust promotion: the sealed file + /// remains cleanup-on-drop until a later verified-artifact boundary exists. + pub fn seal( + mut self, + receipt: DownloadReceipt, + ) -> Result { + let file = self + .file + .as_mut() + .expect("staged artifact descriptor remains present before seal"); + file.flush() + .map_err(|error| StagingArtifactError::FlushFailed(error.kind()))?; + file.sync_all() + .map_err(|error| StagingArtifactError::SyncFailed(error.kind()))?; + let metadata = file + .metadata() + .map_err(|error| StagingArtifactError::MetadataFailed(error.kind()))?; + if !metadata.file_type().is_file() { + return Err(StagingArtifactError::NonRegularArtifact); + } + if metadata.len() != receipt.bytes_written() { + return Err(StagingArtifactError::SizeMismatch); + } + + self.retain_on_drop = true; + let sealed_file = self + .file + .take() + .expect("staged artifact descriptor remains present after validation"); + let staging_lease = self + .staging_lease + .take() + .expect("staging lease remains held through seal"); + Ok(SealedArtifactFile { + file: Some(sealed_file), + staging_lease: Some(staging_lease), + path: self.path.clone(), + bytes_written: receipt.bytes_written(), + }) + } +} + +impl Drop for StagedArtifactFile { + fn drop(&mut self) { + if self.retain_on_drop { + return; + } + if let Some(file) = self.file.take() { + cleanup_owned_staging_path(file, &self.path); + } + let _ = self.staging_lease.take(); + } +} + +/// Synchronized but still unverified staging artifact. +/// +/// The descriptor and staging lease stay open for later digest/signature +/// verification. On Unix, dropping this value removes the staging pathname only +/// when it still resolves to the descriptor-owned file; a replacement pathname +/// is left untouched. On Windows, drop closes the descriptor but intentionally +/// leaves the pathname in the app-owned scratch directory because stable Rust +/// does not expose the by-handle identity needed to prove that path ownership. +/// A later staging attempt reclaims a stale regular child only while holding the +/// same process-shared lease. The lease is released after descriptor cleanup. A +/// later trust-promotion type, not this byte-count boundary, must explicitly +/// retain verified bytes. +#[derive(Debug)] +pub struct SealedArtifactFile { + file: Option, + staging_lease: Option, + path: PathBuf, + bytes_written: u64, +} + +/// Read-only view over the exact still-open sealed artifact descriptor. +/// +/// Reads are positional and begin at byte zero without reopening the staging +/// path. The stream is capped at the exact byte count admitted before sealing, +/// so post-seal file growth cannot expand verifier memory or alter the byte +/// range considered by downstream digest/signature checks. The wrapper +/// intentionally implements `Read` only: callers cannot recover the underlying +/// write-capable staging descriptor. +#[derive(Debug)] +pub struct SealedArtifactReader<'a> { + file: &'a File, + offset: u64, + remaining_bytes: u64, +} + +impl Read for SealedArtifactReader<'_> { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + if buffer.is_empty() || self.remaining_bytes == 0 { + return Ok(0); + } + let maximum_read = usize::try_from(self.remaining_bytes) + .unwrap_or(usize::MAX) + .min(buffer.len()); + let read = descriptor_read_at(self.file, &mut buffer[..maximum_read], self.offset)?; + if read == 0 { + return Err(std::io::Error::new( + ErrorKind::UnexpectedEof, + "sealed artifact truncated below admitted byte boundary", + )); + } + let read_u64 = u64::try_from(read).map_err(|_| { + std::io::Error::new(ErrorKind::InvalidData, "sealed read length overflow") + })?; + self.offset = self.offset.checked_add(read_u64).ok_or_else(|| { + std::io::Error::new(ErrorKind::InvalidData, "sealed reader offset overflow") + })?; + self.remaining_bytes = self.remaining_bytes.checked_sub(read_u64).ok_or_else(|| { + std::io::Error::new(ErrorKind::InvalidData, "sealed reader boundary underflow") + })?; + Ok(read) + } +} + +impl SealedArtifactFile { + /// Return the synchronized staging path held for identity verification. + pub fn path(&self) -> &Path { + &self.path + } + + /// Return the exact admitted byte count bound to this descriptor. + pub const fn bytes_written(&self) -> u64 { + self.bytes_written + } + + /// Open a read-only positional stream over the exact sealed descriptor. + /// + /// The stream starts at byte zero, stops at the exact admitted byte count, + /// and never reopens the staging path. This preserves descriptor binding, + /// prevents post-seal growth from widening verifier input, and withholds the + /// underlying write-capable `File` from downstream digest/signature code. + pub fn reader(&self) -> SealedArtifactReader<'_> { + SealedArtifactReader { + file: self + .file + .as_ref() + .expect("sealed artifact descriptor remains present before drop"), + offset: 0, + remaining_bytes: self.bytes_written, + } + } +} + +impl Drop for SealedArtifactFile { + fn drop(&mut self) { + if let Some(file) = self.file.take() { + cleanup_owned_staging_path(file, &self.path); + } + let _ = self.staging_lease.take(); + } +} + +#[cfg(unix)] +fn cleanup_owned_staging_path(file: File, path: &Path) { + use std::os::unix::fs::MetadataExt; + + let descriptor_identity = file.metadata().ok().map(|metadata| (metadata.dev(), metadata.ino())); + if let (Some((descriptor_dev, descriptor_ino)), Ok(path_metadata)) = + (descriptor_identity, fs::symlink_metadata(path)) + { + if !path_metadata.file_type().is_symlink() + && path_metadata.is_file() + && path_metadata.dev() == descriptor_dev + && path_metadata.ino() == descriptor_ino + { + let _ = fs::remove_file(path); + } + } + drop(file); +} + +#[cfg(not(unix))] +fn cleanup_owned_staging_path(file: File, _path: &Path) { + // Stable Rust does not expose a portable by-handle file identity here. + // Closing without pathname deletion avoids deleting a replacement object; + // the next leased staging attempt reclaims stale regular scratch files. + drop(file); +} + +fn acquire_staging_lease(staging_directory: &Path) -> Result { + let lease_path = staging_directory.join(STAGING_LEASE_FILE_NAME); + let lease_file = match OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(&lease_path) + { + Ok(file) => file, + Err(error) if error.kind() == ErrorKind::AlreadyExists => { + let metadata = fs::symlink_metadata(&lease_path) + .map_err(|error| StagingArtifactError::CreateFailed(error.kind()))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(StagingArtifactError::DestinationExists); + } + OpenOptions::new() + .read(true) + .write(true) + .open(&lease_path) + .map_err(|error| StagingArtifactError::CreateFailed(error.kind()))? + } + Err(error) => return Err(StagingArtifactError::CreateFailed(error.kind())), + }; + + match lease_file.try_lock() { + Ok(()) => Ok(lease_file), + Err(TryLockError::WouldBlock) => Err(StagingArtifactError::ConcurrentAttempt), + Err(TryLockError::Error(error)) => Err(StagingArtifactError::CreateFailed(error.kind())), + } +} + +#[cfg(unix)] +fn descriptor_read_at(file: &File, buffer: &mut [u8], offset: u64) -> std::io::Result { + use std::os::unix::fs::FileExt; + FileExt::read_at(file, buffer, offset) +} + +#[cfg(windows)] +fn descriptor_read_at(file: &File, buffer: &mut [u8], offset: u64) -> std::io::Result { + use std::os::windows::fs::FileExt; + FileExt::seek_read(file, buffer, offset) +} + +#[cfg(not(any(unix, windows)))] +fn descriptor_read_at(_file: &File, _buffer: &mut [u8], _offset: u64) -> std::io::Result { + Err(std::io::Error::new( + ErrorKind::Unsupported, + "sealed descriptor reads are supported only on desktop targets", + )) +} + +fn is_portable_artifact_name(name: &str) -> bool { + if name.is_empty() || name.len() > MAX_ARTIFACT_NAME_BYTES || name.starts_with('.') { + return false; + } + if !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + { + return false; + } + if name.ends_with('.') || name.ends_with(' ') { + return false; + } + + let stem = name.split('.').next().unwrap_or_default().to_ascii_uppercase(); + !is_windows_reserved_stem(&stem) +} + +fn is_windows_reserved_stem(stem: &str) -> bool { + if matches!(stem, "CON" | "PRN" | "AUX" | "NUL") { + return true; + } + let bytes = stem.as_bytes(); + bytes.len() == 4 + && matches!(&bytes[..3], b"COM" | b"LPT") + && matches!(bytes[3], b'1'..=b'9') +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io; + + #[test] + fn exact_chunked_response_emits_receipt() { + let mut sink = Vec::new(); + let mut admission = ArtifactDownloadAdmission::new(6, Some(6)).expect("valid size"); + + admission.write_chunk(&mut sink, b"abc").expect("first chunk"); + admission.write_chunk(&mut sink, b"def").expect("second chunk"); + let receipt = admission.finish().expect("exact response should finish"); + + assert_eq!(sink, b"abcdef"); + assert_eq!(receipt.bytes_written(), 6); + } + + #[test] + fn missing_content_length_still_uses_exact_cumulative_bound() { + let mut sink = Vec::new(); + let mut admission = ArtifactDownloadAdmission::new(4, None).expect("chunked response"); + + admission.write_chunk(&mut sink, b"ab").expect("bounded chunk"); + admission.write_chunk(&mut sink, b"cd").expect("bounded chunk"); + + assert_eq!(admission.finish().expect("exact chunked response").bytes_written(), 4); + assert_eq!(sink, b"abcd"); + } + + #[test] + fn content_length_mismatch_fails_before_body_admission() { + assert_eq!( + ArtifactDownloadAdmission::new(8, Some(7)).unwrap_err(), + DownloadAdmissionError::ContentLengthMismatch + ); + } + + #[test] + fn overrun_is_rejected_before_offending_chunk_reaches_sink() { + let mut sink = Vec::new(); + let mut admission = ArtifactDownloadAdmission::new(4, None).expect("valid size"); + admission.write_chunk(&mut sink, b"abc").expect("first chunk"); + + assert_eq!( + admission.write_chunk(&mut sink, b"de"), + Err(DownloadAdmissionError::ExceedsExpectedSize) + ); + assert_eq!(sink, b"abc"); + assert_eq!( + admission.write_chunk(&mut sink, b"d"), + Err(DownloadAdmissionError::Poisoned) + ); + } + + #[test] + fn oversized_single_chunk_poisoning_is_fail_closed() { + let mut sink = Vec::new(); + let mut admission = ArtifactDownloadAdmission::new( + (MAX_DOWNLOAD_CHUNK_BYTES as u64) + 1, + None, + ) + .expect("artifact remains below global ceiling"); + let chunk = vec![0_u8; MAX_DOWNLOAD_CHUNK_BYTES + 1]; + + assert_eq!( + admission.write_chunk(&mut sink, &chunk), + Err(DownloadAdmissionError::ChunkTooLarge) + ); + assert!(sink.is_empty()); + assert_eq!(admission.finish(), Err(DownloadAdmissionError::Poisoned)); + } + + #[test] + fn truncated_response_never_emits_success_receipt() { + let mut sink = Vec::new(); + let mut admission = ArtifactDownloadAdmission::new(5, Some(5)).expect("valid size"); + admission.write_chunk(&mut sink, b"four").expect("partial body"); + + assert_eq!(admission.received_size_bytes(), 4); + assert_eq!(admission.finish(), Err(DownloadAdmissionError::Incomplete)); + } + + struct PartialThenFailWriter { + accepted: usize, + } + + impl Write for PartialThenFailWriter { + fn write(&mut self, buffer: &[u8]) -> io::Result { + if self.accepted == 0 { + let count = buffer.len().min(1); + self.accepted += count; + return Ok(count); + } + Err(io::Error::new(ErrorKind::WriteZero, "synthetic sink failure")) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + #[test] + fn partial_sink_failure_poisoning_prevents_false_completion() { + let mut sink = PartialThenFailWriter { accepted: 0 }; + let mut admission = ArtifactDownloadAdmission::new(3, Some(3)).expect("valid size"); + + assert_eq!( + admission.write_chunk(&mut sink, b"abc"), + Err(DownloadAdmissionError::SinkWriteFailed(ErrorKind::WriteZero)) + ); + assert_eq!(admission.received_size_bytes(), 0); + assert_eq!(admission.finish(), Err(DownloadAdmissionError::Poisoned)); + } + + #[test] + fn zero_and_over_ceiling_expected_sizes_are_rejected() { + assert_eq!( + ArtifactDownloadAdmission::new(0, None).unwrap_err(), + DownloadAdmissionError::InvalidExpectedSize + ); + assert_eq!( + ArtifactDownloadAdmission::new(MAX_UPDATER_ARTIFACT_BYTES + 1, None).unwrap_err(), + DownloadAdmissionError::InvalidExpectedSize + ); + } + + #[test] + fn portable_name_policy_rejects_windows_devices_and_hidden_paths() { + assert!(is_portable_artifact_name("bandscope-0.1.3.tar.gz")); + assert!(!is_portable_artifact_name("CON")); + assert!(!is_portable_artifact_name("com1.exe")); + assert!(!is_portable_artifact_name(".hidden")); + assert!(!is_portable_artifact_name("../escape")); + assert!(!is_portable_artifact_name("name%2fescape")); + } +} diff --git a/apps/desktop/distribution-download/tests/path_replacement_cleanup.rs b/apps/desktop/distribution-download/tests/path_replacement_cleanup.rs new file mode 100644 index 000000000..c01b0be2f --- /dev/null +++ b/apps/desktop/distribution-download/tests/path_replacement_cleanup.rs @@ -0,0 +1,103 @@ +//! Integration tests for staging-path replacement and deferred cleanup safety. + +#![cfg(any(unix, windows))] + +use bandscope_distribution_download::{ArtifactDownloadAdmission, StagedArtifactFile}; +use std::fs; +use std::io::ErrorKind; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn scratch_dir(label: &str) -> std::path::PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock after unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "bandscope-distribution-download-path-replacement-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir(&path).expect("create isolated staging directory"); + path +} + +fn remove_file_if_present(path: &Path) { + match fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => panic!("remove fixture {}: {error}", path.display()), + } +} + +fn cleanup(directory: &Path, paths: &[&Path]) { + for path in paths { + remove_file_if_present(path); + } + remove_file_if_present(&directory.join(".bandscope-staging.lock")); + fs::remove_dir(directory).expect("remove staging directory"); +} + +#[test] +fn cancelled_attempt_does_not_delete_replacement_path() { + let directory = scratch_dir("cancelled"); + let staged = StagedArtifactFile::create(&directory, "update.bin").expect("stage file"); + let original_path = staged.path().to_path_buf(); + let moved_original = directory.join("moved-original.bin"); + + fs::rename(&original_path, &moved_original).expect("move owned staging file away"); + fs::write(&original_path, b"replacement-must-survive").expect("create unrelated replacement"); + + drop(staged); + + assert_eq!( + fs::read(&original_path).expect("replacement pathname must not be deleted"), + b"replacement-must-survive" + ); + cleanup(&directory, &[&original_path, &moved_original]); +} + +#[test] +fn sealed_attempt_does_not_delete_replacement_path() { + let directory = scratch_dir("sealed"); + let mut staged = StagedArtifactFile::create(&directory, "update.bin").expect("stage file"); + let mut admission = ArtifactDownloadAdmission::new(4, Some(4)).expect("admission"); + staged + .admit_chunk(&mut admission, b"data") + .expect("write admitted bytes"); + let receipt = admission.finish().expect("exact receipt"); + let sealed = staged.seal(receipt).expect("seal exact artifact"); + let original_path = sealed.path().to_path_buf(); + let moved_original = directory.join("moved-sealed-original.bin"); + + fs::rename(&original_path, &moved_original).expect("move sealed staging file away"); + fs::write(&original_path, b"replacement-must-survive").expect("create unrelated replacement"); + + drop(sealed); + + assert_eq!( + fs::read(&original_path).expect("replacement pathname must not be deleted"), + b"replacement-must-survive" + ); + cleanup(&directory, &[&original_path, &moved_original]); +} + +#[cfg(windows)] +#[test] +fn deferred_windows_stale_file_is_reclaimed_by_next_leased_attempt() { + let directory = scratch_dir("windows-deferred-reclaim"); + let staged = StagedArtifactFile::create(&directory, "update.bin").expect("first stage file"); + let path = staged.path().to_path_buf(); + + drop(staged); + assert!( + path.is_file(), + "Windows drop leaves scratch bytes when pathname ownership cannot be proven" + ); + + let next = StagedArtifactFile::create(&directory, "update.bin") + .expect("next leased attempt reclaims stale regular scratch"); + assert_eq!(next.path(), path); + drop(next); + + cleanup(&directory, &[&path]); +} diff --git a/apps/desktop/distribution-download/tests/sealed_reader.rs b/apps/desktop/distribution-download/tests/sealed_reader.rs new file mode 100644 index 000000000..88d815436 --- /dev/null +++ b/apps/desktop/distribution-download/tests/sealed_reader.rs @@ -0,0 +1,138 @@ +//! Integration tests for descriptor-bound reads from sealed staging artifacts. + +use bandscope_distribution_download::{ArtifactDownloadAdmission, StagedArtifactFile}; +use std::fs::{self, OpenOptions}; +use std::io::{ErrorKind, Read, Write}; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn scratch_dir(label: &str) -> std::path::PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock after unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "bandscope-distribution-download-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir(&path).expect("create isolated staging directory"); + path +} + +fn assert_platform_drop_cleanup(path: &Path) { + #[cfg(unix)] + assert!(!path.exists(), "Unix removes the descriptor-owned staging path"); + + #[cfg(not(unix))] + { + assert!( + path.is_file(), + "non-Unix drop defers pathname deletion when descriptor identity cannot be proven" + ); + fs::remove_file(path).expect("remove deferred unverified scratch fixture"); + } +} + +fn remove_staging_lease(directory: &Path) { + fs::remove_file(directory.join(".bandscope-staging.lock")) + .expect("remove persistent staging lease fixture"); +} + +#[test] +fn sealed_artifact_exposes_descriptor_bound_read_only_stream() { + let directory = scratch_dir("sealed-reader"); + let mut staged = StagedArtifactFile::create(&directory, "update.bin").expect("stage file"); + let staged_path = staged.path().to_path_buf(); + let mut admission = ArtifactDownloadAdmission::new(4, Some(4)).expect("admission"); + staged + .admit_chunk(&mut admission, b"data") + .expect("write admitted bytes"); + let receipt = admission.finish().expect("exact response receipt"); + let sealed = staged.seal(receipt).expect("sync and seal exact artifact"); + + let mut reader = sealed.reader(); + let mut bytes = Vec::new(); + reader + .read_to_end(&mut bytes) + .expect("read exact sealed descriptor bytes"); + assert_eq!(bytes, b"data"); + assert_eq!(sealed.bytes_written(), 4); + + drop(reader); + drop(sealed); + assert_platform_drop_cleanup(&staged_path); + remove_staging_lease(&directory); + fs::remove_dir(directory).expect("remove staging directory"); +} + +#[test] +fn sealed_reader_never_crosses_the_admitted_byte_boundary_after_external_growth() { + let directory = scratch_dir("sealed-reader-growth"); + let mut staged = StagedArtifactFile::create(&directory, "update.bin").expect("stage file"); + let staged_path = staged.path().to_path_buf(); + let mut admission = ArtifactDownloadAdmission::new(4, Some(4)).expect("admission"); + staged + .admit_chunk(&mut admission, b"data") + .expect("write admitted bytes"); + let receipt = admission.finish().expect("exact response receipt"); + let sealed = staged.seal(receipt).expect("sync and seal exact artifact"); + + let mut external = OpenOptions::new() + .append(true) + .open(&staged_path) + .expect("simulate post-seal local growth"); + external + .write_all(b"untrusted-tail") + .expect("append hostile tail"); + external.sync_all().expect("persist hostile tail"); + drop(external); + + let mut reader = sealed.reader(); + let mut bytes = Vec::new(); + reader + .read_to_end(&mut bytes) + .expect("reader remains bounded to admitted bytes"); + assert_eq!(bytes, b"data"); + assert_eq!(sealed.bytes_written(), 4); + + drop(reader); + drop(sealed); + assert_platform_drop_cleanup(&staged_path); + remove_staging_lease(&directory); + fs::remove_dir(directory).expect("remove staging directory"); +} + +#[test] +fn sealed_reader_fails_closed_when_the_admitted_descriptor_is_truncated() { + let directory = scratch_dir("sealed-reader-truncate"); + let mut staged = StagedArtifactFile::create(&directory, "update.bin").expect("stage file"); + let staged_path = staged.path().to_path_buf(); + let mut admission = ArtifactDownloadAdmission::new(4, Some(4)).expect("admission"); + staged + .admit_chunk(&mut admission, b"data") + .expect("write admitted bytes"); + let receipt = admission.finish().expect("exact response receipt"); + let sealed = staged.seal(receipt).expect("sync and seal exact artifact"); + + let external = OpenOptions::new() + .write(true) + .open(&staged_path) + .expect("simulate post-seal local truncation"); + external.set_len(2).expect("truncate hostile artifact"); + external.sync_all().expect("persist truncation"); + drop(external); + + let mut reader = sealed.reader(); + let mut bytes = Vec::new(); + let error = reader + .read_to_end(&mut bytes) + .expect_err("truncation below admitted boundary must fail closed"); + assert_eq!(error.kind(), ErrorKind::UnexpectedEof); + assert_eq!(bytes, b"da"); + + drop(reader); + drop(sealed); + assert_platform_drop_cleanup(&staged_path); + remove_staging_lease(&directory); + fs::remove_dir(directory).expect("remove staging directory"); +} diff --git a/apps/desktop/distribution-download/tests/staged_artifact.rs b/apps/desktop/distribution-download/tests/staged_artifact.rs new file mode 100644 index 000000000..8d22cc5e2 --- /dev/null +++ b/apps/desktop/distribution-download/tests/staged_artifact.rs @@ -0,0 +1,345 @@ +//! Integration tests for staging admission, cleanup, and cross-process lease safety. + +use bandscope_distribution_download::{ + ArtifactDownloadAdmission, StagedArtifactFile, StagingArtifactError, +}; +use std::fs; +use std::io::ErrorKind; +use std::path::Path; +use std::process::Command; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const CHILD_STAGING_DIRECTORY_ENV: &str = "BANDSCOPE_TEST_STAGING_DIRECTORY"; +const CHILD_READY_PATH_ENV: &str = "BANDSCOPE_TEST_STAGING_READY"; +const CHILD_RELEASE_PATH_ENV: &str = "BANDSCOPE_TEST_STAGING_RELEASE"; + +fn scratch_dir(label: &str) -> std::path::PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock after unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "bandscope-distribution-download-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir(&path).expect("create isolated staging directory"); + path +} + +fn remove_file_if_present(path: &Path) { + match fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => panic!("remove fixture {}: {error}", path.display()), + } +} + +fn remove_scratch_dir(directory: &Path) { + remove_file_if_present(&directory.join("update.bin")); + remove_file_if_present(&directory.join(".bandscope-staging.lock")); + fs::remove_dir(directory).expect("remove staging directory"); +} + +fn assert_platform_drop_cleanup(path: &Path) { + #[cfg(unix)] + assert!(!path.exists(), "Unix removes the descriptor-owned staging path"); + + #[cfg(not(unix))] + assert!( + path.is_file(), + "non-Unix drop defers pathname deletion when descriptor identity cannot be proven" + ); +} + +fn wait_for_path(path: &Path, label: &str) { + for _ in 0..1_000 { + if path.exists() { + return; + } + thread::sleep(Duration::from_millis(10)); + } + panic!("timed out waiting for {label}: {}", path.display()); +} + +#[test] +fn cancelled_staging_file_follows_platform_cleanup_contract() { + let directory = scratch_dir("cancel"); + let staged = StagedArtifactFile::create(&directory, "update.bin").expect("stage file"); + let path = staged.path().to_path_buf(); + assert!(path.is_file()); + + drop(staged); + + assert_platform_drop_cleanup(&path); + remove_scratch_dir(&directory); +} + +#[test] +fn sealed_but_unverified_artifact_follows_platform_cleanup_contract() { + let directory = scratch_dir("seal"); + let mut staged = StagedArtifactFile::create(&directory, "update.bin").expect("stage file"); + let mut admission = ArtifactDownloadAdmission::new(4, Some(4)).expect("admission"); + staged + .admit_chunk(&mut admission, b"data") + .expect("write admitted bytes"); + let receipt = admission.finish().expect("exact response receipt"); + + let sealed = staged.seal(receipt).expect("sync and seal exact artifact"); + assert_eq!(sealed.bytes_written(), 4); + assert_eq!(fs::metadata(sealed.path()).expect("sealed metadata").len(), 4); + let path = sealed.path().to_path_buf(); + drop(sealed); + + assert_platform_drop_cleanup(&path); + remove_scratch_dir(&directory); +} + +#[test] +fn sealed_unverified_artifact_keeps_staging_lease() { + let directory = scratch_dir("sealed-lease"); + let mut staged = StagedArtifactFile::create(&directory, "update.bin").expect("stage file"); + let mut admission = ArtifactDownloadAdmission::new(4, Some(4)).expect("admission"); + staged + .admit_chunk(&mut admission, b"data") + .expect("write admitted bytes"); + let receipt = admission.finish().expect("exact response receipt"); + let sealed = staged.seal(receipt).expect("seal artifact"); + + assert_eq!( + StagedArtifactFile::create(&directory, "update.bin").unwrap_err(), + StagingArtifactError::ConcurrentAttempt + ); + assert!(sealed.path().is_file()); + + drop(sealed); + let replacement = StagedArtifactFile::create(&directory, "update.bin") + .expect("lease must release and stale scratch must be reclaimable"); + drop(replacement); + remove_scratch_dir(&directory); +} + +#[test] +fn failed_admission_follows_platform_cleanup_contract() { + let directory = scratch_dir("overrun"); + let mut staged = StagedArtifactFile::create(&directory, "update.bin").expect("stage file"); + let path = staged.path().to_path_buf(); + let mut admission = ArtifactDownloadAdmission::new(4, None).expect("admission"); + staged + .admit_chunk(&mut admission, b"abc") + .expect("bounded first chunk"); + assert!(staged.admit_chunk(&mut admission, b"de").is_err()); + + drop(staged); + + assert_platform_drop_cleanup(&path); + remove_scratch_dir(&directory); +} + +#[test] +fn receipt_size_mismatch_follows_platform_cleanup_contract() { + let directory = scratch_dir("receipt-mismatch"); + let staged = StagedArtifactFile::create(&directory, "update.bin").expect("stage file"); + let path = staged.path().to_path_buf(); + let mut unrelated_sink = Vec::new(); + let mut unrelated_admission = ArtifactDownloadAdmission::new(1, Some(1)).expect("admission"); + unrelated_admission + .write_chunk(&mut unrelated_sink, b"x") + .expect("write unrelated receipt fixture"); + let receipt = unrelated_admission.finish().expect("receipt"); + + assert_eq!(staged.seal(receipt).unwrap_err(), StagingArtifactError::SizeMismatch); + assert_platform_drop_cleanup(&path); + remove_scratch_dir(&directory); +} + +#[test] +fn stale_regular_destination_is_reclaimed_before_new_attempt() { + let directory = scratch_dir("stale-restart"); + let path = directory.join("update.bin"); + fs::write(&path, b"partial-from-crashed-process").expect("write stale partial artifact"); + + let staged = StagedArtifactFile::create(&directory, "update.bin") + .expect("restart should reclaim stale unverified regular file"); + + assert_eq!(fs::metadata(&path).expect("replacement metadata").len(), 0); + drop(staged); + assert_platform_drop_cleanup(&path); + remove_scratch_dir(&directory); +} + +#[test] +fn active_staging_attempt_is_not_reclaimed_as_stale() { + let directory = scratch_dir("active-attempt"); + let path = directory.join("update.bin"); + let mut first = StagedArtifactFile::create(&directory, "update.bin").expect("first attempt"); + let mut admission = ArtifactDownloadAdmission::new(4, Some(4)).expect("admission"); + first + .admit_chunk(&mut admission, b"da") + .expect("write partial active attempt"); + + assert_eq!( + StagedArtifactFile::create(&directory, "update.bin").unwrap_err(), + StagingArtifactError::ConcurrentAttempt + ); + assert!(path.exists()); + + drop(first); + assert_platform_drop_cleanup(&path); + + let replacement = StagedArtifactFile::create(&directory, "update.bin") + .expect("released active attempt must allow a fresh retry"); + drop(replacement); + remove_scratch_dir(&directory); +} + +#[test] +fn staging_lease_child_holds_until_release() { + let Ok(directory) = std::env::var(CHILD_STAGING_DIRECTORY_ENV) else { + return; + }; + let ready_path = std::env::var(CHILD_READY_PATH_ENV).expect("child ready path"); + let release_path = std::env::var(CHILD_RELEASE_PATH_ENV).expect("child release path"); + let staged = StagedArtifactFile::create(Path::new(&directory), "update.bin") + .expect("child staging attempt"); + fs::write(&ready_path, b"ready").expect("publish child readiness"); + wait_for_path(Path::new(&release_path), "parent release signal"); + drop(staged); +} + +#[test] +fn separate_process_cannot_reclaim_live_staging_attempt() { + let directory = scratch_dir("separate-process"); + let ready_path = directory.join("child.ready"); + let release_path = directory.join("child.release"); + let test_binary = std::env::current_exe().expect("current integration test binary"); + let mut child = Command::new(test_binary) + .arg("--exact") + .arg("staging_lease_child_holds_until_release") + .arg("--nocapture") + .env(CHILD_STAGING_DIRECTORY_ENV, &directory) + .env(CHILD_READY_PATH_ENV, &ready_path) + .env(CHILD_RELEASE_PATH_ENV, &release_path) + .spawn() + .expect("spawn staging lease child process"); + + wait_for_path(&ready_path, "child staging readiness"); + assert_eq!( + StagedArtifactFile::create(&directory, "update.bin").unwrap_err(), + StagingArtifactError::ConcurrentAttempt + ); + assert!(directory.join("update.bin").is_file()); + + fs::write(&release_path, b"release").expect("release child staging lease"); + let status = child.wait().expect("wait for staging lease child"); + assert!(status.success()); + let staging_path = directory.join("update.bin"); + assert_platform_drop_cleanup(&staging_path); + + let replacement = StagedArtifactFile::create(&directory, "update.bin") + .expect("fresh attempt after child process release"); + drop(replacement); + fs::remove_file(ready_path).expect("remove child readiness fixture"); + fs::remove_file(release_path).expect("remove child release fixture"); + remove_scratch_dir(&directory); +} + +#[test] +fn path_like_artifact_names_fail_closed() { + let directory = scratch_dir("path-like-name"); + + assert_eq!( + StagedArtifactFile::create(&directory, "../escape.bin").unwrap_err(), + StagingArtifactError::InvalidArtifactName + ); + + remove_scratch_dir(&directory); +} + +#[test] +fn unavailable_or_non_directory_staging_roots_fail_closed() { + let directory = scratch_dir("invalid-root"); + let missing = directory.join("missing"); + let regular_file = directory.join("regular-file"); + fs::write(®ular_file, b"not a directory").expect("write regular fixture"); + + assert_eq!( + StagedArtifactFile::create(&missing, "update.bin").unwrap_err(), + StagingArtifactError::StagingDirectoryUnavailable(ErrorKind::NotFound) + ); + assert_eq!( + StagedArtifactFile::create(®ular_file, "update.bin").unwrap_err(), + StagingArtifactError::InvalidStagingDirectory + ); + + fs::remove_file(regular_file).expect("remove regular fixture"); + remove_scratch_dir(&directory); +} + +#[cfg(unix)] +#[test] +fn symlink_staging_root_is_rejected() { + use std::os::unix::fs::symlink; + + let directory = scratch_dir("symlink-root"); + let target = directory.join("real"); + let link = directory.join("link"); + fs::create_dir(&target).expect("create target directory"); + symlink(&target, &link).expect("create directory symlink"); + + assert_eq!( + StagedArtifactFile::create(&link, "update.bin").unwrap_err(), + StagingArtifactError::InvalidStagingDirectory + ); + + fs::remove_file(link).expect("remove symlink"); + fs::remove_dir(target).expect("remove target directory"); + remove_scratch_dir(&directory); +} + +#[cfg(unix)] +#[test] +fn symlink_staging_lease_is_not_followed() { + use std::os::unix::fs::symlink; + + let directory = scratch_dir("symlink-lease"); + let target = directory.join("outside.lock"); + let lease = directory.join(".bandscope-staging.lock"); + fs::write(&target, b"must-not-be-used-as-lock").expect("write lease target fixture"); + symlink(&target, &lease).expect("create lease symlink"); + + assert_eq!( + StagedArtifactFile::create(&directory, "update.bin").unwrap_err(), + StagingArtifactError::DestinationExists + ); + assert_eq!( + fs::read(&target).expect("read lease target fixture"), + b"must-not-be-used-as-lock" + ); + + fs::remove_file(lease).expect("remove lease symlink"); + fs::remove_file(target).expect("remove lease target fixture"); + fs::remove_dir(directory).expect("remove staging directory"); +} + +#[cfg(unix)] +#[test] +fn symlink_destination_is_not_reclaimed_as_stale_regular_file() { + use std::os::unix::fs::symlink; + + let directory = scratch_dir("symlink-destination"); + let target = directory.join("outside.bin"); + let link = directory.join("update.bin"); + fs::write(&target, b"must-not-be-touched").expect("write target fixture"); + symlink(&target, &link).expect("create destination symlink"); + + assert_eq!( + StagedArtifactFile::create(&directory, "update.bin").unwrap_err(), + StagingArtifactError::DestinationExists + ); + assert_eq!(fs::read(&target).expect("read target fixture"), b"must-not-be-touched"); + + fs::remove_file(link).expect("remove destination symlink"); + fs::remove_file(target).expect("remove target fixture"); + remove_scratch_dir(&directory); +} diff --git a/apps/desktop/distribution-runtime/Cargo.lock b/apps/desktop/distribution-runtime/Cargo.lock new file mode 100644 index 000000000..66e324f28 --- /dev/null +++ b/apps/desktop/distribution-runtime/Cargo.lock @@ -0,0 +1,14 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bandscope-distribution-core" +version = "0.1.0" + +[[package]] +name = "bandscope-distribution-runtime" +version = "0.1.0" +dependencies = [ + "bandscope-distribution-core", +] diff --git a/apps/desktop/distribution-runtime/Cargo.toml b/apps/desktop/distribution-runtime/Cargo.toml new file mode 100644 index 000000000..54135263d --- /dev/null +++ b/apps/desktop/distribution-runtime/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "bandscope-distribution-runtime" +version = "0.1.0" +edition = "2021" +description = "Bounded provisional updater-metadata admission for BandScope Distribution." +publish = false + +[dependencies] +bandscope-distribution-core = { path = "../distribution-core" } + +[workspace] + +[lints.rust] +unsafe_code = "forbid" +warnings = "deny" +missing_docs = "deny" diff --git a/apps/desktop/distribution-runtime/src/lib.rs b/apps/desktop/distribution-runtime/src/lib.rs new file mode 100644 index 000000000..0be6b7d73 --- /dev/null +++ b/apps/desktop/distribution-runtime/src/lib.rs @@ -0,0 +1,827 @@ +//! Bounded admission for BandScope updater metadata before runtime trust is established. +//! +//! Tauri's updater verifies the downloaded updater artifact signature, but the +//! static JSON response itself is remote metadata. `Update::raw_json` therefore +//! remains provisional input: this crate validates its exact BandScope schema +//! and resource bounds, but it deliberately does not write highest-seen state +//! or return an authenticated `UpdateCandidate`. A later adapter must add an +//! authenticated metadata binding before Distribution may persist freshness. + +#![forbid(unsafe_code)] + +use bandscope_distribution_core::{UpdateCandidate, UpdateRejection}; +use std::fmt; +use std::path::{Path, PathBuf}; + +/// Maximum accepted updater JSON payload before parsing. +pub const MAX_RAW_JSON_BYTES: usize = 256 * 1024; +/// Maximum accepted signature text inside one platform entry. +pub const MAX_SIGNATURE_BYTES: usize = 64 * 1024; +/// Maximum accepted updater URL length. +pub const MAX_URL_BYTES: usize = 2 * 1024; +/// Hard ceiling for one declared updater artifact. +pub const MAX_DECLARED_UPDATER_BYTES: u64 = 2 * 1024 * 1024 * 1024; +/// Static updater targets emitted by BandScope's release builder. +pub const SUPPORTED_TARGETS: [&str; 4] = [ + "windows-x86_64", + "windows-aarch64", + "darwin-x86_64", + "darwin-aarch64", +]; + +const MAX_JSON_DEPTH: usize = 8; +const MAX_JSON_MEMBERS: usize = 64; +const MAX_STRING_BYTES: usize = 128 * 1024; +const STATE_DIRECTORY: &str = "distribution"; +const HIGHEST_SEEN_STATE_FILE: &str = "highest-seen-v1.log"; +const RELEASE_HOST: &str = "github.com"; +const RELEASE_OWNER: &str = "ContextualWisdomLab"; +const RELEASE_REPOSITORY: &str = "bandscope"; +const REDACTED_SIGNATURE: &str = ""; + +/// Fail-closed reasons for provisional updater metadata admission. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MetadataError { + /// The updater response is empty or exceeds the bounded JSON budget. + InvalidSize, + /// The updater response is not valid UTF-8. + InvalidUtf8, + /// The updater response is not valid within BandScope's strict JSON subset. + InvalidJson, + /// A JSON object contains a duplicate member name. + DuplicateMember, + /// An object has a missing or unexpected member. + UnexpectedShape, + /// The requested desktop target is not one of BandScope's release targets. + UnsupportedTarget, + /// A platform signature field is not a bounded canonical standard-base64 envelope. + InvalidSignature, + /// A platform URL is not the canonical bounded GitHub exact-tag release URL. + InvalidUrl, + /// An updater artifact declares a zero or excessive byte length. + InvalidArtifactSize, + /// Core release-identity syntax validation failed. + InvalidReleaseIdentity(UpdateRejection), + /// The app-local-data root is not an absolute path. + InvalidAppDataRoot, +} + +/// Strictly parsed but still unauthenticated updater metadata. +/// +/// This type intentionally exposes no method that writes Distribution state or +/// calls the anti-replay decision core. The remote JSON fields are not promoted +/// to durable release authority merely because their syntax is valid. +#[derive(Clone, Eq, PartialEq)] +pub struct ProvisionalUpdateMetadata { + candidate: UpdateCandidate, + artifact_size_bytes: u64, + artifact_url: String, + artifact_signature: String, +} + +impl fmt::Debug for ProvisionalUpdateMetadata { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProvisionalUpdateMetadata") + .field("candidate", &self.candidate) + .field("artifact_size_bytes", &self.artifact_size_bytes) + .field("artifact_url", &self.artifact_url) + .field("artifact_signature", &REDACTED_SIGNATURE) + .finish() + } +} + +impl ProvisionalUpdateMetadata { + /// Return the canonical numeric release version components. + pub fn version_components(&self) -> (u64, u64, u64) { + self.candidate.identity().version().components() + } + + /// Return the exact release source commit announced by remote metadata. + pub fn source_commit(&self) -> &str { + self.candidate.identity().source_commit() + } + + /// Return the expected updater artifact SHA-256 announced by remote metadata. + pub fn expected_artifact_sha256(&self) -> &str { + self.candidate.identity().artifact_sha256() + } + + /// Return the updater target selected for this installation. + pub fn target(&self) -> &str { + self.candidate.target() + } + + /// Return the declared updater artifact length. + pub const fn artifact_size_bytes(&self) -> u64 { + self.artifact_size_bytes + } + + /// Return the canonical exact-tag updater URL selected by strict admission. + pub fn artifact_url(&self) -> &str { + &self.artifact_url + } + + /// Return the updater signature text paired with the selected admitted URL. + pub fn artifact_signature(&self) -> &str { + &self.artifact_signature + } + + /// Return the minimum client version allowed on the automatic update path. + pub fn minimum_supported_version_components(&self) -> (u64, u64, u64) { + self.candidate.minimum_supported_version().components() + } +} + +/// Parse and bound an untrusted Tauri static updater response. +/// +/// Security Notes: `raw_json` is remote metadata, not proof that the announced +/// version, commit, or digest is authentic. The function rejects duplicate and +/// unknown members, enforces all four release targets, requires every platform +/// signature to use Tauri's bounded canonical standard-base64 outer envelope, +/// bounds URL and artifact-size fields, pins artifact URLs to BandScope's exact +/// GitHub release namespace, and delegates release-identity syntax to the pure +/// Distribution core. The selected URL and signature are retained from this +/// same strict parse so a later transport adapter does not need a second, +/// looser metadata parse. Success is deliberately *provisional* and must never +/// be persisted as highest-seen authority without a separate authenticated +/// metadata binding. +pub fn admit_untrusted_raw_json( + raw_json: &[u8], + expected_target: &str, +) -> Result { + if raw_json.is_empty() || raw_json.len() > MAX_RAW_JSON_BYTES { + return Err(MetadataError::InvalidSize); + } + if !SUPPORTED_TARGETS.contains(&expected_target) { + return Err(MetadataError::UnsupportedTarget); + } + let text = std::str::from_utf8(raw_json).map_err(|_| MetadataError::InvalidUtf8)?; + let document = Parser::new(text.as_bytes()).parse_document()?; + let root = as_object(&document)?; + require_exact_members(root, &["version", "platforms", "bandscope"])?; + + let version = as_string(field(root, "version")?)?; + let platforms = as_object(field(root, "platforms")?)?; + require_exact_members(platforms, &SUPPORTED_TARGETS)?; + let mut selected_url = None; + let mut selected_signature = None; + for target in SUPPORTED_TARGETS { + let platform = as_object(field(platforms, target)?)?; + require_exact_members(platform, &["signature", "url"])?; + let signature = as_string(field(platform, "signature")?)?; + let url = as_string(field(platform, "url")?)?; + validate_signature(signature)?; + validate_release_url(url, version)?; + if target == expected_target { + selected_url = Some(url.to_owned()); + selected_signature = Some(signature.to_owned()); + } + } + + let bandscope = as_object(field(root, "bandscope")?)?; + require_exact_members( + bandscope, + &[ + "schemaVersion", + "sourceCommit", + "minimumSupportedVersion", + "artifacts", + ], + )?; + if as_number(field(bandscope, "schemaVersion")?)? != 1 { + return Err(MetadataError::UnexpectedShape); + } + let source_commit = as_string(field(bandscope, "sourceCommit")?)?; + let minimum_supported_version = + as_string(field(bandscope, "minimumSupportedVersion")?)?; + let artifacts = as_object(field(bandscope, "artifacts")?)?; + require_exact_members(artifacts, &SUPPORTED_TARGETS)?; + + let mut selected_size = None; + let mut selected_digest = None; + for target in SUPPORTED_TARGETS { + let artifact = as_object(field(artifacts, target)?)?; + require_exact_members(artifact, &["sizeBytes", "sha256"])?; + let size = as_number(field(artifact, "sizeBytes")?)?; + if size == 0 || size > MAX_DECLARED_UPDATER_BYTES { + return Err(MetadataError::InvalidArtifactSize); + } + let digest = as_string(field(artifact, "sha256")?)?; + if target == expected_target { + selected_size = Some(size); + selected_digest = Some(digest); + } else { + validate_candidate_syntax( + version, + source_commit, + digest, + target, + minimum_supported_version, + )?; + } + } + + let artifact_size_bytes = selected_size.ok_or(MetadataError::UnexpectedShape)?; + let artifact_sha256 = selected_digest.ok_or(MetadataError::UnexpectedShape)?; + let artifact_url = selected_url.ok_or(MetadataError::UnexpectedShape)?; + let artifact_signature = selected_signature.ok_or(MetadataError::UnexpectedShape)?; + let candidate = validate_candidate_syntax( + version, + source_commit, + artifact_sha256, + expected_target, + minimum_supported_version, + )?; + + Ok(ProvisionalUpdateMetadata { + candidate, + artifact_size_bytes, + artifact_url, + artifact_signature, + }) +} + +/// Return the fixed Distribution-owned highest-seen path under Tauri app data. +/// +/// This is a path projection only. It does not create directories or files and +/// it does not persist provisional metadata. `bandscope-distribution-state` +/// remains the sole owner of state-file admission and durability semantics. +pub fn app_owned_highest_seen_path(app_local_data_dir: &Path) -> Result { + if !app_local_data_dir.is_absolute() { + return Err(MetadataError::InvalidAppDataRoot); + } + Ok(app_local_data_dir + .join(STATE_DIRECTORY) + .join(HIGHEST_SEEN_STATE_FILE)) +} + +fn validate_candidate_syntax( + version: &str, + source_commit: &str, + artifact_sha256: &str, + target: &str, + minimum_supported_version: &str, +) -> Result { + UpdateCandidate::new( + version, + source_commit, + artifact_sha256, + target, + minimum_supported_version, + ) + .map_err(MetadataError::InvalidReleaseIdentity) +} + +fn validate_signature(value: &str) -> Result<(), MetadataError> { + if value.len() > MAX_SIGNATURE_BYTES || !is_canonical_standard_base64(value) { + return Err(MetadataError::InvalidSignature); + } + Ok(()) +} + +fn is_canonical_standard_base64(value: &str) -> bool { + let bytes = value.as_bytes(); + if bytes.is_empty() || bytes.len() % 4 != 0 { + return false; + } + + let padding = if bytes.ends_with(b"==") { + 2 + } else if bytes.ends_with(b"=") { + 1 + } else { + 0 + }; + let data_len = bytes.len() - padding; + if data_len == 0 + || bytes[..data_len] + .iter() + .any(|byte| base64_sextet(*byte).is_none()) + || bytes[data_len..].iter().any(|byte| *byte != b'=') + { + return false; + } + + match padding { + 0 => true, + 1 => base64_sextet(bytes[data_len - 1]) + .is_some_and(|sextet| sextet & 0b0000_0011 == 0), + 2 => base64_sextet(bytes[data_len - 1]) + .is_some_and(|sextet| sextet & 0b0000_1111 == 0), + _ => false, + } +} + +fn base64_sextet(byte: u8) -> Option { + match byte { + b'A'..=b'Z' => Some(byte - b'A'), + b'a'..=b'z' => Some(byte - b'a' + 26), + b'0'..=b'9' => Some(byte - b'0' + 52), + b'+' => Some(62), + b'/' => Some(63), + _ => None, + } +} + +fn validate_release_url(value: &str, version: &str) -> Result<(), MetadataError> { + if value.is_empty() + || value.len() > MAX_URL_BYTES + || value + .bytes() + .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace()) + || value.contains('?') + || value.contains('#') + || value.contains('\\') + { + return Err(MetadataError::InvalidUrl); + } + + let remainder = value + .strip_prefix("https://") + .ok_or(MetadataError::InvalidUrl)?; + let (authority, path) = remainder + .split_once('/') + .ok_or(MetadataError::InvalidUrl)?; + if !authority.eq_ignore_ascii_case(RELEASE_HOST) || authority.contains('@') { + return Err(MetadataError::InvalidUrl); + } + + let segments: Vec<&str> = path.split('/').collect(); + let expected_tag = format!("v{version}"); + if segments.len() != 6 + || segments[0] != RELEASE_OWNER + || segments[1] != RELEASE_REPOSITORY + || segments[2] != "releases" + || segments[3] != "download" + || segments[4] != expected_tag.as_str() + || !is_safe_release_asset_name(segments[5]) + { + return Err(MetadataError::InvalidUrl); + } + Ok(()) +} + +fn is_safe_release_asset_name(value: &str) -> bool { + !value.is_empty() + && value != "." + && value != ".." + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-') + }) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum JsonValue { + Object(Vec<(String, JsonValue)>), + String(String), + Number(u64), +} + +struct Parser<'a> { + bytes: &'a [u8], + position: usize, + depth: usize, + members: usize, +} + +impl<'a> Parser<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { + bytes, + position: 0, + depth: 0, + members: 0, + } + } + + fn parse_document(mut self) -> Result { + self.skip_whitespace(); + let value = self.parse_value()?; + self.skip_whitespace(); + if self.position != self.bytes.len() { + return Err(MetadataError::InvalidJson); + } + Ok(value) + } + + fn parse_value(&mut self) -> Result { + self.skip_whitespace(); + match self.peek() { + Some(b'{') => self.parse_object(), + Some(b'"') => self.parse_string().map(JsonValue::String), + Some(b'0'..=b'9') => self.parse_number().map(JsonValue::Number), + _ => Err(MetadataError::InvalidJson), + } + } + + fn parse_object(&mut self) -> Result { + if self.depth >= MAX_JSON_DEPTH { + return Err(MetadataError::InvalidJson); + } + self.consume(b'{')?; + self.depth += 1; + self.skip_whitespace(); + let mut entries = Vec::new(); + if self.peek() == Some(b'}') { + self.position += 1; + self.depth -= 1; + return Ok(JsonValue::Object(entries)); + } + + loop { + self.skip_whitespace(); + if self.peek() != Some(b'"') { + self.depth -= 1; + return Err(MetadataError::InvalidJson); + } + let key = self.parse_string()?; + if entries.iter().any(|(existing, _)| existing == &key) { + self.depth -= 1; + return Err(MetadataError::DuplicateMember); + } + self.members += 1; + if self.members > MAX_JSON_MEMBERS { + self.depth -= 1; + return Err(MetadataError::InvalidJson); + } + self.skip_whitespace(); + self.consume(b':')?; + let value = self.parse_value()?; + entries.push((key, value)); + self.skip_whitespace(); + match self.peek() { + Some(b',') => self.position += 1, + Some(b'}') => { + self.position += 1; + self.depth -= 1; + return Ok(JsonValue::Object(entries)); + } + _ => { + self.depth -= 1; + return Err(MetadataError::InvalidJson); + } + } + } + } + + fn parse_string(&mut self) -> Result { + self.consume(b'"')?; + let mut output = String::new(); + loop { + let byte = self.next().ok_or(MetadataError::InvalidJson)?; + match byte { + b'"' => break, + b'\\' => self.parse_escape(&mut output)?, + 0x00..=0x1f => return Err(MetadataError::InvalidJson), + 0x20..=0x7f => output.push(char::from(byte)), + _ => { + self.position -= 1; + let remaining = std::str::from_utf8(&self.bytes[self.position..]) + .map_err(|_| MetadataError::InvalidUtf8)?; + let character = remaining.chars().next().ok_or(MetadataError::InvalidJson)?; + output.push(character); + self.position += character.len_utf8(); + } + } + if output.len() > MAX_STRING_BYTES { + return Err(MetadataError::InvalidJson); + } + } + Ok(output) + } + + fn parse_escape(&mut self, output: &mut String) -> Result<(), MetadataError> { + let escaped = self.next().ok_or(MetadataError::InvalidJson)?; + match escaped { + b'"' => output.push('"'), + b'\\' => output.push('\\'), + b'/' => output.push('/'), + b'b' => output.push('\u{0008}'), + b'f' => output.push('\u{000c}'), + b'n' => output.push('\n'), + b'r' => output.push('\r'), + b't' => output.push('\t'), + b'u' => { + let first = self.parse_hex_quad()?; + let codepoint = if (0xd800..=0xdbff).contains(&first) { + self.consume(b'\\')?; + self.consume(b'u')?; + let second = self.parse_hex_quad()?; + if !(0xdc00..=0xdfff).contains(&second) { + return Err(MetadataError::InvalidJson); + } + 0x10000 + (((first - 0xd800) as u32) << 10) + (second - 0xdc00) as u32 + } else if (0xdc00..=0xdfff).contains(&first) { + return Err(MetadataError::InvalidJson); + } else { + first as u32 + }; + let character = char::from_u32(codepoint).ok_or(MetadataError::InvalidJson)?; + output.push(character); + } + _ => return Err(MetadataError::InvalidJson), + } + Ok(()) + } + + fn parse_hex_quad(&mut self) -> Result { + let mut value = 0_u16; + for _ in 0..4 { + let byte = self.next().ok_or(MetadataError::InvalidJson)?; + let digit = match byte { + b'0'..=b'9' => (byte - b'0') as u16, + b'a'..=b'f' => (byte - b'a' + 10) as u16, + b'A'..=b'F' => (byte - b'A' + 10) as u16, + _ => return Err(MetadataError::InvalidJson), + }; + value = (value << 4) | digit; + } + Ok(value) + } + + fn parse_number(&mut self) -> Result { + let start = self.position; + match self.peek() { + Some(b'0') => { + self.position += 1; + if matches!(self.peek(), Some(b'0'..=b'9')) { + return Err(MetadataError::InvalidJson); + } + } + Some(b'1'..=b'9') => { + self.position += 1; + while matches!(self.peek(), Some(b'0'..=b'9')) { + self.position += 1; + } + } + _ => return Err(MetadataError::InvalidJson), + } + let text = std::str::from_utf8(&self.bytes[start..self.position]) + .map_err(|_| MetadataError::InvalidUtf8)?; + text.parse::().map_err(|_| MetadataError::InvalidJson) + } + + fn skip_whitespace(&mut self) { + while matches!(self.peek(), Some(b' ' | b'\n' | b'\r' | b'\t')) { + self.position += 1; + } + } + + fn consume(&mut self, expected: u8) -> Result<(), MetadataError> { + if self.next() != Some(expected) { + return Err(MetadataError::InvalidJson); + } + Ok(()) + } + + fn peek(&self) -> Option { + self.bytes.get(self.position).copied() + } + + fn next(&mut self) -> Option { + let byte = self.peek()?; + self.position += 1; + Some(byte) + } +} + +fn as_object(value: &JsonValue) -> Result<&[(String, JsonValue)], MetadataError> { + match value { + JsonValue::Object(entries) => Ok(entries), + _ => Err(MetadataError::UnexpectedShape), + } +} + +fn as_string(value: &JsonValue) -> Result<&str, MetadataError> { + match value { + JsonValue::String(text) => Ok(text), + _ => Err(MetadataError::UnexpectedShape), + } +} + +fn as_number(value: &JsonValue) -> Result { + match value { + JsonValue::Number(number) => Ok(*number), + _ => Err(MetadataError::UnexpectedShape), + } +} + +fn field<'a>( + object: &'a [(String, JsonValue)], + name: &str, +) -> Result<&'a JsonValue, MetadataError> { + object + .iter() + .find_map(|(key, value)| (key == name).then_some(value)) + .ok_or(MetadataError::UnexpectedShape) +} + +fn require_exact_members( + object: &[(String, JsonValue)], + expected: &[&str], +) -> Result<(), MetadataError> { + if object.len() != expected.len() + || object + .iter() + .any(|(key, _)| !expected.contains(&key.as_str())) + || expected + .iter() + .any(|name| !object.iter().any(|(key, _)| key == name)) + { + return Err(MetadataError::UnexpectedShape); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SOURCE: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const DIGEST_WINDOWS_X86: &str = + "1111111111111111111111111111111111111111111111111111111111111111"; + const DIGEST_WINDOWS_ARM: &str = + "2222222222222222222222222222222222222222222222222222222222222222"; + const DIGEST_DARWIN_X86: &str = + "3333333333333333333333333333333333333333333333333333333333333333"; + const DIGEST_DARWIN_ARM: &str = + "4444444444444444444444444444444444444444444444444444444444444444"; + + fn manifest(version: &str) -> String { + format!( + r#"{{ + "version": "{version}", + "platforms": {{ + "windows-x86_64": {{"signature": "c2ln", "url": "https://github.com/ContextualWisdomLab/bandscope/releases/download/v{version}/win-x86.zip"}}, + "windows-aarch64": {{"signature": "c2lnMQ==", "url": "https://github.com/ContextualWisdomLab/bandscope/releases/download/v{version}/win-arm.zip"}}, + "darwin-x86_64": {{"signature": "c2lnMg==", "url": "https://github.com/ContextualWisdomLab/bandscope/releases/download/v{version}/mac-x86.tar.gz"}}, + "darwin-aarch64": {{"signature": "c2lnMw==", "url": "https://github.com/ContextualWisdomLab/bandscope/releases/download/v{version}/mac-arm.tar.gz"}} + }}, + "bandscope": {{ + "schemaVersion": 1, + "sourceCommit": "{SOURCE}", + "minimumSupportedVersion": "0.1.3", + "artifacts": {{ + "windows-x86_64": {{"sizeBytes": 101, "sha256": "{DIGEST_WINDOWS_X86}"}}, + "windows-aarch64": {{"sizeBytes": 102, "sha256": "{DIGEST_WINDOWS_ARM}"}}, + "darwin-x86_64": {{"sizeBytes": 103, "sha256": "{DIGEST_DARWIN_X86}"}}, + "darwin-aarch64": {{"sizeBytes": 104, "sha256": "{DIGEST_DARWIN_ARM}"}} + }} + }} +}}"# + ) + } + + #[test] + fn strict_manifest_is_admitted_only_as_provisional_metadata() { + let metadata = admit_untrusted_raw_json(manifest("2.0.0").as_bytes(), "windows-x86_64") + .expect("current publication shape should parse"); + assert_eq!(metadata.version_components(), (2, 0, 0)); + assert_eq!(metadata.source_commit(), SOURCE); + assert_eq!(metadata.expected_artifact_sha256(), DIGEST_WINDOWS_X86); + assert_eq!(metadata.target(), "windows-x86_64"); + assert_eq!(metadata.artifact_size_bytes(), 101); + assert_eq!(metadata.minimum_supported_version_components(), (0, 1, 3)); + } + + #[test] + fn duplicate_or_unknown_members_fail_closed() { + let duplicate = manifest("2.0.0").replacen( + "\"version\": \"2.0.0\",", + "\"version\": \"2.0.0\", \"version\": \"9.9.9\",", + 1, + ); + assert_eq!( + admit_untrusted_raw_json(duplicate.as_bytes(), "windows-x86_64"), + Err(MetadataError::DuplicateMember) + ); + + let unknown = manifest("2.0.0").replacen( + "\"schemaVersion\": 1,", + "\"schemaVersion\": 1, \"trusted\": 1,", + 1, + ); + assert_eq!( + admit_untrusted_raw_json(unknown.as_bytes(), "windows-x86_64"), + Err(MetadataError::UnexpectedShape) + ); + } + + #[test] + fn malformed_identity_and_mutable_release_urls_fail_closed() { + let bad_version = manifest("02.0.0"); + assert_eq!( + admit_untrusted_raw_json(bad_version.as_bytes(), "windows-x86_64"), + Err(MetadataError::InvalidReleaseIdentity( + UpdateRejection::InvalidVersion + )) + ); + + let mutable_url = manifest("2.0.0").replace( + "/releases/download/v2.0.0/", + "/releases/latest/download/", + ); + assert_eq!( + admit_untrusted_raw_json(mutable_url.as_bytes(), "windows-x86_64"), + Err(MetadataError::InvalidUrl) + ); + } + + #[test] + fn release_download_namespace_is_pinned_before_any_network_adapter_can_use_it() { + let hostile_host = manifest("2.0.0").replace("https://github.com/", "https://evil.example/"); + assert_eq!( + admit_untrusted_raw_json(hostile_host.as_bytes(), "windows-x86_64"), + Err(MetadataError::InvalidUrl) + ); + + let hostile_repo = manifest("2.0.0").replace( + "/ContextualWisdomLab/bandscope/", + "/attacker/bandscope/", + ); + assert_eq!( + admit_untrusted_raw_json(hostile_repo.as_bytes(), "windows-x86_64"), + Err(MetadataError::InvalidUrl) + ); + + let userinfo = manifest("2.0.0").replace( + "https://github.com/", + "https://github.com@evil.example/", + ); + assert_eq!( + admit_untrusted_raw_json(userinfo.as_bytes(), "windows-x86_64"), + Err(MetadataError::InvalidUrl) + ); + + let query = manifest("2.0.0").replace( + "win-x86.zip\"", + "win-x86.zip?mirror=/releases/download/v2.0.0/other.zip\"", + ); + assert_eq!( + admit_untrusted_raw_json(query.as_bytes(), "windows-x86_64"), + Err(MetadataError::InvalidUrl) + ); + + let encoded_path = manifest("2.0.0").replace("win-x86.zip", "win%2Fx86.zip"); + assert_eq!( + admit_untrusted_raw_json(encoded_path.as_bytes(), "windows-x86_64"), + Err(MetadataError::InvalidUrl) + ); + } + + #[test] + fn resource_bounds_and_target_set_fail_closed() { + assert_eq!( + admit_untrusted_raw_json(&vec![b' '; MAX_RAW_JSON_BYTES + 1], "windows-x86_64"), + Err(MetadataError::InvalidSize) + ); + assert_eq!( + admit_untrusted_raw_json(manifest("2.0.0").as_bytes(), "linux-x86_64"), + Err(MetadataError::UnsupportedTarget) + ); + + let zero_size = manifest("2.0.0").replacen("\"sizeBytes\": 101", "\"sizeBytes\": 0", 1); + assert_eq!( + admit_untrusted_raw_json(zero_size.as_bytes(), "windows-x86_64"), + Err(MetadataError::InvalidArtifactSize) + ); + } + + #[test] + fn app_owned_state_path_is_fixed_but_not_created() { + let root = std::env::temp_dir().join(format!( + "bandscope-runtime-path-{}", + std::process::id() + )); + let path = app_owned_highest_seen_path(&root).expect("temp path should be absolute"); + assert_eq!( + path, + root.join("distribution").join("highest-seen-v1.log") + ); + assert!(!path.exists(), "path projection must not persist untrusted metadata"); + assert_eq!( + app_owned_highest_seen_path(Path::new("relative/app-data")), + Err(MetadataError::InvalidAppDataRoot) + ); + } + + #[test] + fn parser_accepts_json_unicode_escape_but_rejects_invalid_surrogate() { + let escaped = Parser::new(br#"{"value":"\u2603"}"#) + .parse_document() + .expect("valid unicode escape should parse"); + assert_eq!( + escaped, + JsonValue::Object(vec![( + "value".to_owned(), + JsonValue::String("☃".to_owned()) + )]) + ); + + assert_eq!( + Parser::new(br#"{"value":"\uD800x"}"#).parse_document(), + Err(MetadataError::InvalidJson) + ); + } +} diff --git a/apps/desktop/distribution-runtime/tests/provisional_artifact.rs b/apps/desktop/distribution-runtime/tests/provisional_artifact.rs new file mode 100644 index 000000000..1015ca4a0 --- /dev/null +++ b/apps/desktop/distribution-runtime/tests/provisional_artifact.rs @@ -0,0 +1,29 @@ +use bandscope_distribution_runtime::admit_untrusted_raw_json; + +const SOURCE_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; +const DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const WINDOWS_X86_64_SIGNATURE: &str = "c2lnLXdpbi14NjQ="; +const WINDOWS_AARCH64_SIGNATURE: &str = "c2lnLXdpbi1hcm02NA=="; +const DARWIN_X86_64_SIGNATURE: &str = "c2lnLW1hYy14NjQ="; +const DARWIN_AARCH64_SIGNATURE: &str = "c2lnLW1hYy1hcm02NA=="; + +fn updater_document() -> Vec { + format!( + r#"{{"version":"1.2.3","platforms":{{"windows-x86_64":{{"signature":"{WINDOWS_X86_64_SIGNATURE}","url":"https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-windows-x86_64.zip"}},"windows-aarch64":{{"signature":"{WINDOWS_AARCH64_SIGNATURE}","url":"https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-windows-aarch64.zip"}},"darwin-x86_64":{{"signature":"{DARWIN_X86_64_SIGNATURE}","url":"https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-darwin-x86_64.tar.gz"}},"darwin-aarch64":{{"signature":"{DARWIN_AARCH64_SIGNATURE}","url":"https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-darwin-aarch64.tar.gz"}}}},"bandscope":{{"schemaVersion":1,"sourceCommit":"{SOURCE_COMMIT}","minimumSupportedVersion":"0.1.3","artifacts":{{"windows-x86_64":{{"sizeBytes":4,"sha256":"{DIGEST}"}},"windows-aarch64":{{"sizeBytes":5,"sha256":"{DIGEST}"}},"darwin-x86_64":{{"sizeBytes":6,"sha256":"{DIGEST}"}},"darwin-aarch64":{{"sizeBytes":7,"sha256":"{DIGEST}"}}}}}}}}"# + ) + .into_bytes() +} + +#[test] +fn selected_transport_fields_remain_bound_to_strict_admission() { + let metadata = admit_untrusted_raw_json(&updater_document(), "darwin-aarch64") + .expect("fixture must satisfy provisional metadata admission"); + + assert_eq!(metadata.artifact_size_bytes(), 7); + assert_eq!(metadata.expected_artifact_sha256(), DIGEST); + assert_eq!( + metadata.artifact_url(), + "https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-darwin-aarch64.tar.gz" + ); + assert_eq!(metadata.artifact_signature(), DARWIN_AARCH64_SIGNATURE); +} diff --git a/apps/desktop/distribution-runtime/tests/provisional_diagnostics.rs b/apps/desktop/distribution-runtime/tests/provisional_diagnostics.rs new file mode 100644 index 000000000..a8e3b4e14 --- /dev/null +++ b/apps/desktop/distribution-runtime/tests/provisional_diagnostics.rs @@ -0,0 +1,24 @@ +use bandscope_distribution_runtime::admit_untrusted_raw_json; + +const SOURCE_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; +const DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const INITIAL_URL: &str = "https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-windows-x86_64.zip"; +const SIGNATURE: &str = "c2lnbmF0dXJlLXJlbW90ZS1kaWFnbm9zdGljLW1hcmtlcg=="; + +fn updater_document() -> Vec { + format!( + r#"{{"version":"1.2.3","platforms":{{"windows-x86_64":{{"signature":"{SIGNATURE}","url":"{INITIAL_URL}"}},"windows-aarch64":{{"signature":"c2ln","url":"https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-windows-aarch64.zip"}},"darwin-x86_64":{{"signature":"c2ln","url":"https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-darwin-x86_64.tar.gz"}},"darwin-aarch64":{{"signature":"c2ln","url":"https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-darwin-aarch64.tar.gz"}}}},"bandscope":{{"schemaVersion":1,"sourceCommit":"{SOURCE_COMMIT}","minimumSupportedVersion":"0.1.3","artifacts":{{"windows-x86_64":{{"sizeBytes":4,"sha256":"{DIGEST}"}},"windows-aarch64":{{"sizeBytes":5,"sha256":"{DIGEST}"}},"darwin-x86_64":{{"sizeBytes":6,"sha256":"{DIGEST}"}},"darwin-aarch64":{{"sizeBytes":7,"sha256":"{DIGEST}"}}}}}}}}"# + ) + .into_bytes() +} + +#[test] +fn provisional_metadata_debug_redacts_remote_signature() { + let metadata = admit_untrusted_raw_json(&updater_document(), "windows-x86_64") + .expect("fixture must satisfy provisional metadata admission"); + + let diagnostic = format!("{metadata:?}"); + assert!(diagnostic.contains("")); + assert!(!diagnostic.contains(SIGNATURE)); + assert_eq!(metadata.artifact_signature(), SIGNATURE); +} diff --git a/apps/desktop/distribution-runtime/tests/signature_envelope.rs b/apps/desktop/distribution-runtime/tests/signature_envelope.rs new file mode 100644 index 000000000..73a57c1c7 --- /dev/null +++ b/apps/desktop/distribution-runtime/tests/signature_envelope.rs @@ -0,0 +1,56 @@ +use bandscope_distribution_runtime::{ + admit_untrusted_raw_json, MetadataError, MAX_SIGNATURE_BYTES, +}; + +const SOURCE_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; +const DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +fn updater_document(nonselected_signature: &str) -> Vec { + format!( + r#"{{"version":"1.2.3","platforms":{{"windows-x86_64":{{"signature":"c2ln","url":"https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-windows-x86_64.zip"}},"windows-aarch64":{{"signature":"{nonselected_signature}","url":"https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-windows-aarch64.zip"}},"darwin-x86_64":{{"signature":"c2ln","url":"https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-darwin-x86_64.tar.gz"}},"darwin-aarch64":{{"signature":"c2ln","url":"https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-darwin-aarch64.tar.gz"}}}},"bandscope":{{"schemaVersion":1,"sourceCommit":"{SOURCE_COMMIT}","minimumSupportedVersion":"0.1.3","artifacts":{{"windows-x86_64":{{"sizeBytes":4,"sha256":"{DIGEST}"}},"windows-aarch64":{{"sizeBytes":5,"sha256":"{DIGEST}"}},"darwin-x86_64":{{"sizeBytes":6,"sha256":"{DIGEST}"}},"darwin-aarch64":{{"sizeBytes":7,"sha256":"{DIGEST}"}}}}}}}}"# + ) + .into_bytes() +} + +#[test] +fn malformed_nonselected_signature_fails_at_metadata_owner() { + assert_eq!( + admit_untrusted_raw_json(&updater_document("not-base64!"), "windows-x86_64"), + Err(MetadataError::InvalidSignature) + ); +} + +#[test] +fn canonical_padding_variants_are_admitted_for_nonselected_targets() { + for signature in ["c2ln", "c2k=", "c2lnMQ=="] { + assert!( + admit_untrusted_raw_json(&updater_document(signature), "windows-x86_64").is_ok(), + "canonical signature envelope should be admitted: {signature}" + ); + } +} + +#[test] +fn malformed_padding_and_nonzero_pad_bits_fail_closed() { + for signature in ["c2ln=", "=2ln", "YR==", "YWJ="] { + assert_eq!( + admit_untrusted_raw_json(&updater_document(signature), "windows-x86_64"), + Err(MetadataError::InvalidSignature), + "noncanonical signature envelope must fail: {signature}" + ); + } +} + +#[test] +fn empty_and_oversized_signature_envelopes_fail_closed() { + assert_eq!( + admit_untrusted_raw_json(&updater_document(""), "windows-x86_64"), + Err(MetadataError::InvalidSignature) + ); + + let oversized = "A".repeat(MAX_SIGNATURE_BYTES + 4); + assert_eq!( + admit_untrusted_raw_json(&updater_document(&oversized), "windows-x86_64"), + Err(MetadataError::InvalidSignature) + ); +} diff --git a/apps/desktop/distribution-state/Cargo.lock b/apps/desktop/distribution-state/Cargo.lock new file mode 100644 index 000000000..099b15d61 --- /dev/null +++ b/apps/desktop/distribution-state/Cargo.lock @@ -0,0 +1,14 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bandscope-distribution-core" +version = "0.1.0" + +[[package]] +name = "bandscope-distribution-state" +version = "0.1.0" +dependencies = [ + "bandscope-distribution-core", +] diff --git a/apps/desktop/distribution-state/Cargo.toml b/apps/desktop/distribution-state/Cargo.toml new file mode 100644 index 000000000..56356a0b0 --- /dev/null +++ b/apps/desktop/distribution-state/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "bandscope-distribution-state" +version = "0.1.0" +edition = "2021" +description = "Crash-resilient local freshness state for BandScope Distribution/update decisions." +publish = false + +[dependencies] +bandscope-distribution-core = { path = "../distribution-core" } + +[workspace] + +[lints.rust] +unsafe_code = "forbid" +warnings = "deny" +missing_docs = "deny" diff --git a/apps/desktop/distribution-state/src/lib.rs b/apps/desktop/distribution-state/src/lib.rs new file mode 100644 index 000000000..9f3045b9f --- /dev/null +++ b/apps/desktop/distribution-state/src/lib.rs @@ -0,0 +1,645 @@ +//! Durable local freshness state for BandScope's Distribution/update boundary. +//! +//! This crate owns only the locally persisted highest authenticated release +//! identity used by the anti-replay decision core. It does not fetch update +//! metadata, verify Tauri signatures, install software, or write BandScope +//! project data. Unix keeps the bounded state log append-only. Windows publishes +//! an equivalent committed log snapshot through a synchronized sibling file and +//! pathname replacement so updating state cannot mutate a pre-existing hard-link +//! alias when stable Rust cannot inspect link count safely. + +#![forbid(unsafe_code)] + +use bandscope_distribution_core::ReleaseIdentity; +use std::fs::{File, OpenOptions, TryLockError}; +use std::io::{Read, Write}; +use std::path::Path; + +/// Maximum accepted state-log size. +pub const MAX_STATE_BYTES: usize = 64 * 1024; + +const RECORD_PREFIX: &str = "v1|"; +const MAX_RECORD_BYTES: usize = 192; +const STATE_LEASE_FILE_NAME: &str = ".bandscope-highest-seen.lock"; +#[cfg(windows)] +const STATE_NEXT_FILE_NAME: &str = ".bandscope-highest-seen.next"; + +/// Successful result of remembering an authenticated release identity. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RememberOutcome { + /// The new highest authenticated identity was durably committed. + Remembered, + /// The exact identity was already the committed highest-seen release. + AlreadyRemembered, +} + +/// Fail-closed durable-state errors. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StateError { + /// A local filesystem operation failed. + Io, + /// The configured state path is a link or is not an admissible regular file. + NotRegularFile, + /// The state log exceeded its bounded storage budget. + TooLarge, + /// A committed record or unrecoverable trailing fragment is malformed. + Corrupt, + /// A caller attempted to remember a release older than local authority. + Replay, + /// The same release version was presented with different immutable identity. + Equivocation, + /// Another process owns the state lease or bytes changed during admission. + ConcurrentMutation, +} + +/// Load the highest committed authenticated release identity from local state. +/// +/// Access is serialized through a sibling OS file lease so a reader cannot +/// observe a stale highest-seen snapshot while another cooperating process is +/// committing a newer authenticated release. A final non-newline-terminated +/// fragment is treated as recoverable only when every byte is a valid prefix of +/// one state record. This is the sole torn-write case accepted. Malformed +/// committed records fail closed rather than silently discarding anti-replay +/// evidence. +pub fn load_highest_seen(path: &Path) -> Result, StateError> { + let _lease = acquire_state_lease(path)?; + let bytes = read_state_bytes(path)?.unwrap_or_default(); + parse_state_bytes(&bytes).map(|parsed| parsed.highest) +} + +/// Remember a newly authenticated release identity in durable local state. +/// +/// The caller must invoke this only after updater metadata and artifact +/// authenticity have been established. One sibling OS file lease is held from +/// the first state read through repair and synchronization so two app processes +/// cannot commit from the same stale snapshot. Unix appends to the single-link +/// log and truncates only a validated torn tail. Windows constructs the same +/// committed log bytes in a sibling scratch file, synchronizes them, and +/// replaces only the state pathname; this avoids in-place mutation of a +/// pre-existing hard-link alias without relying on nightly-only Windows +/// metadata APIs. +pub fn remember_highest_seen( + path: &Path, + identity: &ReleaseIdentity, +) -> Result { + let _lease = acquire_state_lease(path)?; + let original = read_state_bytes(path)?; + let bytes = original.as_deref().unwrap_or(&[]); + let parsed = parse_state_bytes(bytes)?; + + if let Some(highest) = parsed.highest.as_ref() { + if identity.version() < highest.version() { + return Err(StateError::Replay); + } + if identity.version() == highest.version() { + if identity != highest { + return Err(StateError::Equivocation); + } + if parsed.committed_len != bytes.len() { + repair_recoverable_tail(path, bytes, parsed.committed_len)?; + } + return Ok(RememberOutcome::AlreadyRemembered); + } + } + + let record = encode_record(identity); + let expected_len = parsed + .committed_len + .checked_add(record.len()) + .ok_or(StateError::TooLarge)?; + if expected_len > MAX_STATE_BYTES { + return Err(StateError::TooLarge); + } + + #[cfg(windows)] + { + let mut committed = Vec::with_capacity(expected_len); + committed.extend_from_slice(&bytes[..parsed.committed_len]); + committed.extend_from_slice(record.as_bytes()); + publish_state_snapshot(path, &committed)?; + return Ok(RememberOutcome::Remembered); + } + + #[cfg(not(windows))] + { + if parsed.committed_len != bytes.len() { + repair_recoverable_tail(path, bytes, parsed.committed_len)?; + } + + let existed = original.is_some(); + let mut file = open_for_append(path, existed)?; + let current_len = file.metadata().map_err(|_| StateError::Io)?.len() as usize; + if current_len != parsed.committed_len { + return Err(StateError::ConcurrentMutation); + } + + file.write_all(record.as_bytes()).map_err(|_| StateError::Io)?; + file.sync_all().map_err(|_| StateError::Io)?; + if file.metadata().map_err(|_| StateError::Io)?.len() as usize != expected_len { + return Err(StateError::ConcurrentMutation); + } + + #[cfg(unix)] + if !existed { + let parent = path.parent().ok_or(StateError::Io)?; + File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|_| StateError::Io)?; + } + + Ok(RememberOutcome::Remembered) + } +} + +#[derive(Debug)] +struct ParsedState { + highest: Option, + committed_len: usize, +} + +fn acquire_state_lease(path: &Path) -> Result { + let parent = path.parent().ok_or(StateError::Io)?; + let lease_path = parent.join(STATE_LEASE_FILE_NAME); + let lease_file = match OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(&lease_path) + { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + let metadata = std::fs::symlink_metadata(&lease_path).map_err(|_| StateError::Io)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(StateError::NotRegularFile); + } + OpenOptions::new() + .read(true) + .write(true) + .open(&lease_path) + .map_err(|_| StateError::Io)? + } + Err(_) => return Err(StateError::Io), + }; + + match lease_file.try_lock() { + Ok(()) => Ok(lease_file), + Err(TryLockError::WouldBlock) => Err(StateError::ConcurrentMutation), + Err(TryLockError::Error(_)) => Err(StateError::Io), + } +} + +fn read_state_bytes(path: &Path) -> Result>, StateError> { + let metadata = match std::fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err(StateError::Io), + }; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || !has_admissible_filesystem_link_count(&metadata) + { + return Err(StateError::NotRegularFile); + } + if metadata.len() as usize > MAX_STATE_BYTES { + return Err(StateError::TooLarge); + } + + let mut file = File::open(path).map_err(|_| StateError::Io)?; + let opened = file.metadata().map_err(|_| StateError::Io)?; + if !opened.is_file() + || !has_admissible_filesystem_link_count(&opened) + || opened.len() != metadata.len() + { + return Err(StateError::ConcurrentMutation); + } + + let mut bytes = Vec::with_capacity(opened.len() as usize); + Read::by_ref(&mut file) + .take((MAX_STATE_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|_| StateError::Io)?; + if bytes.len() > MAX_STATE_BYTES { + return Err(StateError::TooLarge); + } + if file.metadata().map_err(|_| StateError::Io)?.len() as usize != bytes.len() { + return Err(StateError::ConcurrentMutation); + } + Ok(Some(bytes)) +} + +fn has_admissible_filesystem_link_count(metadata: &std::fs::Metadata) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + return metadata.nlink() == 1; + } + #[cfg(windows)] + { + // Windows never mutates an admitted existing state file in place; it + // publishes a synchronized sibling snapshot and replaces only this + // pathname. Stable Rust therefore does not need the nightly-only + // `windows_by_handle` link-count API for safe alias preservation. + let _ = metadata; + return true; + } + #[cfg(not(any(unix, windows)))] + { + let _ = metadata; + false + } +} + +fn parse_state_bytes(bytes: &[u8]) -> Result { + let committed_len = match bytes.iter().rposition(|byte| *byte == b'\n') { + Some(index) => index + 1, + None => 0, + }; + let tail = &bytes[committed_len..]; + if !tail.is_empty() && !is_recoverable_record_prefix(tail) { + return Err(StateError::Corrupt); + } + + let committed = std::str::from_utf8(&bytes[..committed_len]).map_err(|_| StateError::Corrupt)?; + let mut highest: Option = None; + for line in committed.lines() { + let identity = parse_record(line)?; + if let Some(previous) = highest.as_ref() { + if identity.version() < previous.version() { + return Err(StateError::Corrupt); + } + if identity.version() == previous.version() { + return Err(StateError::Corrupt); + } + } + highest = Some(identity); + } + + Ok(ParsedState { + highest, + committed_len, + }) +} + +fn parse_record(line: &str) -> Result { + if line.len() > MAX_RECORD_BYTES { + return Err(StateError::Corrupt); + } + let mut fields = line.split('|'); + if fields.next() != Some("v1") { + return Err(StateError::Corrupt); + } + let version = fields.next().ok_or(StateError::Corrupt)?; + let source_commit = fields.next().ok_or(StateError::Corrupt)?; + let artifact_sha256 = fields.next().ok_or(StateError::Corrupt)?; + if fields.next().is_some() { + return Err(StateError::Corrupt); + } + ReleaseIdentity::new(version, source_commit, artifact_sha256).map_err(|_| StateError::Corrupt) +} + +fn encode_record(identity: &ReleaseIdentity) -> String { + let (major, minor, patch) = identity.version().components(); + format!( + "v1|{major}.{minor}.{patch}|{}|{}\n", + identity.source_commit(), + identity.artifact_sha256() + ) +} + +fn is_recoverable_record_prefix(bytes: &[u8]) -> bool { + if bytes.len() > MAX_RECORD_BYTES || bytes.contains(&b'\n') { + return false; + } + let Ok(text) = std::str::from_utf8(bytes) else { + return false; + }; + if text.len() < RECORD_PREFIX.len() { + return RECORD_PREFIX.starts_with(text); + } + if !text.starts_with(RECORD_PREFIX) { + return false; + } + + let fields: Vec<&str> = text.split('|').collect(); + if fields.len() > 4 || fields.first().copied() != Some("v1") { + return false; + } + if let Some(version) = fields.get(1) { + if !is_version_prefix(version) { + return false; + } + } + if let Some(source) = fields.get(2) { + if source.len() > 40 || !source.bytes().all(is_lower_hex) { + return false; + } + } + if let Some(digest) = fields.get(3) { + if digest.len() > 64 || !digest.bytes().all(is_lower_hex) { + return false; + } + } + true +} + +fn is_version_prefix(value: &str) -> bool { + if value.is_empty() { + return true; + } + if !value.bytes().all(|byte| byte.is_ascii_digit() || byte == b'.') { + return false; + } + let parts: Vec<&str> = value.split('.').collect(); + if parts.len() > 3 { + return false; + } + for (index, part) in parts.iter().enumerate() { + if part.len() > 20 { + return false; + } + if part.len() > 1 && part.starts_with('0') { + return false; + } + if part.is_empty() && index + 1 != parts.len() { + return false; + } + if !part.is_empty() && part.parse::().is_err() { + return false; + } + } + true +} + +fn is_lower_hex(byte: u8) -> bool { + byte.is_ascii_digit() || matches!(byte, b'a'..=b'f') +} + +fn repair_recoverable_tail( + path: &Path, + bytes: &[u8], + committed_len: usize, +) -> Result<(), StateError> { + #[cfg(windows)] + { + return publish_state_snapshot(path, &bytes[..committed_len]); + } + #[cfg(not(windows))] + { + let _ = bytes; + truncate_recoverable_tail(path, committed_len) + } +} + +#[cfg(not(windows))] +fn truncate_recoverable_tail(path: &Path, committed_len: usize) -> Result<(), StateError> { + let metadata = std::fs::symlink_metadata(path).map_err(|_| StateError::Io)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(StateError::NotRegularFile); + } + let file = OpenOptions::new() + .write(true) + .open(path) + .map_err(|_| StateError::Io)?; + if file.metadata().map_err(|_| StateError::Io)?.len() as usize < committed_len { + return Err(StateError::ConcurrentMutation); + } + file.set_len(committed_len as u64) + .map_err(|_| StateError::Io)?; + file.sync_all().map_err(|_| StateError::Io) +} + +#[cfg(not(windows))] +fn open_for_append(path: &Path, existed: bool) -> Result { + if existed { + let metadata = std::fs::symlink_metadata(path).map_err(|_| StateError::Io)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(StateError::NotRegularFile); + } + OpenOptions::new() + .append(true) + .open(path) + .map_err(|_| StateError::Io) + } else { + OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .map_err(|_| StateError::Io) + } +} + +#[cfg(windows)] +fn publish_state_snapshot(path: &Path, committed: &[u8]) -> Result<(), StateError> { + let parent = path.parent().ok_or(StateError::Io)?; + let next_path = parent.join(STATE_NEXT_FILE_NAME); + + match std::fs::symlink_metadata(&next_path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(StateError::NotRegularFile); + } + std::fs::remove_file(&next_path).map_err(|_| StateError::Io)?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err(StateError::Io), + } + + let mut next = OpenOptions::new() + .write(true) + .create_new(true) + .open(&next_path) + .map_err(|_| StateError::Io)?; + if let Err(error) = next.write_all(committed) { + drop(next); + let _ = std::fs::remove_file(&next_path); + let _ = error; + return Err(StateError::Io); + } + if next.sync_all().is_err() { + drop(next); + let _ = std::fs::remove_file(&next_path); + return Err(StateError::Io); + } + if next.metadata().map_err(|_| StateError::Io)?.len() as usize != committed.len() { + drop(next); + let _ = std::fs::remove_file(&next_path); + return Err(StateError::ConcurrentMutation); + } + drop(next); + + if std::fs::rename(&next_path, path).is_err() { + let _ = std::fs::remove_file(&next_path); + return Err(StateError::Io); + } + + let published = std::fs::read(path).map_err(|_| StateError::Io)?; + if published != committed { + return Err(StateError::ConcurrentMutation); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write as _; + use std::path::PathBuf; + use std::sync::atomic::{AtomicU64, Ordering}; + + const SOURCE_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const SOURCE_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const DIGEST_A: &str = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const DIGEST_B: &str = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + static NEXT_TEST_ID: AtomicU64 = AtomicU64::new(1); + + struct TestDirectory(PathBuf); + + impl TestDirectory { + fn new() -> Self { + let id = NEXT_TEST_ID.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "bandscope-distribution-state-{}-{id}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("test directory should be created"); + Self(path) + } + + fn state_path(&self) -> PathBuf { + self.0.join("highest-seen.log") + } + } + + impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn identity(version: &str) -> ReleaseIdentity { + ReleaseIdentity::new(version, SOURCE_A, DIGEST_A).expect("fixture identity should be valid") + } + + #[test] + fn absent_state_loads_as_none() { + let directory = TestDirectory::new(); + assert_eq!(load_highest_seen(&directory.state_path()), Ok(None)); + } + + #[test] + fn append_log_remembers_monotonic_highest_identity() { + let directory = TestDirectory::new(); + let path = directory.state_path(); + let first = identity("1.0.0"); + let second = identity("2.0.0"); + + assert_eq!( + remember_highest_seen(&path, &first), + Ok(RememberOutcome::Remembered) + ); + assert_eq!(load_highest_seen(&path), Ok(Some(first))); + assert_eq!( + remember_highest_seen(&path, &second), + Ok(RememberOutcome::Remembered) + ); + assert_eq!(load_highest_seen(&path), Ok(Some(second))); + } + + #[test] + fn exact_repeat_is_idempotent_without_growing_log() { + let directory = TestDirectory::new(); + let path = directory.state_path(); + let release = identity("1.2.3"); + remember_highest_seen(&path, &release).expect("first append should succeed"); + let original_len = std::fs::metadata(&path).expect("state metadata").len(); + + assert_eq!( + remember_highest_seen(&path, &release), + Ok(RememberOutcome::AlreadyRemembered) + ); + assert_eq!( + std::fs::metadata(&path).expect("state metadata").len(), + original_len + ); + } + + #[test] + fn replay_and_same_version_equivocation_fail_closed() { + let directory = TestDirectory::new(); + let path = directory.state_path(); + let highest = identity("2.0.0"); + remember_highest_seen(&path, &highest).expect("highest append should succeed"); + + assert_eq!(remember_highest_seen(&path, &identity("1.9.9")), Err(StateError::Replay)); + let conflicting = ReleaseIdentity::new("2.0.0", SOURCE_B, DIGEST_B) + .expect("conflicting identity should be structurally valid"); + assert_eq!( + remember_highest_seen(&path, &conflicting), + Err(StateError::Equivocation) + ); + assert_eq!(load_highest_seen(&path), Ok(Some(highest))); + } + + #[test] + fn recoverable_torn_tail_keeps_previous_record_and_is_repaired_on_append() { + let directory = TestDirectory::new(); + let path = directory.state_path(); + let first = identity("1.0.0"); + let second = identity("2.0.0"); + remember_highest_seen(&path, &first).expect("first append should succeed"); + + let mut file = OpenOptions::new() + .append(true) + .open(&path) + .expect("test state should open"); + file.write_all(b"v1|2.0").expect("partial tail should write"); + file.sync_all().expect("partial tail should sync for the fixture"); + + assert_eq!(load_highest_seen(&path), Ok(Some(first))); + assert_eq!( + remember_highest_seen(&path, &second), + Ok(RememberOutcome::Remembered) + ); + assert_eq!(load_highest_seen(&path), Ok(Some(second))); + let bytes = std::fs::read(&path).expect("state should be readable"); + assert!(bytes.ends_with(b"\n")); + assert!(!String::from_utf8(bytes).expect("state should be utf-8").contains("v1|2.0v1|")); + } + + #[test] + fn malformed_committed_record_and_invalid_tail_are_rejected() { + let directory = TestDirectory::new(); + let path = directory.state_path(); + std::fs::write(&path, b"broken\n").expect("fixture should write"); + assert_eq!(load_highest_seen(&path), Err(StateError::Corrupt)); + + std::fs::write(&path, b"v1|1.0.0|aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa|aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\ngarbage") + .expect("fixture should write"); + assert_eq!(load_highest_seen(&path), Err(StateError::Corrupt)); + } + + #[test] + fn non_regular_and_oversized_state_are_rejected() { + let directory = TestDirectory::new(); + assert_eq!(load_highest_seen(&directory.0), Err(StateError::NotRegularFile)); + + let path = directory.state_path(); + std::fs::write(&path, vec![b'x'; MAX_STATE_BYTES + 1]).expect("oversized fixture should write"); + assert_eq!(load_highest_seen(&path), Err(StateError::TooLarge)); + } + + #[cfg(unix)] + #[test] + fn symlink_state_is_rejected() { + use std::os::unix::fs::symlink; + + let directory = TestDirectory::new(); + let target = directory.0.join("target.log"); + std::fs::write(&target, b"").expect("target should write"); + let path = directory.state_path(); + symlink(&target, &path).expect("symlink fixture should be created"); + assert_eq!(load_highest_seen(&path), Err(StateError::NotRegularFile)); + } +} diff --git a/apps/desktop/distribution-state/tests/concurrent_writer.rs b/apps/desktop/distribution-state/tests/concurrent_writer.rs new file mode 100644 index 000000000..3bba329a4 --- /dev/null +++ b/apps/desktop/distribution-state/tests/concurrent_writer.rs @@ -0,0 +1,83 @@ +//! Cross-process lease contract for Distribution highest-seen freshness state. + +use bandscope_distribution_core::ReleaseIdentity; +use bandscope_distribution_state::{ + load_highest_seen, remember_highest_seen, RememberOutcome, StateError, +}; +use std::fs::OpenOptions; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +const SOURCE: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const STATE_LEASE_FILE_NAME: &str = ".bandscope-highest-seen.lock"; +static NEXT_TEST_ID: AtomicU64 = AtomicU64::new(1); + +struct TestDirectory(PathBuf); + +impl TestDirectory { + fn new() -> Self { + let id = NEXT_TEST_ID.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "bandscope-distribution-state-lease-{}-{id}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("test directory should be created"); + Self(path) + } + + fn state_path(&self) -> PathBuf { + self.0.join("highest-seen.log") + } +} + +impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn lease_path(state_path: &Path) -> PathBuf { + state_path + .parent() + .expect("test state has a parent") + .join(STATE_LEASE_FILE_NAME) +} + +fn identity(version: &str) -> ReleaseIdentity { + ReleaseIdentity::new(version, SOURCE, DIGEST).expect("fixture identity should be valid") +} + +#[test] +fn active_cross_process_lease_blocks_stale_read_and_competing_append() { + let directory = TestDirectory::new(); + let state_path = directory.state_path(); + let lease_path = lease_path(&state_path); + let lease = OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(&lease_path) + .expect("lease fixture should open"); + lease.try_lock().expect("fixture should own the state lease"); + + assert_eq!( + load_highest_seen(&state_path), + Err(StateError::ConcurrentMutation) + ); + assert_eq!( + remember_highest_seen(&state_path, &identity("1.0.0")), + Err(StateError::ConcurrentMutation) + ); + assert!( + !state_path.exists(), + "competing writer must not create freshness state while the lease is held" + ); + + drop(lease); + assert_eq!( + remember_highest_seen(&state_path, &identity("1.0.0")), + Ok(RememberOutcome::Remembered) + ); + assert_eq!(load_highest_seen(&state_path), Ok(Some(identity("1.0.0")))); +} diff --git a/apps/desktop/distribution-state/tests/hard_link_admission.rs b/apps/desktop/distribution-state/tests/hard_link_admission.rs new file mode 100644 index 000000000..65ad9ff1c --- /dev/null +++ b/apps/desktop/distribution-state/tests/hard_link_admission.rs @@ -0,0 +1,63 @@ +#![cfg(unix)] + +use bandscope_distribution_core::ReleaseIdentity; +use bandscope_distribution_state::{load_highest_seen, remember_highest_seen, StateError}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +const SOURCE: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const DIGEST: &str = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +static NEXT_TEST_ID: AtomicU64 = AtomicU64::new(1); + +struct TestDirectory(PathBuf); + +impl TestDirectory { + fn new() -> Self { + let id = NEXT_TEST_ID.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "bandscope-distribution-state-hard-link-{}-{id}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("test directory should be created"); + Self(path) + } + + fn join(&self, name: &str) -> PathBuf { + self.0.join(name) + } +} + +impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn identity(version: &str) -> ReleaseIdentity { + ReleaseIdentity::new(version, SOURCE, DIGEST).expect("fixture identity should be valid") +} + +fn bytes(path: &Path) -> Vec { + std::fs::read(path).expect("fixture should remain readable") +} + +#[test] +fn hard_linked_state_is_not_accepted_or_mutated() { + let directory = TestDirectory::new(); + let authority_target = directory.join("authority-target.log"); + let state_path = directory.join("highest-seen.log"); + let first = identity("1.0.0"); + let second = identity("2.0.0"); + + remember_highest_seen(&authority_target, &first).expect("fixture state should be created"); + let original = bytes(&authority_target); + std::fs::hard_link(&authority_target, &state_path).expect("hard-link fixture should be created"); + + assert_eq!(load_highest_seen(&state_path), Err(StateError::NotRegularFile)); + assert_eq!( + remember_highest_seen(&state_path, &second), + Err(StateError::NotRegularFile) + ); + assert_eq!(bytes(&authority_target), original); +} diff --git a/apps/desktop/distribution-state/tests/hard_link_snapshot_windows.rs b/apps/desktop/distribution-state/tests/hard_link_snapshot_windows.rs new file mode 100644 index 000000000..c068f4de0 --- /dev/null +++ b/apps/desktop/distribution-state/tests/hard_link_snapshot_windows.rs @@ -0,0 +1,96 @@ +#![cfg(windows)] + +use bandscope_distribution_core::ReleaseIdentity; +use bandscope_distribution_state::{load_highest_seen, remember_highest_seen, RememberOutcome}; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +const SOURCE: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const DIGEST: &str = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +static NEXT_TEST_ID: AtomicU64 = AtomicU64::new(1); + +struct TestDirectory(PathBuf); + +impl TestDirectory { + fn new() -> Self { + let id = NEXT_TEST_ID.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "bandscope-distribution-state-windows-hard-link-{}-{id}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("test directory should be created"); + Self(path) + } + + fn join(&self, name: &str) -> PathBuf { + self.0.join(name) + } +} + +impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn identity(version: &str) -> ReleaseIdentity { + ReleaseIdentity::new(version, SOURCE, DIGEST).expect("fixture identity should be valid") +} + +fn bytes(path: &Path) -> Vec { + std::fs::read(path).expect("fixture should remain readable") +} + +#[test] +fn monotonic_update_replaces_state_path_without_mutating_hard_link_alias() { + let directory = TestDirectory::new(); + let authority_target = directory.join("authority-target.log"); + let state_path = directory.join("highest-seen.log"); + let first = identity("1.0.0"); + let second = identity("2.0.0"); + + remember_highest_seen(&authority_target, &first).expect("fixture state should be created"); + let original_alias_bytes = bytes(&authority_target); + std::fs::hard_link(&authority_target, &state_path).expect("hard-link fixture should be created"); + + assert_eq!(load_highest_seen(&state_path), Ok(Some(first.clone()))); + assert_eq!( + remember_highest_seen(&state_path, &second), + Ok(RememberOutcome::Remembered) + ); + assert_eq!(load_highest_seen(&state_path), Ok(Some(second))); + assert_eq!(bytes(&authority_target), original_alias_bytes); +} + +#[test] +fn torn_tail_repair_replaces_state_path_without_truncating_hard_link_alias() { + let directory = TestDirectory::new(); + let authority_target = directory.join("authority-target.log"); + let state_path = directory.join("highest-seen.log"); + let first = identity("1.0.0"); + + remember_highest_seen(&authority_target, &first).expect("fixture state should be created"); + let mut alias_writer = std::fs::OpenOptions::new() + .append(true) + .open(&authority_target) + .expect("fixture alias should open"); + alias_writer + .write_all(b"v1|2.0") + .expect("recoverable tail should be written"); + alias_writer.sync_all().expect("fixture tail should sync"); + drop(alias_writer); + + let alias_with_tail = bytes(&authority_target); + std::fs::hard_link(&authority_target, &state_path).expect("hard-link fixture should be created"); + + assert_eq!(load_highest_seen(&state_path), Ok(Some(first.clone()))); + assert_eq!( + remember_highest_seen(&state_path, &first), + Ok(RememberOutcome::AlreadyRemembered) + ); + assert_eq!(load_highest_seen(&state_path), Ok(Some(first))); + assert_eq!(bytes(&authority_target), alias_with_tail); + assert!(!bytes(&state_path).ends_with(b"v1|2.0")); +} diff --git a/apps/desktop/distribution-state/tests/impossible_torn_tail.rs b/apps/desktop/distribution-state/tests/impossible_torn_tail.rs new file mode 100644 index 000000000..b5181d575 --- /dev/null +++ b/apps/desktop/distribution-state/tests/impossible_torn_tail.rs @@ -0,0 +1,58 @@ +use bandscope_distribution_state::{load_highest_seen, StateError}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; + +static NEXT_TEST_ID: AtomicU64 = AtomicU64::new(1); + +struct TestDirectory(PathBuf); + +impl TestDirectory { + fn new() -> Self { + let id = NEXT_TEST_ID.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "bandscope-distribution-state-impossible-tail-{}-{id}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("test directory should be created"); + Self(path) + } + + fn state_path(&self) -> PathBuf { + self.0.join("highest-seen.log") + } +} + +impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[test] +fn impossible_u64_version_component_is_not_recoverable_torn_tail() { + let directory = TestDirectory::new(); + let path = directory.state_path(); + + for tail in [ + b"v1|18446744073709551616".as_slice(), + b"v1|1.18446744073709551616".as_slice(), + b"v1|1.2.18446744073709551616".as_slice(), + ] { + std::fs::write(&path, tail).expect("fixture should write"); + assert_eq!(load_highest_seen(&path), Err(StateError::Corrupt)); + } +} + +#[test] +fn still_extendable_version_prefix_remains_recoverable() { + let directory = TestDirectory::new(); + let path = directory.state_path(); + + for tail in [ + b"v1|2.0".as_slice(), + b"v1|18446744073709551615".as_slice(), + ] { + std::fs::write(&path, tail).expect("fixture should write"); + assert_eq!(load_highest_seen(&path), Ok(None)); + } +} diff --git a/apps/desktop/distribution-transport/Cargo.lock b/apps/desktop/distribution-transport/Cargo.lock new file mode 100644 index 000000000..18bbfb6f1 --- /dev/null +++ b/apps/desktop/distribution-transport/Cargo.lock @@ -0,0 +1,26 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bandscope-distribution-core" +version = "0.1.0" + +[[package]] +name = "bandscope-distribution-download" +version = "0.1.0" + +[[package]] +name = "bandscope-distribution-runtime" +version = "0.1.0" +dependencies = [ + "bandscope-distribution-core", +] + +[[package]] +name = "bandscope-distribution-transport" +version = "0.1.0" +dependencies = [ + "bandscope-distribution-download", + "bandscope-distribution-runtime", +] diff --git a/apps/desktop/distribution-transport/Cargo.toml b/apps/desktop/distribution-transport/Cargo.toml new file mode 100644 index 000000000..7e4215986 --- /dev/null +++ b/apps/desktop/distribution-transport/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "bandscope-distribution-transport" +version = "0.1.0" +edition = "2021" +description = "Updater transport admission bridge for BandScope Distribution." +publish = false + +[dependencies] +bandscope-distribution-download = { path = "../distribution-download" } +bandscope-distribution-runtime = { path = "../distribution-runtime" } + +[workspace] + +[lints.rust] +unsafe_code = "forbid" +warnings = "deny" +missing_docs = "deny" diff --git a/apps/desktop/distribution-transport/src/lib.rs b/apps/desktop/distribution-transport/src/lib.rs new file mode 100644 index 000000000..6c19cdf64 --- /dev/null +++ b/apps/desktop/distribution-transport/src/lib.rs @@ -0,0 +1,521 @@ +//! Fail-closed transport admission between updater metadata and staged bytes. +//! +//! This Distribution-owned boundary consumes `ProvisionalUpdateMetadata` +//! directly, so transport code never reparses remote updater JSON. It admits +//! response status/effective-URL evidence and routes body chunks through +//! `bandscope-distribution-download`. It deliberately does not perform network +//! I/O, metadata authentication, artifact signature verification, installation, +//! freshness-state mutation, or project persistence. + +#![forbid(unsafe_code)] + +use bandscope_distribution_download::{ + ArtifactDownloadAdmission, DownloadAdmissionError, SealedArtifactFile, SealedArtifactReader, + StagedArtifactFile, StagingArtifactError, +}; +use bandscope_distribution_runtime::ProvisionalUpdateMetadata; +use std::fmt; +use std::path::Path; + +/// Maximum redirect location accepted from one release-asset response. +pub const MAX_REDIRECT_URL_BYTES: usize = 16 * 1024; + +const RELEASE_ASSET_CDN_PREFIX: &str = "https://release-assets.githubusercontent.com/"; +const REDACTED_SIGNATURE: &str = ""; + +struct RedactedUrl<'a>(&'a str); + +impl fmt::Debug for RedactedUrl<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.0.split_once('?') { + Some((base, _)) => write!(formatter, "{base}?"), + None => formatter.write_str(self.0), + } + } +} + +#[derive(Clone, Eq, PartialEq)] +struct ProvisionalPolicyIdentity { + version_components: (u64, u64, u64), + source_commit: String, + target: String, + minimum_supported_version_components: (u64, u64, u64), +} + +/// Fail-closed reasons for updater transport-policy admission. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TransportPolicyError { + /// The provisional updater URL did not contain a direct artifact basename. + InvalidAdmittedArtifactUrl, + /// The HTTP stack reports an effective URL different from the admitted request URL. + EffectiveUrlDrift, + /// The initial response status is not an admitted direct-download or redirect status. + UnexpectedInitialStatus(u16), + /// A redirect response omitted or supplied an invalid Location value. + InvalidRedirectLocation, + /// A redirect decision originated from different provisional transport identity. + RedirectPolicyMismatch, + /// The redirected request completed at a URL different from the admitted Location. + RedirectEffectiveUrlDrift, + /// A redirected release-asset request attempted another redirect. + RedirectChainingRejected, + /// The redirected response did not terminate in an admitted success status. + UnexpectedRedirectStatus(u16), +} + +/// Failure while converting an admitted response head into bounded staged bytes. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TransportDownloadError { + /// The response declared a content coding that would transform artifact bytes. + UnsupportedContentEncoding, + /// Byte-count or content-length admission failed. + Download(DownloadAdmissionError), + /// App-owned staging-file admission or sealing failed. + Staging(StagingArtifactError), +} + +/// A one-hop release-asset redirect admitted by Distribution policy. +#[derive(Clone, Eq, PartialEq)] +pub struct AdmittedRedirect { + source_url: String, + location: String, + policy_identity: ProvisionalPolicyIdentity, + expected_size_bytes: u64, + expected_artifact_sha256: String, + artifact_signature: String, +} + +impl fmt::Debug for AdmittedRedirect { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AdmittedRedirect") + .field("source_url", &self.source_url) + .field("location", &RedactedUrl(&self.location)) + .finish() + } +} + +impl AdmittedRedirect { + /// Return the exact HTTPS redirect target the network adapter may request. + pub fn location(&self) -> &str { + &self.location + } +} + +/// An admitted final response whose body may enter bounded staging. +#[derive(Clone, Eq, PartialEq)] +pub struct AdmittedDownloadHead { + effective_url: String, + artifact_name: String, + policy_identity: ProvisionalPolicyIdentity, + expected_size_bytes: u64, + expected_artifact_sha256: String, + artifact_signature: String, +} + +impl fmt::Debug for AdmittedDownloadHead { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AdmittedDownloadHead") + .field("effective_url", &RedactedUrl(&self.effective_url)) + .field("artifact_name", &self.artifact_name) + .field("expected_size_bytes", &self.expected_size_bytes) + .field("expected_artifact_sha256", &self.expected_artifact_sha256) + .field("artifact_signature", &REDACTED_SIGNATURE) + .finish() + } +} + +impl AdmittedDownloadHead { + /// Return the exact final URL admitted for this response body. + pub fn effective_url(&self) -> &str { + &self.effective_url + } + + /// Return the safe app-owned staging basename derived from strict metadata admission. + pub fn artifact_name(&self) -> &str { + &self.artifact_name + } + + /// Return the provisional expected byte length retained from strict metadata admission. + pub const fn expected_size_bytes(&self) -> u64 { + self.expected_size_bytes + } + + /// Return the provisional artifact digest retained from strict metadata admission. + pub fn expected_artifact_sha256(&self) -> &str { + &self.expected_artifact_sha256 + } + + /// Return the provisional Tauri updater signature retained from strict metadata admission. + pub fn artifact_signature(&self) -> &str { + &self.artifact_signature + } + + /// Start one bounded staged body after response-head admission succeeds. + /// + /// The admitted response head is consumed so its candidate identity and + /// artifact evidence cannot be detached from the staging attempt. Content- + /// encoding and content-length admission run before filesystem mutation. + /// Updater signatures and digests are defined over exact release artifact + /// bytes, so any response content coding other than the explicit identity + /// coding is rejected rather than relying on HTTP-client decompression + /// behavior. `None` means the response omitted the header. + pub fn start_staging( + self, + staging_directory: &Path, + response_content_length: Option, + response_content_encoding: Option<&str>, + ) -> Result { + if response_content_encoding + .is_some_and(|encoding| !encoding.eq_ignore_ascii_case("identity")) + { + return Err(TransportDownloadError::UnsupportedContentEncoding); + } + let admission = ArtifactDownloadAdmission::new( + self.expected_size_bytes, + response_content_length, + ) + .map_err(TransportDownloadError::Download)?; + let staged = StagedArtifactFile::create(staging_directory, &self.artifact_name) + .map_err(TransportDownloadError::Staging)?; + Ok(TransportDownload { + head: Some(self), + admission: Some(admission), + staged: Some(staged), + }) + } +} + +/// Required next action after admitting one HTTP response head. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ResponseDecision { + /// Stream this response body into the bounded staging boundary. + Download(AdmittedDownloadHead), + /// Follow exactly one admitted release-asset redirect. + FollowRedirect(AdmittedRedirect), +} + +/// Deterministic transport policy derived from one strictly admitted updater target. +#[derive(Clone, Eq, PartialEq)] +pub struct ReleaseTransportPolicy { + initial_url: String, + artifact_name: String, + policy_identity: ProvisionalPolicyIdentity, + expected_size_bytes: u64, + expected_artifact_sha256: String, + artifact_signature: String, +} + +impl fmt::Debug for ReleaseTransportPolicy { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ReleaseTransportPolicy") + .field("initial_url", &self.initial_url) + .field("artifact_name", &self.artifact_name) + .field("expected_size_bytes", &self.expected_size_bytes) + .field("expected_artifact_sha256", &self.expected_artifact_sha256) + .field("artifact_signature", &REDACTED_SIGNATURE) + .finish() + } +} + +impl ReleaseTransportPolicy { + /// Build transport policy from the same strict provisional metadata parse. + /// + /// No raw JSON is accepted here. The URL, signature, size and digest are + /// copied from `ProvisionalUpdateMetadata` and remain provisional evidence. + /// `distribution-runtime` already requires every supported platform's Tauri + /// signature to use the canonical standard-base64 outer envelope, so this + /// transport layer does not duplicate that metadata syntax authority. This + /// still does not verify the minisign payload or authenticate the remote + /// metadata that carried it. + pub fn from_provisional( + metadata: &ProvisionalUpdateMetadata, + ) -> Result { + let initial_url = metadata.artifact_url(); + let artifact_name = initial_url + .rsplit_once('/') + .map(|(_, name)| name) + .filter(|name| !name.is_empty()) + .ok_or(TransportPolicyError::InvalidAdmittedArtifactUrl)?; + Ok(Self { + initial_url: initial_url.to_owned(), + artifact_name: artifact_name.to_owned(), + policy_identity: ProvisionalPolicyIdentity { + version_components: metadata.version_components(), + source_commit: metadata.source_commit().to_owned(), + target: metadata.target().to_owned(), + minimum_supported_version_components: metadata + .minimum_supported_version_components(), + }, + expected_size_bytes: metadata.artifact_size_bytes(), + expected_artifact_sha256: metadata.expected_artifact_sha256().to_owned(), + artifact_signature: metadata.artifact_signature().to_owned(), + }) + } + + /// Return the exact initial release URL admitted by the metadata boundary. + pub fn initial_url(&self) -> &str { + &self.initial_url + } + + /// Admit the first HTTP response without trusting automatic redirect behavior. + /// + /// The network adapter must disable automatic redirects and report the exact + /// effective URL plus an optional `Location` value. A direct `200` can + /// stream immediately. A `302` is admitted only when its Location is an + /// HTTPS `release-assets.githubusercontent.com` URL and becomes a distinct + /// one-hop follow-up decision; no redirect body is exposed for staging. + pub fn admit_initial_response( + &self, + status: u16, + effective_url: &str, + redirect_location: Option<&str>, + ) -> Result { + if effective_url != self.initial_url { + return Err(TransportPolicyError::EffectiveUrlDrift); + } + match status { + 200 => Ok(ResponseDecision::Download(self.download_head(effective_url))), + 302 => { + let location = redirect_location + .ok_or(TransportPolicyError::InvalidRedirectLocation)?; + validate_release_asset_cdn_url(location)?; + Ok(ResponseDecision::FollowRedirect(AdmittedRedirect { + source_url: self.initial_url.clone(), + location: location.to_owned(), + policy_identity: self.policy_identity.clone(), + expected_size_bytes: self.expected_size_bytes, + expected_artifact_sha256: self.expected_artifact_sha256.clone(), + artifact_signature: self.artifact_signature.clone(), + })) + } + other => Err(TransportPolicyError::UnexpectedInitialStatus(other)), + } + } + + /// Admit the response produced by one previously admitted redirect. + /// + /// The redirect token is bound to the full provisional release candidate + /// identity plus the same artifact URL, size, digest and updater signature + /// that admitted its first response. It cannot be replayed across another + /// metadata projection that changes source commit or minimum-supported + /// version while reusing the same release artifact evidence. A second + /// redirect is never followed. Only a final `200` at the exact admitted + /// Location can expose a body to `distribution-download`. + pub fn admit_redirect_response( + &self, + redirect: &AdmittedRedirect, + status: u16, + effective_url: &str, + ) -> Result { + if redirect.source_url != self.initial_url + || redirect.policy_identity != self.policy_identity + || redirect.expected_size_bytes != self.expected_size_bytes + || redirect.expected_artifact_sha256 != self.expected_artifact_sha256 + || redirect.artifact_signature != self.artifact_signature + { + return Err(TransportPolicyError::RedirectPolicyMismatch); + } + if effective_url != redirect.location { + return Err(TransportPolicyError::RedirectEffectiveUrlDrift); + } + if (300..400).contains(&status) { + return Err(TransportPolicyError::RedirectChainingRejected); + } + if status != 200 { + return Err(TransportPolicyError::UnexpectedRedirectStatus(status)); + } + Ok(self.download_head(effective_url)) + } + + fn download_head(&self, effective_url: &str) -> AdmittedDownloadHead { + AdmittedDownloadHead { + effective_url: effective_url.to_owned(), + artifact_name: self.artifact_name.clone(), + policy_identity: self.policy_identity.clone(), + expected_size_bytes: self.expected_size_bytes, + expected_artifact_sha256: self.expected_artifact_sha256.clone(), + artifact_signature: self.artifact_signature.clone(), + } + } +} + +/// Synchronized but still-unverified artifact bound to its transport evidence. +/// +/// This value owns the exact `SealedArtifactFile` descriptor together with the +/// provisional release-candidate identity, final effective URL, expected size, +/// digest, and updater signature that admitted its bytes. Keeping those values +/// in one move-only object prevents later verification code from accidentally +/// pairing a sealed descriptor with evidence copied from another candidate. +/// This is evidence continuity only, not metadata authentication or verified- +/// artifact promotion. +pub struct SealedTransportArtifact { + head: AdmittedDownloadHead, + sealed: SealedArtifactFile, +} + +impl fmt::Debug for SealedTransportArtifact { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SealedTransportArtifact") + .field("effective_url", &RedactedUrl(&self.head.effective_url)) + .field("artifact_name", &self.head.artifact_name) + .field("expected_size_bytes", &self.head.expected_size_bytes) + .field("bytes_written", &self.sealed.bytes_written()) + .field("artifact_signature", &REDACTED_SIGNATURE) + .finish() + } +} + +impl SealedTransportArtifact { + /// Return the provisional release version bound to this exact descriptor. + pub const fn version_components(&self) -> (u64, u64, u64) { + self.head.policy_identity.version_components + } + + /// Return the provisional source commit bound to this exact descriptor. + pub fn source_commit(&self) -> &str { + &self.head.policy_identity.source_commit + } + + /// Return the provisional target bound to this exact descriptor. + pub fn target(&self) -> &str { + &self.head.policy_identity.target + } + + /// Return the provisional minimum-supported version bound to this descriptor. + pub const fn minimum_supported_version_components(&self) -> (u64, u64, u64) { + self.head + .policy_identity + .minimum_supported_version_components + } + + /// Return the exact final response URL that produced this descriptor. + pub fn effective_url(&self) -> &str { + &self.head.effective_url + } + + /// Return the admitted app-owned artifact basename. + pub fn artifact_name(&self) -> &str { + &self.head.artifact_name + } + + /// Return the provisional expected byte length bound to this descriptor. + pub const fn expected_size_bytes(&self) -> u64 { + self.head.expected_size_bytes + } + + /// Return the provisional SHA-256 value bound to this descriptor. + pub fn expected_artifact_sha256(&self) -> &str { + &self.head.expected_artifact_sha256 + } + + /// Return the provisional updater signature bound to this descriptor. + pub fn artifact_signature(&self) -> &str { + &self.head.artifact_signature + } + + /// Return the direct child path held by the sealed descriptor owner. + pub fn path(&self) -> &Path { + self.sealed.path() + } + + /// Return the exact admitted byte count held by the sealed descriptor. + pub const fn bytes_written(&self) -> u64 { + self.sealed.bytes_written() + } + + /// Borrow a positional read-only stream over the exact sealed descriptor. + /// + /// This delegates to `distribution-download` and never reopens the staging + /// pathname, preserving the descriptor identity required by later digest + /// and signature verification. + pub fn reader(&self) -> SealedArtifactReader<'_> { + self.sealed.reader() + } +} + +/// One response body being admitted into an exclusive staging artifact. +#[derive(Debug)] +pub struct TransportDownload { + head: Option, + admission: Option, + staged: Option, +} + +impl TransportDownload { + /// Return the direct child path reserved for this in-flight response. + /// + /// This borrows the path already owned by `StagedArtifactFile`; it does not + /// reopen the artifact or expose the underlying file descriptor. + pub fn path(&self) -> &Path { + self.staged + .as_ref() + .expect("transport staging file remains present before finish") + .path() + } + + /// Admit one already-bounded network chunk into the staged artifact. + pub fn admit_chunk(&mut self, chunk: &[u8]) -> Result<(), TransportDownloadError> { + let admission = self + .admission + .as_mut() + .expect("transport admission remains present before finish"); + let staged = self + .staged + .as_mut() + .expect("transport staging file remains present before finish"); + staged + .admit_chunk(admission, chunk) + .map_err(TransportDownloadError::Download) + } + + /// Finish an exact response and keep its identity with the sealed descriptor. + /// + /// Failure leaves the staging value owned by this consumed object, so its + /// existing drop cleanup removes partial or unverified bytes. Success still + /// does not promote trust: the returned value remains cleanup-on-drop until + /// later metadata, digest, and updater-signature verification succeeds. + pub fn finish(mut self) -> Result { + let admission = self + .admission + .take() + .expect("transport admission remains present before finish"); + let receipt = admission.finish().map_err(TransportDownloadError::Download)?; + let staged = self + .staged + .take() + .expect("transport staging file remains present before finish"); + let sealed = staged + .seal(receipt) + .map_err(TransportDownloadError::Staging)?; + let head = self + .head + .take() + .expect("transport response identity remains present before finish"); + Ok(SealedTransportArtifact { head, sealed }) + } +} + +fn validate_release_asset_cdn_url(value: &str) -> Result<(), TransportPolicyError> { + if value.is_empty() + || value.len() > MAX_REDIRECT_URL_BYTES + || value + .bytes() + .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace()) + || value.contains('#') + || value.contains('\\') + { + return Err(TransportPolicyError::InvalidRedirectLocation); + } + let remainder = value + .strip_prefix(RELEASE_ASSET_CDN_PREFIX) + .ok_or(TransportPolicyError::InvalidRedirectLocation)?; + let path = remainder.split_once('?').map_or(remainder, |(path, _)| path); + if path.is_empty() || path.starts_with('/') { + return Err(TransportPolicyError::InvalidRedirectLocation); + } + Ok(()) +} diff --git a/apps/desktop/distribution-transport/tests/redirect_policy_identity.rs b/apps/desktop/distribution-transport/tests/redirect_policy_identity.rs new file mode 100644 index 000000000..e4c14fc01 --- /dev/null +++ b/apps/desktop/distribution-transport/tests/redirect_policy_identity.rs @@ -0,0 +1,53 @@ +use bandscope_distribution_runtime::admit_untrusted_raw_json; +use bandscope_distribution_transport::{ + ReleaseTransportPolicy, ResponseDecision, TransportPolicyError, +}; + +const SOURCE_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; +const OTHER_SOURCE_COMMIT: &str = "1111111111111111111111111111111111111111"; +const DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const INITIAL_URL: &str = "https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-windows-x86_64.zip"; +const CDN_URL: &str = "https://release-assets.githubusercontent.com/github-production-release-asset/1178322014/update.zip?sp=r&sv=2021-08-06&sr=b"; + +fn policy(source_commit: &str, minimum_supported_version: &str) -> ReleaseTransportPolicy { + let document = format!( + r#"{{"version":"1.2.3","platforms":{{"windows-x86_64":{{"signature":"c2ln","url":"{INITIAL_URL}"}},"windows-aarch64":{{"signature":"c2ln","url":"https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-windows-aarch64.zip"}},"darwin-x86_64":{{"signature":"c2ln","url":"https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-darwin-x86_64.tar.gz"}},"darwin-aarch64":{{"signature":"c2ln","url":"https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-darwin-aarch64.tar.gz"}}}},"bandscope":{{"schemaVersion":1,"sourceCommit":"{source_commit}","minimumSupportedVersion":"{minimum_supported_version}","artifacts":{{"windows-x86_64":{{"sizeBytes":4,"sha256":"{DIGEST}"}},"windows-aarch64":{{"sizeBytes":5,"sha256":"{DIGEST}"}},"darwin-x86_64":{{"sizeBytes":6,"sha256":"{DIGEST}"}},"darwin-aarch64":{{"sizeBytes":7,"sha256":"{DIGEST}"}}}}}}}}"# + ); + let metadata = admit_untrusted_raw_json(document.as_bytes(), "windows-x86_64") + .expect("fixture must satisfy provisional metadata admission"); + ReleaseTransportPolicy::from_provisional(&metadata).expect("transport projection") +} + +fn redirect_from(policy: &ReleaseTransportPolicy) -> bandscope_distribution_transport::AdmittedRedirect { + match policy + .admit_initial_response(302, INITIAL_URL, Some(CDN_URL)) + .expect("originating policy admits one redirect") + { + ResponseDecision::FollowRedirect(redirect) => redirect, + ResponseDecision::Download(_) => panic!("302 must require a redirect follow-up"), + } +} + +#[test] +fn redirect_decision_rejects_different_source_commit_with_same_artifact_evidence() { + let originating_policy = policy(SOURCE_COMMIT, "0.1.3"); + let different_policy = policy(OTHER_SOURCE_COMMIT, "0.1.3"); + let redirect = redirect_from(&originating_policy); + + assert_eq!( + different_policy.admit_redirect_response(&redirect, 200, CDN_URL), + Err(TransportPolicyError::RedirectPolicyMismatch) + ); +} + +#[test] +fn redirect_decision_rejects_different_minimum_supported_version() { + let originating_policy = policy(SOURCE_COMMIT, "0.1.3"); + let different_policy = policy(SOURCE_COMMIT, "0.1.2"); + let redirect = redirect_from(&originating_policy); + + assert_eq!( + different_policy.admit_redirect_response(&redirect, 200, CDN_URL), + Err(TransportPolicyError::RedirectPolicyMismatch) + ); +} diff --git a/apps/desktop/distribution-transport/tests/transport_diagnostics.rs b/apps/desktop/distribution-transport/tests/transport_diagnostics.rs new file mode 100644 index 000000000..9a11f315a --- /dev/null +++ b/apps/desktop/distribution-transport/tests/transport_diagnostics.rs @@ -0,0 +1,113 @@ +use bandscope_distribution_runtime::admit_untrusted_raw_json; +use bandscope_distribution_transport::{ReleaseTransportPolicy, ResponseDecision}; +use std::fs; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +const SOURCE_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; +const DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const INITIAL_URL: &str = "https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-windows-x86_64.zip"; +const CDN_URL_WITH_QUERY: &str = "https://release-assets.githubusercontent.com/github-production-release-asset/1178322014/update.zip?opaque=provider-query-value"; + +fn policy() -> ReleaseTransportPolicy { + let document = format!( + r#"{{"version":"1.2.3","platforms":{{"windows-x86_64":{{"signature":"c2ln","url":"{INITIAL_URL}"}},"windows-aarch64":{{"signature":"c2ln","url":"https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-windows-aarch64.zip"}},"darwin-x86_64":{{"signature":"c2ln","url":"https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-darwin-x86_64.tar.gz"}},"darwin-aarch64":{{"signature":"c2ln","url":"https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-darwin-aarch64.tar.gz"}}}},"bandscope":{{"schemaVersion":1,"sourceCommit":"{SOURCE_COMMIT}","minimumSupportedVersion":"0.1.3","artifacts":{{"windows-x86_64":{{"sizeBytes":4,"sha256":"{DIGEST}"}},"windows-aarch64":{{"sizeBytes":5,"sha256":"{DIGEST}"}},"darwin-x86_64":{{"sizeBytes":6,"sha256":"{DIGEST}"}},"darwin-aarch64":{{"sizeBytes":7,"sha256":"{DIGEST}"}}}}}}}}"# + ); + let metadata = admit_untrusted_raw_json(document.as_bytes(), "windows-x86_64") + .expect("fixture must satisfy provisional metadata admission"); + ReleaseTransportPolicy::from_provisional(&metadata).expect("transport projection") +} + +fn scratch_dir(label: &str) -> std::path::PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock after unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "bandscope-distribution-transport-diagnostics-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir(&path).expect("create isolated diagnostics staging directory"); + path +} + +fn remove_sealed_fixture(path: &Path) { + #[cfg(unix)] + assert!(!path.exists(), "Unix removes the descriptor-owned staging path"); + + #[cfg(not(unix))] + if path.is_file() { + fs::remove_file(path).expect("remove deferred sealed diagnostics fixture"); + } +} + +#[test] +fn redirect_query_and_signature_are_redacted_from_debug_surfaces() { + let policy = policy(); + let policy_debug = format!("{policy:?}"); + assert!(policy_debug.contains("")); + assert!(!policy_debug.contains("c2ln")); + + let redirect = match policy + .admit_initial_response(302, INITIAL_URL, Some(CDN_URL_WITH_QUERY)) + .expect("CDN redirect with an opaque provider query should be admitted") + { + ResponseDecision::FollowRedirect(redirect) => redirect, + ResponseDecision::Download(_) => panic!("302 must produce a redirect decision"), + }; + + let redirect_debug = format!("{redirect:?}"); + assert!(redirect_debug.contains("")); + assert!(!redirect_debug.contains("provider-query-value")); + + let head = policy + .admit_redirect_response(&redirect, 200, CDN_URL_WITH_QUERY) + .expect("admitted redirect should terminate in a download head"); + let head_debug = format!("{head:?}"); + assert!(head_debug.contains("")); + assert!(!head_debug.contains("provider-query-value")); + assert!(head_debug.contains("")); + assert!(!head_debug.contains("c2ln")); + + assert_eq!(redirect.location(), CDN_URL_WITH_QUERY); + assert_eq!(head.effective_url(), CDN_URL_WITH_QUERY); + assert_eq!(head.artifact_signature(), "c2ln"); +} + +#[test] +fn sealed_transport_debug_keeps_candidate_and_provider_evidence_out_of_logs() { + let policy = policy(); + let redirect = match policy + .admit_initial_response(302, INITIAL_URL, Some(CDN_URL_WITH_QUERY)) + .expect("admit one release-asset redirect") + { + ResponseDecision::FollowRedirect(redirect) => redirect, + ResponseDecision::Download(_) => panic!("302 must produce a redirect decision"), + }; + let head = policy + .admit_redirect_response(&redirect, 200, CDN_URL_WITH_QUERY) + .expect("redirect should terminate in an admitted body"); + let directory = scratch_dir("sealed-redaction"); + let mut download = head + .start_staging(&directory, Some(4), None) + .expect("start exact bounded staging"); + download.admit_chunk(b"data").expect("write exact fixture bytes"); + let sealed = download.finish().expect("seal exact fixture bytes"); + + let sealed_debug = format!("{sealed:?}"); + assert!(sealed_debug.contains("")); + assert!(sealed_debug.contains("")); + assert!(!sealed_debug.contains("provider-query-value")); + assert!(!sealed_debug.contains("c2ln")); + assert!(!sealed_debug.contains(SOURCE_COMMIT)); + assert!(!sealed_debug.contains(DIGEST)); + + let path = sealed.path().to_path_buf(); + drop(sealed); + remove_sealed_fixture(&path); + let lease = directory.join(".bandscope-staging.lock"); + if lease.is_file() { + fs::remove_file(lease).expect("remove diagnostics staging lease fixture"); + } + fs::remove_dir(directory).expect("remove diagnostics staging directory"); +} diff --git a/apps/desktop/distribution-transport/tests/transport_policy.rs b/apps/desktop/distribution-transport/tests/transport_policy.rs new file mode 100644 index 000000000..216ab0200 --- /dev/null +++ b/apps/desktop/distribution-transport/tests/transport_policy.rs @@ -0,0 +1,290 @@ +use bandscope_distribution_download::DownloadAdmissionError; +use bandscope_distribution_runtime::{admit_untrusted_raw_json, MetadataError}; +use bandscope_distribution_transport::{ + ReleaseTransportPolicy, ResponseDecision, TransportDownloadError, TransportPolicyError, +}; +use std::fs; +use std::io::Read; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +const SOURCE_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; +const DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const INITIAL_URL: &str = "https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-windows-x86_64.zip"; +const CDN_URL: &str = "https://release-assets.githubusercontent.com/github-production-release-asset/1178322014/update.zip?sp=r&sv=2021-08-06&sr=b"; + +fn updater_document_with_signature(signature: &str) -> Vec { + format!( + r#"{{"version":"1.2.3","platforms":{{"windows-x86_64":{{"signature":"{signature}","url":"{INITIAL_URL}"}},"windows-aarch64":{{"signature":"c2ln","url":"https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-windows-aarch64.zip"}},"darwin-x86_64":{{"signature":"c2ln","url":"https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-darwin-x86_64.tar.gz"}},"darwin-aarch64":{{"signature":"c2ln","url":"https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-darwin-aarch64.tar.gz"}}}},"bandscope":{{"schemaVersion":1,"sourceCommit":"{SOURCE_COMMIT}","minimumSupportedVersion":"0.1.3","artifacts":{{"windows-x86_64":{{"sizeBytes":4,"sha256":"{DIGEST}"}},"windows-aarch64":{{"sizeBytes":5,"sha256":"{DIGEST}"}},"darwin-x86_64":{{"sizeBytes":6,"sha256":"{DIGEST}"}},"darwin-aarch64":{{"sizeBytes":7,"sha256":"{DIGEST}"}}}}}}}}"# + ) + .into_bytes() +} + +fn updater_document() -> Vec { + updater_document_with_signature("c2ln") +} + +fn policy_with_signature(signature: &str) -> ReleaseTransportPolicy { + let metadata = admit_untrusted_raw_json( + &updater_document_with_signature(signature), + "windows-x86_64", + ) + .expect("fixture must satisfy provisional metadata admission"); + ReleaseTransportPolicy::from_provisional(&metadata).expect("transport projection") +} + +fn policy() -> ReleaseTransportPolicy { + policy_with_signature("c2ln") +} + +fn scratch_dir(label: &str) -> std::path::PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock after unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "bandscope-distribution-transport-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir(&path).expect("create isolated staging directory"); + path +} + +fn staging_lease_path(directory: &Path) -> std::path::PathBuf { + directory.join(".bandscope-staging.lock") +} + +fn remove_staging_lease(directory: &Path) { + fs::remove_file(staging_lease_path(directory)).expect("remove persistent staging lease fixture"); +} + +fn assert_platform_drop_cleanup(path: &Path) { + #[cfg(unix)] + assert!(!path.exists(), "Unix removes the descriptor-owned staging path"); + + #[cfg(not(unix))] + { + assert!( + path.is_file(), + "non-Unix drop defers pathname deletion when descriptor identity cannot be proven" + ); + fs::remove_file(path).expect("remove deferred unverified scratch fixture"); + } +} + +#[test] +fn malformed_tauri_signature_envelope_is_rejected_by_metadata_owner() { + assert_eq!( + admit_untrusted_raw_json( + &updater_document_with_signature("not-base64!"), + "windows-x86_64", + ), + Err(MetadataError::InvalidSignature) + ); +} + +#[test] +fn github_release_redirect_is_one_hop_and_streams_through_bounded_staging() { + let policy = policy(); + let redirect = match policy + .admit_initial_response(302, INITIAL_URL, Some(CDN_URL)) + .expect("GitHub release CDN redirect should be admitted") + { + ResponseDecision::FollowRedirect(redirect) => redirect, + ResponseDecision::Download(_) => panic!("302 must not expose a response body"), + }; + assert_eq!(redirect.location(), CDN_URL); + + let head = policy + .admit_redirect_response(&redirect, 200, CDN_URL) + .expect("one admitted redirect may terminate in 200"); + assert_eq!(head.expected_size_bytes(), 4); + assert_eq!(head.expected_artifact_sha256(), DIGEST); + assert_eq!(head.artifact_signature(), "c2ln"); + + let directory = scratch_dir("redirect"); + let mut download = head + .start_staging(&directory, Some(4), None) + .expect("start bounded staging"); + download.admit_chunk(b"da").expect("first chunk"); + download.admit_chunk(b"ta").expect("second chunk"); + let sealed = download.finish().expect("exact response seals"); + assert_eq!(sealed.bytes_written(), 4); + let path = sealed.path().to_path_buf(); + drop(sealed); + assert_platform_drop_cleanup(&path); + remove_staging_lease(&directory); + fs::remove_dir(directory).expect("remove staging directory"); +} + +#[test] +fn sealed_transport_artifact_keeps_candidate_identity_with_descriptor_evidence() { + let policy = policy(); + let head = match policy + .admit_initial_response(200, INITIAL_URL, None) + .expect("direct response") + { + ResponseDecision::Download(head) => head, + ResponseDecision::FollowRedirect(_) => panic!("200 must be final"), + }; + let directory = scratch_dir("sealed-identity"); + let mut download = head + .start_staging(&directory, Some(4), None) + .expect("start bounded staging"); + download.admit_chunk(b"data").expect("exact artifact chunk"); + let sealed = download.finish().expect("exact response seals"); + + assert_eq!(sealed.version_components(), (1, 2, 3)); + assert_eq!(sealed.source_commit(), SOURCE_COMMIT); + assert_eq!(sealed.target(), "windows-x86_64"); + assert_eq!(sealed.minimum_supported_version_components(), (0, 1, 3)); + assert_eq!(sealed.effective_url(), INITIAL_URL); + assert_eq!(sealed.artifact_name(), "BandScope-windows-x86_64.zip"); + assert_eq!(sealed.expected_size_bytes(), 4); + assert_eq!(sealed.expected_artifact_sha256(), DIGEST); + assert_eq!(sealed.artifact_signature(), "c2ln"); + assert_eq!(sealed.bytes_written(), 4); + + let mut reader = sealed.reader(); + let mut exact_bytes = Vec::new(); + reader + .read_to_end(&mut exact_bytes) + .expect("read exact sealed descriptor bytes"); + assert_eq!(exact_bytes, b"data"); + + let path = sealed.path().to_path_buf(); + drop(sealed); + assert_platform_drop_cleanup(&path); + remove_staging_lease(&directory); + fs::remove_dir(directory).expect("remove staging directory"); +} + +#[test] +fn redirect_decision_cannot_cross_provisional_policy_identity() { + let originating_policy = policy_with_signature("c2ln"); + let different_policy = policy_with_signature("c2lnMQ=="); + let redirect = match originating_policy + .admit_initial_response(302, INITIAL_URL, Some(CDN_URL)) + .expect("originating policy admits one redirect") + { + ResponseDecision::FollowRedirect(redirect) => redirect, + ResponseDecision::Download(_) => panic!("302 must require a redirect follow-up"), + }; + + assert_eq!( + different_policy.admit_redirect_response(&redirect, 200, CDN_URL), + Err(TransportPolicyError::RedirectPolicyMismatch) + ); +} + +#[test] +fn hostile_redirects_and_redirect_chaining_fail_closed() { + let policy = policy(); + assert_eq!( + policy.admit_initial_response(302, INITIAL_URL, Some("https://evil.example/update.zip")), + Err(TransportPolicyError::InvalidRedirectLocation) + ); + + let redirect = match policy + .admit_initial_response(302, INITIAL_URL, Some(CDN_URL)) + .expect("canonical release CDN redirect") + { + ResponseDecision::FollowRedirect(redirect) => redirect, + ResponseDecision::Download(_) => panic!("302 must require a redirect follow-up"), + }; + assert_eq!( + policy.admit_redirect_response(&redirect, 302, CDN_URL), + Err(TransportPolicyError::RedirectChainingRejected) + ); + assert_eq!( + policy.admit_redirect_response(&redirect, 200, "https://evil.example/update.zip"), + Err(TransportPolicyError::RedirectEffectiveUrlDrift) + ); +} + +#[test] +fn content_length_mismatch_fails_before_staging_file_creation() { + let policy = policy(); + let head = match policy + .admit_initial_response(200, INITIAL_URL, None) + .expect("direct response") + { + ResponseDecision::Download(head) => head, + ResponseDecision::FollowRedirect(_) => panic!("200 must be final"), + }; + let directory = scratch_dir("length"); + assert_eq!( + head.start_staging(&directory, Some(3), None).unwrap_err(), + TransportDownloadError::Download(DownloadAdmissionError::ContentLengthMismatch) + ); + assert_eq!(fs::read_dir(&directory).expect("read staging directory").count(), 0); + fs::remove_dir(directory).expect("remove staging directory"); +} + +#[test] +fn encoded_response_body_is_rejected_before_staging_file_creation() { + let policy = policy(); + let head = match policy + .admit_initial_response(200, INITIAL_URL, None) + .expect("direct response") + { + ResponseDecision::Download(head) => head, + ResponseDecision::FollowRedirect(_) => panic!("200 must be final"), + }; + let directory = scratch_dir("content-encoding"); + assert_eq!( + head.start_staging(&directory, Some(4), Some("gzip")).unwrap_err(), + TransportDownloadError::UnsupportedContentEncoding + ); + assert_eq!(fs::read_dir(&directory).expect("read staging directory").count(), 0); + fs::remove_dir(directory).expect("remove staging directory"); +} + +#[test] +fn explicit_identity_content_encoding_remains_admitted() { + let policy = policy(); + let head = match policy + .admit_initial_response(200, INITIAL_URL, None) + .expect("direct response") + { + ResponseDecision::Download(head) => head, + ResponseDecision::FollowRedirect(_) => panic!("200 must be final"), + }; + let directory = scratch_dir("identity-encoding"); + let mut download = head + .start_staging(&directory, Some(4), Some("identity")) + .expect("identity encoding preserves exact artifact bytes"); + download.admit_chunk(b"data").expect("exact artifact chunk"); + let sealed = download.finish().expect("exact response seals"); + let path = sealed.path().to_path_buf(); + drop(sealed); + assert_platform_drop_cleanup(&path); + remove_staging_lease(&directory); + fs::remove_dir(directory).expect("remove staging directory"); +} + +#[test] +fn cancelled_transport_releases_lease_and_preserves_platform_cleanup_contract() { + let policy = policy(); + let head = match policy + .admit_initial_response(200, INITIAL_URL, None) + .expect("direct response") + { + ResponseDecision::Download(head) => head, + ResponseDecision::FollowRedirect(_) => panic!("200 must be final"), + }; + let directory = scratch_dir("cancel"); + let mut download = head + .start_staging(&directory, None, None) + .expect("start bounded staging"); + download.admit_chunk(b"da").expect("partial chunk"); + let path = download.path().to_path_buf(); + assert!(path.is_file()); + assert!(staging_lease_path(&directory).is_file()); + + drop(download); + assert_platform_drop_cleanup(&path); + assert!(staging_lease_path(&directory).is_file()); + remove_staging_lease(&directory); + fs::remove_dir(directory).expect("remove staging directory"); +} diff --git a/docs/traceability/distribution-http-dependency-admission.md b/docs/traceability/distribution-http-dependency-admission.md new file mode 100644 index 000000000..7fbbf469a --- /dev/null +++ b/docs/traceability/distribution-http-dependency-admission.md @@ -0,0 +1,118 @@ +# Distribution HTTP dependency admission traceability + +Status: implemented pre-compilation dependency gate; production HTTP adapter remains pending. + +## Problem + +BandScope's Distribution transport policy is ready to consume real HTTP response state, but the production client dependency has not yet been admitted. That dependency decision became security-relevant on 2026-09-14 when RustSec published `RUSTSEC-2026-0285` for `rustls`: TLS 1.3 handshake messages could be accepted across an encryption-level transition when they followed a key-changing message in the same record. RustSec marks `rustls >=0.23.13,<0.23.45` as affected and `>=0.23.45` as patched. + +The planned client, `reqwest`, enables `default-tls` through its default feature set. In reqwest 0.13.5, the default set also includes HTTP/2 and system-proxy behavior. Reqwest documents that `default-tls` currently selects rustls but is intentionally backend-agnostic and can take precedence when another crate enables it. A Distribution-owned release path therefore cannot treat `reqwest = "..."` or an affected rustls lock as an acceptable implementation shortcut. + +Reqwest 0.13.5 exposes optional behavior that changes updater transport semantics before BandScope sees exact response bytes: gzip, Brotli, Zstandard and deflate response decoding; system/SOCKS proxy routing; alternate DNS resolution; and HTTP/2 or experimental HTTP/3 protocol activation. `Response::chunk()` is available without reqwest's optional `stream` feature, so the updater transport has no current requirement for any direct reqwest feature beyond `rustls`. The dependency gate therefore uses an allow-list rather than trying to maintain independent deny-lists for TLS, decompression, proxy and protocol features. + +The preceding checker used a narrower TLS deny-list. It correctly rejected the affected rustls interval and the known native-TLS feature spellings, but a future manifest such as `features = ["rustls", "gzip"]` would still have passed pre-compilation admission. Reqwest documents that enabling `gzip`, `brotli`, `zstd` or `deflate` turns automatic response decompression on by default and can remove `Content-Encoding` and `Content-Length` before application code observes the response. That is incompatible with BandScope's requirement that `distribution-transport` admit the exact wire response metadata and that `distribution-download` receive the exact updater artifact bytes. + +A second review of the executable gate found an ownership-location bypass after the feature allow-list was added. The checker originally read only top-level `[dependencies].reqwest`. Cargo permits normal runtime dependencies under target-scoped tables such as `[target.'cfg(windows)'.dependencies]`; a future Windows-only or macOS-only reqwest declaration there would have activated the production client while the checker returned early as if Distribution had no direct HTTP dependency. Target-scoped normal dependencies therefore belong to the same owner admission surface as unconditional normal dependencies. + +Fresh review then found a third bypass in dependency identity. Cargo explicitly permits a dependency key to differ from the package name by using `package = "..."`. For example, `distribution_http = { package = "reqwest", ... }` is still a direct runtime dependency on the reqwest package. The previous checker matched only the TOML key `reqwest`, so a renamed reqwest dependency could make the gate return early and skip the feature/TLS/lock admission entirely. The gate must therefore identify owner dependencies by Cargo package identity, not by the local dependency alias. + +A fourth review found the same identity gap through Cargo workspace inheritance. This crate is itself a workspace root, so `[workspace.dependencies]` can define `distribution_http = { package = "reqwest", ... }` while `[dependencies]` activates it with `distribution_http = { workspace = true, ... }`. Cargo makes inherited features additive with the workspace declaration. A checker that examines only the local declaration cannot know that the effective package is reqwest or that workspace and local feature sets combine. Until BandScope has a concrete reason to share this security-sensitive client through workspace inheritance, inherited reqwest is rejected rather than partially reconstructing Cargo feature resolution in a Python preflight. + +The final source review in the preceding slice found that package identity and TLS features were still insufficient supply-chain admission. Cargo can source a package from git, a local path or an alternate registry while retaining the package name `reqwest`, and a `[patch.crates-io]` override can similarly replace the resolved `rustls` package while preserving a safe-looking version. The repository dependency policy already requires direct git refs and equivalent nonstandard sources to be rejected or explicitly excepted. Distribution therefore admits only a versioned crates.io reqwest declaration and requires both resolved reqwest and rustls lock entries to carry the canonical crates.io registry source. A version string alone is not accepted as provenance. + +Fresh review of that versioned-source rule found one remaining fail-open input: the checker accepted any non-empty version string. Cargo accepts wildcard and comparator requirements such as `*` and `>=0.13.5`; those forms can cross future SemVer-incompatible release lines on a lock refresh. That conflicts with the repository policy rejecting unbounded version ranges. Cargo recommends a fully specified three-component default/caret requirement such as `0.13.5`; for a pre-1.0 package this is bounded to the compatible `0.13.x` line while the committed lock remains the exact current resolution. Distribution therefore requires that canonical three-component form and rejects wildcard, comparator, shortened, pre-release and metadata-bearing requirements at this owner boundary. + +This finding does not prove that an existing transitive `rustls` entry elsewhere in BandScope is exploitable. The gate is deliberately activated when `apps/desktop/distribution-transport/Cargo.toml` acquires a direct runtime `reqwest` package dependency, unconditional or target-scoped and regardless of its local Cargo alias, because that is the repository-owned production HTTP boundary being prepared here. + +## Constraints + +- The production updater client must remain in the Distribution bounded context and must not reimplement metadata, project persistence, authentication, or installer ownership. +- Any direct runtime reqwest package dependency, whether under top-level `[dependencies]` or a Cargo `[target..dependencies]` table and whether named `reqwest` locally or renamed with `package = "reqwest"`, must use table syntax with `default-features = false` and exactly the approved direct feature set: `features = ["rustls"]`. +- A direct reqwest `version` must use the canonical fully specified `MAJOR.MINOR.PATCH` Cargo default/caret requirement form. Wildcards, comparison operators, shortened requirements, prerelease/build metadata and otherwise unbounded requirements are not admitted; the committed lock remains the exact resolved-version authority. +- A reqwest package inherited with `workspace = true` is not currently admitted. The Distribution transport must declare the package, version, default-feature policy and direct feature set in its own runtime dependency table so the owner contract is reviewable without reconstructing additive workspace feature inheritance. +- Direct reqwest declarations may use only `version`, optional `package` rename metadata, `default-features`, and `features`. Git, path, alternate-registry, branch/tag/rev and optional/source indirection are not admitted without a new owner decision and dependency-policy exception. +- The committed standalone lock must resolve reqwest and rustls from `registry+https://github.com/rust-lang/crates.io-index`; git/path/alternate-registry entries are rejected even when the package name and version look acceptable. +- `dev-dependencies` and `build-dependencies` do not activate the production client and are not treated as runtime owner declarations by this gate. +- Additional direct reqwest features are rejected until a concrete Distribution requirement, threat analysis, tests and traceability justify widening the allow-list. This currently rejects native/default TLS alternatives, transparent decompression, proxy, alternate DNS and HTTP/2/HTTP/3 feature activation at the owner manifest. +- Runtime code must still call `ClientBuilder::tls_backend_rustls()`. Cargo features are additive across the dependency graph, so the manifest allow-list is not a substitute for explicit runtime backend selection. +- Runtime code must also call `no_gzip()`, `no_brotli()`, `no_zstd()`, `no_deflate()`, `redirect(Policy::none())` and `no_proxy()`. Reqwest intentionally provides the `no_*` decompression methods even when the corresponding optional feature is not selected so an additive transitive feature cannot silently change client behavior. +- The standalone `apps/desktop/distribution-transport/Cargo.lock` is authoritative for the HTTP adapter's resolved Rust graph. +- If that standalone graph contains `rustls >=0.23.13,<0.23.45`, CI must fail with `RUSTSEC-2026-0285` before any Distribution platform build starts. +- A missing reqwest or rustls lock entry after direct reqwest admission is a failure, not an implicit resolver fallback. +- Later unaffected rustls lines remain admissible; the check encodes the advisory range rather than freezing BandScope to one minor release. +- The dependency gate does not replace `cargo audit`, GitHub dependency review, OSV, SBOM generation, or release-time artifact provenance. + +## Alternatives considered + +Using reqwest defaults was rejected because the default TLS backend is intentionally not a stable backend-selection contract and the default feature set admits network behavior that BandScope has not explicitly accepted. Selecting native TLS was rejected for this owner because it produces materially different TLS stacks on Windows, macOS, and Linux and would make cross-platform release evidence harder to compare. Admitting an affected rustls lock with a local exception was rejected because a patched release already exists, so the repository's vulnerability-exception rule does not apply. + +Maintaining separate forbidden-feature sets for native TLS, decompression, proxies and protocols was rejected after the feature review. Reqwest features are additive and its public feature surface can grow; a deny-list fails open whenever a newly relevant feature is omitted. The selected allow-list has the inverse property: a new direct feature requires an explicit repository decision before it can enter the release transport. + +Inspecting only top-level `[dependencies]` was rejected because Cargo target tables can declare the same normal runtime dependency for one platform. Distribution supports Windows and macOS explicitly; platform scoping changes where the dependency is declared, not who owns its network/TLS semantics. The checker therefore enumerates both unconditional and target-scoped normal dependency tables and applies the same declaration policy to each one. + +Matching only a dependency key literally named `reqwest` was rejected because Cargo's `package` key is the authoritative package-selection mechanism when a dependency is renamed. A local alias changes the Rust/Cargo-facing dependency name, not the upstream package being introduced into the Distribution runtime graph. The checker therefore resolves each normal dependency declaration to its Cargo package name first and applies reqwest policy whenever that package name is `reqwest`. + +Trying to admit workspace-inherited reqwest by merging `[workspace.dependencies]` and local `features` inside the policy script was rejected for now. Cargo explicitly makes inherited feature lists additive, and inherited default-feature behavior has edition/toolchain semantics. Duplicating that resolver logic in a small Python gate would create a second dependency-resolution authority. The safer current contract is to identify inherited reqwest through the workspace package declaration and reject it until a deliberate owner decision adds equivalent executable resolution evidence. + +Allowing git/path/alternate-registry reqwest or a patched/forked rustls solely because the package name and version match was rejected. That would let a source substitution bypass the direct dependency admission evidence while leaving a superficially acceptable Cargo graph. The chosen policy uses ordinary crates.io source provenance for this small release-security owner; any future source exception must be explicit and reviewed under the repository dependency policy rather than silently inferred by the checker. + +Allowing `*`, lower-bound-only comparator requirements or shortened version requirements was rejected because a future lock refresh could select a SemVer-incompatible reqwest line without changing the manifest. Requiring an exact `=MAJOR.MINOR.PATCH` pin was also rejected as unnecessary duplication of the committed Cargo lock for this binary product. Cargo's recommended fully specified default/caret form keeps a declared minimum and SemVer-compatible upper bound, while `Cargo.lock` retains the exact resolved graph that CI builds with `--locked`. + +Enabling reqwest's optional `stream` feature was rejected for the current adapter design because `Response::chunk()` already provides bounded asynchronous chunk retrieval without that feature. Avoiding `stream` also avoids an unnecessary `futures`/`tokio-util` surface in this small security-sensitive owner. + +Rejecting every rustls version below 0.23.45 was also rejected. RustSec explicitly lists versions below 0.23.13 as unaffected by this advisory, and future 0.24+ lines should not fail a check written for a 0.23 advisory. The selected check therefore models the published affected interval exactly. + +Scanning every Cargo.lock in the repository and treating any affected transitive rustls as proof that the new updater client is vulnerable was rejected as an ownership error. The executable admission is tied to the standalone Distribution transport manifest and lock. Existing transitive graphs remain covered by the repository-wide supply-chain controls and require their own exposure analysis if an advisory is reported there. + +## Selected design + +`scripts/checks/verify_distribution_http_dependencies.py` is a dependency-free Python 3 gate using `tomllib`. It enumerates direct runtime dependency declarations from top-level `[dependencies]` and every `[target..dependencies]` table, resolves each declaration's package identity using Cargo's `package` rename semantics, and follows `workspace = true` only far enough to determine the inherited workspace package identity. Every declaration whose effective package is `reqwest` enters the owner gate. Workspace-inherited reqwest is rejected. A directly declared reqwest package must use only the approved declaration-key set, use a canonical fully specified three-component default/caret version requirement, own the rustls backend, and use exactly the direct feature set `{rustls}`. The gate then requires a committed standalone lock containing reqwest and rustls from the canonical crates.io registry source and rejects every locked rustls version inside the `RUSTSEC-2026-0285` affected interval. + +`.github/workflows/ci.yml` runs this check in `lock-validation` immediately after checkout. The Distribution Windows/macOS/Linux Rust jobs depend on that job, so an unsafe future HTTP graph is rejected before those crates compile rather than after a platform matrix has already exercised it. `scripts/harness/quickcheck.sh` invokes the same checker so local canonical validation and hosted admission share one rule. + +The regression suite uses synthetic Cargo manifest/lock fixtures only for policy-unit coverage. It proves rejection of rustls 0.23.44, acceptance of 0.23.45 and 0.24.0, non-activation when Distribution has no direct reqwest dependency, rejection of implicit reqwest TLS feature selection, rejection of unapproved direct transport features including gzip/Brotli/Zstandard/deflate decoding, system/SOCKS proxy, alternate DNS, HTTP/2 and HTTP/3, rejection of the same invalid reqwest declaration when it is moved under a Windows target dependency table, rejection when the reqwest package is renamed to another local dependency key, rejection when renamed reqwest is hidden behind workspace dependency inheritance, rejection of git/path/alternate-registry reqwest sources, rejection of a non-crates.io rustls lock source, rejection of wildcard/lower-bound-only reqwest version requirements, and acceptance of a bounded three-component default requirement. Those fixtures are not production networking evidence. + +## RED -> repair evidence + +- `7904f1097484657083bc340a00e403b2f60c03f2` introduced the dependency-admission regression contract before the checker existed. The predecessor therefore had no repository-controlled rule capable of rejecting a future direct reqwest graph on `RUSTSEC-2026-0285` grounds. +- `40496d5589f4903970fc54522256e4c31807ab44` added the fail-closed checker, corrected the regression fixtures for repository docstring/lint policy, and wired the gate before Distribution platform compilation. +- `3def1e1f65dd30bc72d6b6eb409282aaddac4789` wired the same dependency gate into canonical local quickcheck so local and hosted policy could not drift. +- `5f81143b59456a876e18b2879dc78af6ac2c3c1e` added a RED contract for reqwest 0.13.5's `native-tls-vendored-no-alpn` feature, which the then-current forbidden set did not detect. +- `b26fc7ddc8895c06618b25485000b3f97f7619d9` completed that alternate-backend deny-list. +- `621bac6b543255e1fd6c01ce03068c8870a43773` added a RED contract proving that the narrower checker still admitted `features = ["rustls", "gzip"]`, even though gzip activation can transparently transform response bytes and strip transport headers before BandScope admission. +- `43d786362a15afa23824878091c2edea07b474bd` replaced feature-specific deny-listing with the exact direct reqwest allow-list `{"rustls"}`. +- `eb10409b07d26247d6060466b93ad37ec02f9870` expanded edge coverage across transparent decompression, proxy, DNS and HTTP protocol feature classes so a future widening of the allow-list is an explicit contract change. +- `d36c4d647ace77d8a0d68a2e3690460cadd980ed` added a RED contract moving the invalid `rustls + gzip` declaration under `[target.'cfg(windows)'.dependencies]`; the preceding checker treated the manifest as having no direct reqwest dependency. +- `da209f160bcf4e81cc70cf4760ad658c9d78c679` makes target-scoped normal runtime reqwest declarations enter the same fail-closed owner admission as top-level declarations. +- `7330af6e40a6ba5b4c49c9de32abf291dd7c515b` adds the Cargo-rename RED: `distribution_http = { package = "reqwest", ..., features = ["rustls", "gzip"] }` must be treated as the same direct reqwest owner dependency rather than bypassing admission because the local key is not `reqwest`. +- `5356dcfa9a1a75c7cecdf6d185e7359cc056929a` resolves normal dependency declarations to Cargo package identity before selecting reqwest, so unconditional and target-scoped renamed dependencies enter the same policy path. +- `cf6655d134ebcae57037be2a21a1c8f777437ace` adds the workspace-inheritance RED: a local `workspace = true` dependency whose workspace package is renamed reqwest must not make the gate return early. +- `8284ae6ccc5034ad4ab1e94f201e125d25f975da` resolves the workspace package identity for admission and fails closed on inherited reqwest instead of implementing a partial Cargo feature resolver. +- `a8d91b349054a6bebdc069071761c2010829c51c` adds RED coverage for git, local-path and alternate-registry reqwest declarations; the preceding gate accepted their package name/features without validating source provenance. +- `914609c8f33b0f06809d5ef3b47fdfd67e552568` restricts direct reqwest declaration controls to the versioned crates.io form and requires canonical crates.io reqwest lock provenance. +- `e6a0663255c3164fade5be5105d2dc05f6fb7183` adds the corresponding RED for a safe-looking rustls `0.23.45` lock entry sourced from git. +- `4fdc294a2077b4c1d1b7d5e6a7a831a60c98aced` applies the same canonical crates.io lock-source requirement to rustls before advisory-range admission. +- `09852ddb7f262e040cd8a28814bee73f9b4732af` adds RED coverage for wildcard `*` and lower-bound-only `>=0.13.5` reqwest requirements while retaining a bounded `0.13.5` control case. +- `106d1f63117cbebaeaefa11e2998260e3579d3be` makes reqwest version admission require one canonical fully specified three-component Cargo default/caret requirement before lock/TLS admission continues. + +Hosted exact-head checks remain authoritative for repository integration. This source-level gate does not claim that the production HTTP adapter exists, that remote metadata is authenticated, that updater artifact signatures have been verified, or that current packaged Windows/macOS network behavior is release-ready. + +## Follow-up + +The next Distribution implementation may add reqwest only together with a lock graph that passes this admission. The client must then use explicit `ClientBuilder::tls_backend_rustls()`, `redirect(Policy::none())`, `no_gzip()`, `no_brotli()`, `no_zstd()`, `no_deflate()` and `no_proxy()`, preserve exact status/effective URL/Location/Content-Encoding, stream bounded response chunks through `Response::chunk()` into `distribution-download`, and produce realistic network/cancel/disk-full/captive-portal evidence. Cryptographic metadata and sealed-descriptor promotion remain subsequent gates. + +## References + +Rust Project. (2026). *Specifying dependencies: Renaming dependencies in Cargo.toml*. The Cargo Book. https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#renaming-dependencies-in-cargotoml + +Rust Project. (2026). *Dependency resolution: Version requirements*. The Cargo Book. https://doc.rust-lang.org/cargo/reference/resolver.html#version-numbers + +Rust Project. (2026). *Workspaces: The dependencies table*. The Cargo Book. https://doc.rust-lang.org/cargo/reference/workspaces.html#the-dependencies-table + +RustSec. (2026, September 14). *RUSTSEC-2026-0285: rustls: TLS 1.3 handshake messages incorrectly accepted across encryption level boundaries*. RustSec Advisory Database. https://rustsec.org/advisories/RUSTSEC-2026-0285.html + +Reqwest project. (2026). *Cargo feature table (reqwest 0.13.5)*. Docs.rs. https://docs.rs/crate/reqwest/0.13.5/features + +Reqwest project. (2026). *TLS configuration and types (reqwest 0.13.5)*. Docs.rs. https://docs.rs/reqwest/0.13.5/reqwest/tls/ + +Reqwest project. (2026). *Response (reqwest 0.13.5)*. Docs.rs. https://docs.rs/reqwest/0.13.5/reqwest/struct.Response.html diff --git a/docs/traceability/distribution-http-feature-forwarding.md b/docs/traceability/distribution-http-feature-forwarding.md new file mode 100644 index 000000000..8ea5700a4 --- /dev/null +++ b/docs/traceability/distribution-http-feature-forwarding.md @@ -0,0 +1,42 @@ +# Distribution HTTP reqwest feature-forwarding admission + +Status: Draft implementation evidence on PR #1126. This note records a source-level dependency-admission invariant; it is not proof of a production HTTP client, resolved TLS graph, or real-network acceptance. + +## Problem + +The Distribution HTTP gate already required a direct `reqwest` declaration with `default-features = false` and direct features exactly `{rustls}`. Cargo, however, permits dependency features to be activated again from the root package's `[features]` table with `dependency/feature` and `dependency?/feature` entries. Cargo also unifies enabled features for a dependency. A manifest could therefore keep the reviewed dependency declaration unchanged while adding, for example, `default = ["reqwest/gzip"]`, causing an ordinary build to compile reqwest with an unreviewed transport feature. + +For BandScope this matters because the future updater adapter must preserve exact artifact bytes and make TLS/backend, redirect, decoding, proxy, and DNS behavior explicit. A policy that inspects only `dependencies.reqwest.features` does not establish that invariant if the same manifest can activate additional reqwest features elsewhere. + +## Constraints + +- Keep the reviewed direct reqwest feature set owned by the runtime dependency declaration rather than reproducing Cargo's complete feature resolver in Python. +- Preserve dependency renaming: `distribution_http = { package = "reqwest", ... }` must be treated exactly like a dependency named `reqwest`. +- Cover Cargo's direct `dependency/feature` and weak `dependency?/feature` forwarding syntax. +- Do not claim that a static manifest check proves the final resolved feature graph. Cargo feature unification can also be influenced by another package that depends on the same reqwest package. Exact production acceptance still requires a genuinely Cargo-resolved BandScope lock and resolved-feature evidence (`cargo tree -e features -i reqwest` or an equivalent Cargo-owned projection) once the production client exists. + +## Decision + +When a normal unconditional or target-scoped dependency resolves to the `reqwest` package, `scripts/checks/verify_distribution_http_dependencies.py` now scans the root `[features]` table and rejects any member beginning with that dependency key followed by `/` or `?/`. This includes renamed dependency keys. The complete approved reqwest feature set must therefore remain in the direct dependency declaration inspected by the existing gate. + +We deliberately do not attempt to evaluate whether a forwarding feature is currently reachable from `default` or a particular CI command. The security invariant is narrower and easier to audit: the Distribution transport manifest must not contain a second source of reqwest feature activation at all. + +## RED → repair evidence + +- `ed5be008b379f5f07c4325801f1bbba9676fcad1` adds RED regressions for `reqwest/gzip` and a renamed `distribution_http/brotli` forwarding path. These fixtures are policy-unit evidence only; they are not claimed as a separately hosted failing run. +- `bc56faad9bd2582cd6bae4f0b4df43dafc64d9a8` adds dependency-key discovery across unconditional and target-scoped runtime dependencies and rejects root feature forwarding for every reqwest key. +- `0f74972089c4240dbcb7e47d65a8e116bbf099cf` adds the weak `distribution_http?/zstd` edge case so both Cargo forwarding syntaxes are executable contracts. + +## Rejected alternatives + +Allowing approved forwarding such as `reqwest/rustls` was rejected because it creates a second feature authority without buyer value. Following only `default` feature reachability was rejected because non-default features can be enabled by build commands and would leave the manifest with a latent unreviewed transport configuration. Reimplementing Cargo's transitive feature resolver in this Python gate was also rejected; the production client must instead add Cargo-owned resolved-feature evidence when its real graph exists. + +## External basis + +Cargo Reference, *Features* (Rust Project, current documentation accessed 2026-09-16): dependency features may be activated in `[features]` with `package-name/feature-name` or `package-name?/feature-name`, and enabled features for the same dependency are unified. https://doc.rust-lang.org/cargo/reference/features.html + +Cargo Reference, *Dependency Resolution* (Rust Project, current documentation accessed 2026-09-16): lockfile resolution considers package features and compilation performs a second feature-selection pass; dependencies are built with the union of features enabled on them. https://doc.rust-lang.org/cargo/reference/resolver.html + +## Claim boundary and next evidence + +This repair closes root-manifest feature forwarding as an admission bypass. It does not prove that no future transitive package activates additional reqwest features. Before the production adapter can be called release-ready, the genuinely resolved standalone graph must demonstrate the actual reqwest feature set with Cargo-owned feature-graph evidence, alongside the existing lock provenance, rustls advisory, SBOM, dependency-review, platform CI, and real-network tests. diff --git a/docs/traceability/distribution-http-lock-provenance.md b/docs/traceability/distribution-http-lock-provenance.md new file mode 100644 index 000000000..ef2fa8955 --- /dev/null +++ b/docs/traceability/distribution-http-lock-provenance.md @@ -0,0 +1,51 @@ +# Distribution HTTP lock provenance traceability + +Status: implemented pre-compilation supply-chain admission; production HTTP adapter remains pending. + +## Problem + +The Distribution HTTP admission gate already required the direct `reqwest` declaration and the resolved `reqwest` and `rustls` lock entries to come from crates.io. That was not sufficient to bind the implementation selected by the standalone lock graph. Cargo can override an individual registry dependency through `[patch]` using a git or path source, and the patched package can be a transitive TLS, certificate-validation, crypto, DNS, or protocol dependency rather than `reqwest` or `rustls` themselves. A safe-looking top-level `reqwest`/`rustls` pair therefore did not prove that the rest of the HTTP/TLS implementation came from the reviewed registry source. + +For this Distribution owner, that is a pre-compilation admission gap. A future production adapter is security-sensitive and the standalone `apps/desktop/distribution-transport/Cargo.lock` is intended to be the exact resolved graph used by `--locked` builds. A transitive git/path/alternate-registry substitution must not enter that graph without an explicit owner decision merely because the direct packages still look canonical. + +Cargo's own documentation distinguishes crates.io, alternate registries, git repositories, and local paths as dependency sources. It also documents `[patch]` as a mechanism that can override dependencies and notes that patches can be supplied from Cargo configuration as well as `Cargo.toml`. Source replacement is a separate mechanism intended for equivalent mirrors or vendored copies. This gate does not attempt to reimplement Cargo resolution; it validates the provenance recorded in the committed standalone lock after resolution. + +## Constraints + +- This check activates only after `apps/desktop/distribution-transport/Cargo.toml` declares a direct runtime `reqwest` package. Existing unrelated repository lock graphs remain outside this Distribution owner gate. +- The four BandScope Distribution crates in the standalone workspace are local path-owned packages and therefore intentionally have no lock `source`: `bandscope-distribution-core`, `bandscope-distribution-download`, `bandscope-distribution-runtime`, and `bandscope-distribution-transport`. +- Every other package in the active standalone lock graph must record Cargo's canonical crates.io registry source, `registry+https://github.com/rust-lang/crates.io-index`. +- A git source, alternate registry, or source-less external package fails admission even when its name and version match an expected transitive package. +- Adding another local BandScope path package to this standalone graph requires an explicit update to the local-package allow-list; the gate must not infer that an arbitrary source-less package is trusted. +- This policy checks lock provenance, not package integrity by itself. Cargo checksum verification, dependency review, OSV/audit controls, SBOM/provenance generation, platform builds, and release evidence remain separate gates. +- Synthetic lock fixtures are policy-unit evidence only. They do not prove that a production reqwest/rustls graph has been resolved, that a particular transitive package is reachable in a shipped binary, or that real TLS/network behavior is correct. + +## Alternatives considered + +Enumerating only known TLS transitive package names such as `rustls-webpki`, `ring`, or `aws-lc-rs` was rejected because the graph can change across compatible dependency updates; a package-name deny-list or allow-list would fail open when the TLS stack changes. The source invariant is simpler: once the direct production client is admitted, all external packages in this small standalone owner graph must come from the same reviewed registry source. + +Scanning every `Cargo.lock` in BandScope was rejected because it would conflate unrelated bounded contexts with the Distribution HTTP client. Repository-wide supply-chain tooling remains responsible for those graphs; this gate is intentionally attached to the standalone Distribution transport owner. + +Relying only on GitHub dependency review after accepting arbitrary git/path substitutions was rejected because the repository already has a pre-compilation owner gate and the production updater path should fail closed before platform builds. A source exception can still be made later, but it must be an explicit reviewed policy change with corresponding threat analysis and evidence rather than an implicit lock substitution. + +## Selected design + +`scripts/checks/verify_distribution_http_dependencies.py` now validates the provenance of every package entry in the standalone Distribution transport lock once a direct runtime reqwest dependency exists. The four known local Distribution crates must remain source-less path packages. Every other package must record the canonical crates.io registry source. Existing reqwest stable-line and rustls advisory checks then run over that already-admitted graph. + +This makes the committed lock graph the executable provenance boundary rather than treating only `reqwest` and `rustls` as security-owned packages. It deliberately does not parse `[patch]`, Cargo configuration, feature unification, or dependency edges itself; Cargo resolves those inputs and the gate judges the resulting committed lock. + +## RED -> repair evidence + +- `340e7eb795570e244c89890102c88aa043550456` adds policy regressions for two substitutions hidden behind otherwise canonical reqwest/rustls entries: a git-sourced `aws-lc-rs` package and a source-less `rustls-webpki` package. The RED was committed before the checker change; it is not claimed as a separately hosted failing run. +- `92162dad8965095d7ca9852c37f7448184fad3ca` adds whole-lock external-source admission and an explicit local Distribution package allow-list. It also keeps reqwest/rustls version/advisory checks scoped to table-shaped lock entries after graph provenance is validated. +- `532411874679cd8a64bcb92e8dd7f325b28bff37` adds edge evidence that an ordinary crates.io transitive package remains admitted and that a package using a local BandScope Distribution name cannot gain a remote registry source merely through the name allow-list. + +## Claim boundary and next action + +This closes one supply-chain admission path only. BandScope still has no production HTTP client in `distribution-transport`, and the current standalone lock contains only the local Distribution crates. The next implementation step remains to add the production reqwest adapter together with a genuinely Cargo-resolved BandScope-owned lock graph, then require exact-head hosted dependency review, SBOM/security gates, platform compilation, and real network/fault acceptance. No TLS safety or release-readiness claim should be inferred from the synthetic provenance fixtures alone. + +## Primary references + +- The Cargo Book, *Specifying Dependencies*: Cargo dependencies can originate from crates.io/registries, git repositories, or local paths, and local git/path locations can override registry resolution for development. +- The Cargo Book, *Configuration* (`[patch]`): dependency patches can be supplied from Cargo configuration as well as `Cargo.toml`, with configuration patches taking precedence where applicable. +- The Cargo Book, *Source Replacement*: source replacement is intended for equivalent mirrors/vendor sources and is distinct from patching a dependency. diff --git a/docs/traceability/distribution-http-reqwest-version-line.md b/docs/traceability/distribution-http-reqwest-version-line.md new file mode 100644 index 000000000..f7fe3ea92 --- /dev/null +++ b/docs/traceability/distribution-http-reqwest-version-line.md @@ -0,0 +1,55 @@ +# Distribution HTTP reqwest version-line admission traceability + +Status: source-level dependency admission repaired; production HTTP adapter and exact hosted evidence remain pending. + +## Problem + +BandScope's Distribution HTTP admission already required a bounded three-component reqwest requirement, the explicit `rustls` feature, canonical crates.io provenance, and a standalone lock without the `RUSTSEC-2026-0285` rustls affected interval. Fresh review found that the version rule still accepted any syntactically valid three-component reqwest release. `0.13.4` and a future SemVer-incompatible `0.14.0` therefore satisfied the checker even though the transport threat analysis, feature inventory, builder API assumptions, and dependency rationale were reviewed against reqwest 0.13.5. + +That is a policy gap rather than a Cargo parsing bug. A three-component requirement such as `0.14.0` is bounded, but it does not preserve the version line whose behavior BandScope actually reviewed. A downgrade to 0.13.4 likewise moves below the reviewed dependency baseline without a new owner decision. The committed lock is still the exact resolved graph, but the pre-compilation owner gate must reject a manifest or lock graph that silently crosses the reviewed boundary. + +The timing of `RUSTSEC-2026-0285` makes the distinction concrete. Reqwest 0.13.5 was published before rustls 0.23.45. The reqwest 0.13.5 docs.rs source snapshot contains rustls 0.23.44 in its own development lock, while RustSec marks `rustls >=0.23.13,<0.23.45` affected and 0.23.45 patched. That upstream lock is not BandScope's downstream resolution and is not vulnerability evidence for BandScope by itself; it shows why the BandScope-owned standalone lock, rather than an upstream release label, must carry the patched runtime graph. + +## Constraints + +- The currently reviewed reqwest line is `>=0.13.5,<0.14.0`. +- A direct runtime reqwest manifest requirement must remain a canonical three-component Cargo requirement and must fall inside that reviewed line. +- Every reqwest package resolved in `apps/desktop/distribution-transport/Cargo.lock` must also remain inside the reviewed line. A stale safe-looking 0.13 entry must not mask a second unreviewed reqwest release in the same standalone graph. +- A later 0.13.x patch may enter through the normal lock-refresh path because it remains in the reviewed SemVer line and still passes feature, provenance, rustls-advisory, dependency-review, OSV, SBOM and hosted platform gates. +- Any downgrade below 0.13.5 or move to 0.14+ requires a new Distribution owner decision, dependency admission evidence, API/feature review, threat analysis and exact-head tests before the allowed range is changed. +- This rule does not pin reqwest to exactly 0.13.5. The manifest requirement and committed lock retain their separate roles: the manifest constrains the reviewed compatible line; the lock records the exact build graph. +- The production client still must explicitly select `tls_backend_rustls()`, disable redirects, transparent decoding and proxy inheritance, and pass exact response evidence into `distribution-transport`. Version-line admission is not a substitute for runtime configuration. + +## RED -> causal fix + +- RED `106e03ab954c2bb21a0c127da187a07a17c2b505` adds regression cases for a downgrade to reqwest 0.13.4 and a SemVer-line move to 0.14.0. The predecessor checker accepted both because it validated only three-component syntax. +- Causal fix `13be2643220bf66ccff6482576ea9c5db01186b1` adds the reviewed range `>=0.13.5,<0.14.0` to both direct declaration admission and every resolved reqwest lock entry. The existing crates.io-source, exact `{rustls}` feature, workspace/alias/target-scope, and rustls advisory checks remain unchanged. +- A 0.13.6 fixture remains admissible under the helper semantics, so this repair does not convert the binary product lock into an exact patch pin. + +## Alternatives considered + +Exact-pinning reqwest to `=0.13.5` was rejected. BandScope already commits the standalone Cargo lock, and an exact manifest pin would duplicate that authority while making ordinary compatible security/bug-fix refreshes require a source-policy edit. + +Allowing any three-component reqwest version was rejected. The checker would then treat a SemVer-incompatible line as equivalent to the API and feature surface actually reviewed for the updater transport. + +Checking only the manifest and ignoring resolved reqwest versions was rejected. Cargo can carry multiple versions of the same package in one lock graph; the release-security owner must not let an admitted 0.13 entry conceal another reqwest line in the same standalone transport graph. + +Automatically widening the allowed range when crates.io publishes a new reqwest line was rejected. Dependency publication is external supply-chain input, not a BandScope architecture decision. + +## Claim boundary + +This repair prevents the current pre-compilation admission gate from silently accepting an unreviewed reqwest downgrade or SemVer-line change. It does not prove that reqwest 0.13.5 is vulnerability-free, that the future production HTTP adapter is correctly configured, or that the exact current branch has hosted GREEN evidence. The actual adapter must still arrive with a committed resolved lock that selects unaffected rustls, then pass dependency review, OSV/Trivy, SBOM, Windows/macOS/Linux Distribution tests and the subsequent real-network/fault acceptance path. + +## Follow-up + +Implement the production HTTP adapter only after generating a real standalone lock for the reviewed reqwest 0.13.x line with unaffected rustls. Do not copy reqwest's upstream development lock: BandScope must resolve and commit its own dependency graph. Then verify explicit rustls backend selection, no implicit redirect/decompression/proxy behavior, bounded chunk streaming, cancellation, DNS/TLS/network errors, disk-full behavior and captive-portal/non-release responses before moving to authenticated metadata and sealed-artifact cryptographic promotion. + +## References + +Reqwest project. (2026). *reqwest 0.13.5: Cargo.toml and feature configuration*. Docs.rs. https://docs.rs/crate/reqwest/0.13.5/source/Cargo.toml.orig + +Reqwest project. (2026). *reqwest 0.13.5: TLS configuration and types*. Docs.rs. https://docs.rs/reqwest/0.13.5/reqwest/tls/ + +Rust Project. (2026). *Dependency resolution: Version requirements*. The Cargo Book. https://doc.rust-lang.org/cargo/reference/resolver.html#version-numbers + +RustSec. (2026, September 14). *RUSTSEC-2026-0285: rustls: TLS 1.3 handshake messages incorrectly accepted across encryption level boundaries*. RustSec Advisory Database. https://rustsec.org/advisories/RUSTSEC-2026-0285.html diff --git a/docs/traceability/distribution-http-semver-prerelease-admission.md b/docs/traceability/distribution-http-semver-prerelease-admission.md new file mode 100644 index 000000000..af2f57099 --- /dev/null +++ b/docs/traceability/distribution-http-semver-prerelease-admission.md @@ -0,0 +1,33 @@ +# Distribution HTTP SemVer pre-release admission traceability + +Status: implemented owner-policy repair; production HTTP adapter remains pending. + +## Problem + +`RUSTSEC-2026-0285` marks `rustls >=0.23.13,<0.23.45` as affected and stable `rustls >=0.23.45` as patched. The Distribution dependency gate previously reduced every lockfile version to its numeric `MAJOR.MINOR.PATCH` core before comparing advisory boundaries. That made `0.23.45-alpha.1` or `0.23.45-rc.1` compare as if it were the patched stable `0.23.45`, even though SemVer orders a pre-release below the corresponding normal release. + +The same numeric-core projection treated a hypothetical locked `reqwest 0.13.5-alpha.1` as if it belonged to the owner-reviewed stable `0.13.5+` line. The production manifest still rejects pre-release requirements, but the lockfile gate is security evidence in its own right and must not mislabel pre-release packages as reviewed stable releases. + +## Constraints and decision + +- Keep the manifest owner contract unchanged: direct reqwest must use the canonical three-component stable requirement form such as `0.13.5`. +- Preserve build metadata as precedence-neutral for lockfile range decisions, consistent with SemVer/Cargo behavior. +- Reject reqwest pre-releases from the reviewed stable 0.13 line. +- Compare the RustSec interval against stable boundaries: a pre-release at `0.23.13` is below the advisory's stable lower bound, while a pre-release at `0.23.45` is still below the first stable patched release and therefore remains inside the affected interval when it is otherwise at or above the affected floor. +- Do not broaden this small checker into a general package resolver. Cargo remains the dependency-resolution authority; this gate only models the owner ranges it explicitly claims. + +## RED -> repair evidence + +- `dbe79884db3ba93db082688994beb140b8d58aad` adds regression contracts requiring locked `reqwest 0.13.5-alpha.1` to fail the reviewed-stable-line check and `rustls 0.23.45-alpha.1` / `0.23.45-rc.1` to remain rejected under `RUSTSEC-2026-0285`. Stable `rustls 0.23.45` remains admitted. The RED was committed before the causal checker change; it is not claimed as a separately hosted failing run. +- `98a53d6fed8fe89b28a9e22b9c9c0d7c417d8072` preserves pre-release presence while parsing lock versions, rejects reqwest pre-releases from the reviewed stable line, and evaluates the RustSec stable lower/upper boundaries without promoting `0.23.45-*` to patched status. +- `269bd4a72b5bcbd4e76b9174b272fd5aa14772e9` aligns the reqwest pre-release regression with the checker's stable-line diagnostic after the causal fix, without changing the production policy. + +## Claim boundary + +These synthetic Cargo fixtures prove the repository policy function only. They do not prove that a production HTTP client exists, that a given rustls version is reachable in a built binary, or that real TLS/network behavior has passed acceptance. Production evidence still requires a BandScope-owned standalone lock, exact-head dependency review/SBOM/security checks, platform builds, and real network/fault tests once the adapter is implemented. + +## References + +Rust Secure Code Working Group. (2026, September 14). *RUSTSEC-2026-0285: rustls: TLS 1.3 handshake messages incorrectly accepted across encryption level boundaries*. RustSec Advisory Database. https://rustsec.org/advisories/RUSTSEC-2026-0285.html + +The Rust Project Developers. (2026). *The manifest format*. The Cargo Book. https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/docs/traceability/release-artifact-receipt.md b/docs/traceability/release-artifact-receipt.md new file mode 100644 index 000000000..8fc02b269 --- /dev/null +++ b/docs/traceability/release-artifact-receipt.md @@ -0,0 +1,150 @@ +# Release artifact receipt traceability + +BandScope의 Distribution/update bounded context는 설치 파일을 만들었다는 사실과 상용 릴리즈로 신뢰할 수 있다는 판단을 구분합니다. 이 문서는 `scripts/release/package_desktop_artifact.py`가 생성하는 target receipt, Tauri v2 updater artifact와 static manifest binding, hosted release byte/attestation re-verification, 그리고 아직 해결되지 않은 signing/rollback 경계를 기록합니다. + +## 문제 + +기존 패키저는 각 설치 파일에 `.sha256`과 사람이 읽는 `.manifest.txt`를 만들었지만, 태그·전체 source commit·platform/architecture·실제 패키지 bytes를 하나의 기계 판독 가능한 receipt로 묶지 않았습니다. 플랫폼 서명 또는 notarization 검증과 artifact checksum이 각각 성공해도 어떤 exact source commit의 어떤 검증된 installer bytes를 릴리즈 후보로 취급했는지 단일 증거로 연결되지 않았습니다. + +첫 receipt 구현 뒤에도 updater 쪽에는 별도의 결함이 남았습니다. Tauri v2는 `createUpdaterArtifacts=true`일 때 Windows installer 옆에 `.sig`를 만들고, macOS에서는 `.app.tar.gz` updater bundle과 `.sig`를 생성합니다. 그런데 BandScope 패키저는 처음에는 DMG/EXE/MSI만 release artifact로 복사했습니다. 그 뒤 updater bundle/signature를 receipt에 결합했지만, Tauri client가 실제로 소비하는 `latest.json`이 receipt-authorized bytes에서 만들어진다는 보장은 없었습니다. 별도 manifest가 오래된 signature나 다른 bundle URL을 가리켜도 installer receipt만으로는 이를 검출할 수 없었습니다. + +Tauri의 static updater contract는 각 target에 URL과 signature **내용**을 요구합니다. 공식 `tauri-action` 구현도 generated `.sig` 파일을 읽어 그 문자열을 `latest.json`의 `signature`에 넣습니다. 따라서 파일명이나 signature 경로를 manifest에 넣는 방식은 계약과 맞지 않습니다. + +Manifest를 같은 draft release asset set에 포함시킨 뒤에도 publication boundary가 남았습니다. 로컬에서 검증한 asset을 `gh release create`에 넘겼다는 사실만으로 GitHub에 실제 저장된 draft/published asset bytes가 동일하다고 증명할 수 없습니다. 업로드 누락·잘못된 asset set·전송 후 byte drift를 local receipt에서 곧바로 관찰할 수 없기 때문입니다. GitHub immutable releases는 draft에 모든 asset을 붙인 뒤 publish하는 방식을 권고하고, publication 후 release/tag/assets를 잠그며 release attestation을 생성합니다. 따라서 BandScope의 local release graph, hosted asset graph, GitHub의 signed immutable-release attestation을 publication 경계에서 결합해야 합니다. + +## 제약과 소유권 + +- `VERSION`이 버전 권위입니다. `package.json`, Tauri config와 tag parity는 `verify_release_identity.py`가 검증합니다. +- Windows Authenticode와 macOS code signing/notarization/Gatekeeper 검증은 `verify_release_platform_trust.py`가 소유합니다. +- Updater policy/config/Cargo/runtime admission은 `verify_release_updater_policy.py`가 소유합니다. +- Commercial separation-model admission은 `verify_release_model_policy.py`와 #1180/#1181 경계에 남습니다. +- Target `release-receipt.json`, `latest.json`, hosted byte/attestation re-verification은 Distribution package/publication evidence입니다. Project Persistence, Resource Admission 또는 Signal/MIR가 이 포맷을 복제하거나 source-audio/model scientific identity로 사용하지 않습니다. +- 실제 updater private signing key, approved public verification key/production discovery endpoint, Windows signing identity, Apple Developer ID/notarization authority는 repository에서 임의로 생성하지 않습니다. + +## 선택 + +태그 패키징은 release-admission preflight를 먼저 통과해야 합니다. 표준 installer와 updater companion을 수집한 뒤 native platform trust가 성공해야 target별 `release-receipt.json`을 생성합니다. + +Receipt는 다음을 기록합니다. + +- schema version; +- authoritative BandScope version과 일치하는 `v` tag; +- 전체 40-hex Git source commit; +- platform, architecture, target triple; +- 각 packaged installer의 archive name, exact byte size, full SHA-256, checksum filename, per-artifact manifest filename; +- 존재하는 Tauri updater bundle의 exact byte size/full SHA-256; +- updater `.sig` filename, exact byte size/full SHA-256. + +Windows에서는 Tauri v2의 표준 NSIS/MSI installer가 updater bundle 자체이므로, source installer와 BandScope가 이름을 바꿔 복사한 installer bytes가 정확히 같은지 확인하고 adjacent `.sig`를 release output에 함께 복사합니다. Signature는 regular/non-link/non-empty여야 하고 64 KiB ceiling을 넘을 수 없습니다. + +macOS에서는 exact target의 `target//release/bundle/macos/` 아래에 updater용 `*.app.tar.gz`가 정확히 하나 있어야 하며, adjacent `.sig`가 있어야 합니다. Bundle은 target-specific BandScope release filename으로 복사하고 signature도 함께 복사합니다. 여러 bundle, missing bundle/signature, symlink/non-regular/empty evidence는 fail closed입니다. + +Updater source와 copied output은 각각 안정된 regular-file descriptor에서 size/full SHA-256을 확인합니다. Receipt 직전에도 copied bundle/signature를 다시 열어 패키징 시 기록한 identity와 일치하는지 확인합니다. 복사 후 byte drift가 있으면 receipt를 만들지 않습니다. + +표준 installer도 receipt 직전에 한 descriptor에서 regular-file 여부, size와 SHA-256을 다시 확인하며 앞서 생성한 checksum과 현재 bytes가 다르면 거부합니다. Receipt 자체는 같은 output directory에 staged write + `fsync` 후 `os.replace`로 게시합니다. PR/develop의 unsigned validation build에는 release receipt나 updater artifact admission을 요구하지 않습니다. + +`build_updater_manifest.py`는 immutable publication 직전에 `select_release_assets.py`를 다시 실행해 extracted release graph를 re-admit합니다. 그 뒤 네 target receipt의 `VERSION`/tag/source identity를 확인하고 target마다 updater artifact가 정확히 하나일 때만 static manifest를 구성합니다. `.sig`는 regular/non-link/64 KiB bounded descriptor에서 다시 읽고 receipt의 exact size/full SHA-256과 일치하는지 확인한 뒤, **그 exact UTF-8 내용**을 `signature`에 넣습니다. URL은 mutable `releases/latest`가 아니라 `https://///releases/download/v/` 형식의 exact-tag asset URL로 생성합니다. + +`latest.json`은 deterministic JSON으로 staged write + file `fsync` + `os.replace` + 가능한 플랫폼에서 parent-directory `fsync`로 게시합니다. Release workflow는 manifest를 만든 뒤 `--check`로 동일 release graph에서 다시 계산한 bytes와 exact equality를 확인하고 installer/updater/receipt/SBOM/inventory와 `latest.json`을 같은 draft release asset set으로 전달합니다. + +`verify_hosted_release_assets.py`는 publication transfer를 별도 신뢰 경계로 취급합니다. `release-assets.txt`는 256 KiB/256-member 한도로 제한하고, repository-relative safe path와 unique hosted basename만 허용합니다. Draft release를 만든 뒤 `gh release download `로 asset을 별도 directory에 다시 내려받고, 예상한 basename set과 downloaded set이 정확히 같은지 확인합니다. 각 local/hosted file은 regular/non-link file이어야 하고 stable descriptor에서 exact byte size와 streaming SHA-256이 같아야 합니다. 누락 asset, extra asset, duplicate publication basename, signature/manifest/installer byte drift는 publish 전에 fail closed합니다. + +Draft hosted bytes가 local admitted bytes와 일치한 뒤에만 release를 publish합니다. Publication 후에는 fresh directory로 같은 exact tag assets를 다시 다운로드하고 동일 verifier를 다시 실행합니다. 따라서 local `release-assets.txt` → draft hosted asset set → published hosted asset set의 byte identity를 하나의 workflow 안에서 확인합니다. + +Published hosted byte parity가 성공한 뒤에는 GitHub의 immutable-release attestation을 별도 권위로 검증합니다. `gh release verify `가 release attestation을 cryptographically 검증해야 하고, `release-assets.txt`의 모든 local asset은 각각 `gh release verify-asset `를 통과해야 합니다. GitHub 문서상 immutable release attestation은 release tag, commit SHA, release assets를 포함하며 `verify-asset`은 local digest가 해당 release attestation subject와 일치하는지 확인합니다. 이 단계는 BandScope 자체 SHA-256 parity를 없애는 것이 아니라 독립적인 GitHub-hosted signed evidence를 추가합니다. + +### 기각한 대안 + +1. 기존 `.sha256`만 release receipt로 간주: source commit/tag/target과 하나의 machine-readable contract로 결합되지 않으므로 기각했습니다. +2. `createUpdaterArtifacts=true`만으로 updater release evidence가 있다고 간주: 설정은 실제 `.sig` 또는 macOS updater bundle bytes의 존재·identity를 증명하지 못하므로 기각했습니다. +3. `.sig` 파일명만 receipt에 기록: receipt 생성 전 bytes가 바뀌어도 잡지 못하고 immutable publication evidence가 되지 않으므로 exact size/full SHA-256까지 묶습니다. +4. macOS DMG를 updater payload로 간주: Tauri v2의 macOS updater bundle은 `.app.tar.gz`이므로 기각했습니다. +5. 플랫폼 trust 검증 전에 receipt 생성: 실패한 Authenticode/notarization 후보가 release authority처럼 보일 수 있으므로 기각했습니다. +6. 짧은 commit SHA 사용: 충돌 가능성과 exact protected source 증거 부족 때문에 전체 40-hex commit을 요구합니다. +7. receipt를 updater signature 검증 또는 SLSA provenance라고 부르기: receipt는 별도 서명된 attestation이 아니고 `.sig`의 cryptographic validity를 이 함수에서 검증하지 않으므로 기각합니다. +8. `latest.json`에서 `.sig` 경로를 `signature`로 사용: Tauri static updater contract와 공식 `tauri-action` 모두 signature file **내용**을 요구하므로 기각했습니다. +9. `releases/latest` URL을 bundle authority로 사용: prerelease/channel drift와 mutable lookup을 exact release evidence에 섞게 되므로 exact version tag URL을 사용합니다. +10. manifest를 receipt와 별도 workflow에서 재구성: 동일 target graph에 대한 publication authority가 분리되고 TOCTOU 검증이 약해지므로 같은 release job에서 build→recheck→draft upload를 수행합니다. +11. `gh release create` 성공을 hosted byte identity 증거로 간주: API 성공은 local expected set과 remote stored set의 exact parity를 보장하는 BandScope evidence가 아니므로 draft와 published 상태에서 모두 다시 다운로드해 비교합니다. +12. published release만 사후 확인: immutable publish 뒤 mismatch를 발견하면 정상 release를 수리할 수 없으므로 draft download/re-verification을 publication 전 gate로 먼저 둡니다. +13. 자체 SHA-256 parity만으로 immutable release provenance를 주장: local/remote byte equality는 누가 release를 attest했는지 증명하지 않으므로 GitHub의 signed release attestation과 per-asset attestation verification을 추가합니다. + +## 실행 근거 + +Installer/source identity slice: + +- RED `b33958cb19ee55fdc75f2858c4f8700369ed463b`: exact tag/source/artifact binding, checksum 후 byte drift 거부, non-tag no-receipt, platform-trust-before-receipt ordering을 계약으로 추가했습니다. +- Fix `73a213c31b73524dc5e32f0d8682d868e557a4e0`: deterministic release receipt 생성과 descriptor-bound rehash를 구현했습니다. +- Repair `6d63fbf802636474c98552e855574688d414513d`: repository의 `importlib` 기반 executable-guard tests와 충돌하지 않도록 receipt value object를 import-safe `NamedTuple`로 교정했습니다. +- Edge coverage `6b0c520b56ded9b60258e64f18b6afa8db334e69`: ambiguous version, empty/mixed target, missing/malformed/link support files, linked archive와 descriptor drift를 추가 검증합니다. + +Updater artifact slice: + +- RED `421aaeec44fcb93f0250f44487d56a7b711aede0`: Windows installer에 adjacent Tauri `.sig`가 없을 때의 fail-closed, macOS `.app.tar.gz`/`.sig` 요구, copied updater evidence의 receipt binding과 post-copy drift rejection, non-tag 독립성을 계약으로 추가했습니다. +- Fix `0e012723e2bff7068d162b721a29d3141c036175`: Tauri v2 platform별 updater bundle/signature를 target release output에 수집하고 exact bytes를 `release-receipt.json`의 `updaterArtifacts`에 결합합니다. Windows standard installer와 updater bundle byte identity도 확인합니다. +- Publication re-admission `6d50e44f2252a64868c3b610185a2e9f76348696`: Actions artifact transfer 뒤에도 receipt와 installer/updater/signature bytes를 다시 검증합니다. +- Collision repair `a456b1aad8f0c5ed1a69676d13dc8dea06b78a43` + coverage `4ab309f8250ba0311d61e8af6b599d2953303c18`: 네 target receipt가 artifact aggregation 과정에서 서로 덮어쓰지 않도록 exact target-qualified names를 사용합니다. + +Static updater-manifest slice: + +- RED `03555ee9eb54150798363bcc7c634fe6800b13d6`: exact receipts/signature contents/tag URLs, post-generation signature drift, target ambiguity, HTTPS-only URL과 publish-before-check 방지를 executable contract로 추가했습니다. +- Fix `340b476e0039a5367d4d471da99d396c9045aa3a`: `build_updater_manifest.py`를 추가해 receipt-authorized bytes에서 deterministic Tauri static manifest를 생성하도록 했습니다. +- Publication wiring `4d6f7cb8cefd382ada8e01925425e5ea192f579b`: tag release job이 manifest를 생성하고 publication 직전 `--check`한 뒤 `latest.json`을 같은 immutable release asset set에 포함하도록 연결했습니다. +- Primary-contract repair `64fbd14df9bf92ae2618f7cc13008cc283c19545`: official `tauri-action`과 같이 `.sig`의 exact UTF-8 text를 보존하도록 수정했습니다. Receipt hash는 원본 signature bytes에 계속 결합됩니다. +- Test/format repair `889554d2c9200ec1258d1845655b2785486a6a31`: root pytest/ruff gate가 실행하는 manifest tests를 current failure boundary에 맞추고 unused import와 formatting drift를 제거했습니다. +- Traceability `f0d6dd57451984820afb07c41cae172f3c7f3628`: static manifest 결정, primary reference와 claim boundary를 기록했습니다. + +Hosted publication re-verification slice: + +- RED `db4ad0660bc0b262bdf921c243c9691bee29118c`: draft/final hosted asset set과 local admitted bytes의 parity, signature drift, missing/extra asset, duplicate/nested publication authority, workflow ordering을 executable contract로 추가했습니다. +- Fix `e0227942e31100d4a9a0dc3e8c8f7fa95a8613fb`: `verify_hosted_release_assets.py`를 추가해 bounded safe asset list와 flat hosted asset set을 비교하고 stable regular-file descriptor에서 size/full SHA-256 parity를 검증합니다. +- Publication wiring `3c9221b4ea517aea296739302e025b0c9196d198`: draft release upload 뒤 fresh download/re-verification이 성공해야 publish하고, publish 뒤 다시 fresh download/re-verification하도록 workflow를 연결했습니다. 이 commit은 이전 workflow EOF newline drift도 함께 바로잡았습니다. +- Test format `3412a19eb9c2f90ce10fde2d374e3a8a74b91b8e`: repository formatter 규칙에 맞춰 hosted re-verification coverage를 정리했습니다. +- Attestation RED `0f13ef0e11612e8bd3e25ae5783d372b812b6c6d`: published release가 GitHub signed release attestation과 per-asset attestation verification까지 통과해야 한다는 workflow contract를 추가했습니다. +- Attestation fix `b65f00cd607df30f9807894368667b92e2911e38`: post-publish hosted byte parity 뒤 `gh release verify`와 모든 local release asset의 `gh release verify-asset`을 fail-closed gate로 연결했습니다. + +Hosted exact-head workflow evidence가 terminal GREEN이 되기 전에는 위 source lineage만으로 release-ready 또는 merge-ready라고 주장하지 않습니다. 이 slice 이후의 head는 predecessor check/review evidence를 승계하지 않습니다. + +## 현재 claim boundary + +Target receipt, generated `latest.json`, draft/final hosted byte parity와 GitHub immutable-release attestation은 **검증된 tag package bytes, copied updater bundle/signature bytes, exact source identity, exact-tag download URL, GitHub release asset namespace와 GitHub signed release evidence를 하나의 Distribution publication graph로 결합**합니다. Manifest가 receipt에 기록된 `.sig` bytes의 exact text를 싣고 uploaded/downloaded bytes가 일치하며 GitHub attestation subject와 local assets가 맞는다는 것은 검증하지만, Tauri updater `.sig`가 아직 provision되지 않은 organization-approved updater public key로 cryptographically valid하다는 사실까지 증명하지 않습니다. + +현재 updater policy는 의도적으로 `blocked`입니다. 승인된 public verification key와 production discovery endpoint가 provision되지 않았기 때문에 현재 source를 상용 updater authority가 준비된 상태라고 해석하지 않습니다. `latest.json`, hosted re-verification과 immutable-release attestation gates는 future admitted release에서 사용할 publication primitives이며, tag preflight는 blocked policy에서 계속 fail closed합니다. + +다음은 아직 별도 acceptance 대상입니다. + +- approved Tauri updater public-key provisioning 및 generated `.sig` cryptographic verification; +- wrong-key/signature/digest/truncation 및 replay/stale-update 방지; +- staged rollout, explicit deferral, bounded retry, offline startup; +- failed/cancelled update 후 known-good rollback과 project-schema compatibility; +- SBOM/provenance/NOTICE/model artifact와 release attestation의 complete release-graph 결합; +- #770의 rights-cleared real-audio scientific acceptance; +- #1181의 commercial model-rights 해결. + +따라서 #960의 updater/rollback acceptance와 #1180의 complete model-release evidence는 계속 Open입니다. + +## 다음 단계 + +다음 Distribution causal slice는 **updater anti-replay + rollback contract**입니다. 현재 external prerequisite인 approved Tauri updater public key/production discovery endpoint가 들어오기 전에도 source-owned 상태기계와 persistence 경계는 설계·검증할 수 있습니다. Version monotonicity와 stale/replayed metadata 거부, unsupported target, truncated/partial download, disk-full/cancel, offline startup, first-launch failure, last-known-good installer retention, project-schema compatibility를 하나의 packaged update lifecycle로 연결해야 합니다. 실제 signature-positive acceptance는 승인된 key authority가 provision된 뒤 수행합니다. + +승인된 updater public key/production discovery endpoint, Windows/macOS signer authority와 commercial model rights는 외부 권위입니다. 이 값들은 source repair 과정에서 임의 생성하지 않습니다. + +## 참고문헌 + +GitHub. (2026). *Immutable releases*. https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases + +GitHub. (2026). *Verifying the integrity of a release*. https://docs.github.com/en/code-security/how-tos/secure-your-supply-chain/secure-your-dependencies/verify-release-integrity + +GitHub CLI. (2026). *gh release download*. https://cli.github.com/manual/gh_release_download + +GitHub CLI. (2026). *gh release verify*. https://cli.github.com/manual/gh_release_verify + +GitHub CLI. (2026). *gh release verify-asset*. https://cli.github.com/manual/gh_release_verify-asset + +SLSA Community. (2026). *SLSA specification, version 1.2: Provenance*. https://slsa.dev/spec/v1.2/provenance + +Tauri Contributors. (2026). *Tauri v2 updater plugin*. https://v2.tauri.app/plugin/updater/ + +Tauri Contributors. (2026). *Tauri Action: upload-version-json.ts* (Commit a6e90ddc4ba4721f294e52b856d3d50e645edc07). https://github.com/tauri-apps/tauri-action/blob/a6e90ddc4ba4721f294e52b856d3d50e645edc07/src/upload-version-json.ts + +in-toto Authors. (2024). *in-toto specifications: Stable specification and Attestation Framework v1.0*. https://in-toto.io/docs/specs/ diff --git a/docs/traceability/release-version-identity.md b/docs/traceability/release-version-identity.md new file mode 100644 index 000000000..cfb548023 --- /dev/null +++ b/docs/traceability/release-version-identity.md @@ -0,0 +1,105 @@ +# Release version identity traceability + +## Problem + +BandScope's release preflight originally required `VERSION` to be one trimmed line and required `package.json`, `tauri.conf.json`, and an optional `v` tag to agree with it, but it did not constrain the version grammar itself. The native Distribution/update policy core accepts only canonical stable `MAJOR.MINOR.PATCH` values with no leading zeros, prerelease suffix, or build metadata, and each numeric component is parsed as Rust `u64`. + +The first grammar repair rejected prerelease/build/leading-zero forms, but fresh review found one remaining cross-language mismatch: Python's regular expression still accepted arbitrarily large decimal components while `distribution-core::StableVersion` rejects any component above `u64::MAX` (`18446744073709551615`). A source version such as `18446744073709551616.0.0` could therefore pass release preflight and reach packaging even though the runtime updater would reject the same release identity. Publication and consumption must use the same stable-channel domain before any artifact write begins. + +Fresh review then found a separate file-admission problem in the same release gate. `verify_release_identity.py` described `VERSION`, `package.json`, and `tauri.conf.json` as trusted fixed repository paths, but it used `Path.read_text()` and plain `json.loads()`. A symlinked `VERSION` could therefore be followed, and duplicate JSON members such as two `version` keys were accepted according to Python's last-member-wins behavior. Release identity must not depend on pathname indirection or parser-specific duplicate-member resolution. + +A further parser-alignment review found that Python's `json.loads()` also accepts the JavaScript-style numeric constants `NaN`, `Infinity`, and `-Infinity` by default. RFC 8259 explicitly excludes those values from the JSON number grammar. A release projection containing the correct `version` plus one of those constants could therefore pass BandScope preflight while remaining invalid JSON for stricter package, signing, or release consumers. Release identity admission must reject parser extensions that are outside the interchange format rather than treating Python's permissive decoder as the format authority. + +The descriptor repair still had one concurrency hole. `_read_bounded_regular_text()` compared descriptor identity and file length before and after one read, but a writer could replace bytes in place without changing inode or length. A same-size rewrite of `package.json` immediately after the read could therefore leave preflight holding the old bytes while the repository path already exposed different release metadata. Size stability is not content stability. + +The double-read repair closed that in-place mutation class but left a separate pathname race. After the initial `lstat()` matched the opened descriptor, another writer could atomically replace the repository path with a different regular file. The still-open descriptor would continue returning the original bytes twice with stable inode and size, so preflight could authorize the old file while `package.json` at the repository path already named a different inode and different release metadata. Descriptor stability alone is not enough when the contract names a repository path as the release projection. + +## Decision + +`verify_release_identity.py` is the release-pipeline version gate because `package_desktop_artifact.py` invokes release preflight before creating release artifacts. Stable-channel `VERSION` must match exact numeric `MAJOR.MINOR.PATCH`; each component is `0` or a non-zero decimal without leading zeros and must also fit the same unsigned 64-bit range consumed by `distribution-core::StableVersion`. + +The Python guard compares decimal text against the exact `u64::MAX` decimal boundary instead of converting arbitrary-length input to Python integers. This keeps the accepted domain explicit and avoids a second numeric interpretation. The rule intentionally does not broaden the runtime to prerelease/build SemVer. A future beta/prerelease channel requires a separate release decision and one canonical ordering implementation. + +Release identity inputs are admitted from one opened descriptor each. `VERSION` is capped at 128 bytes; repository JSON projections are capped at 256 KiB. The opened object must be a regular file, its path must resolve to the same non-link file identity at admission, and descriptor device/inode/size must remain stable. The guard reads the bounded descriptor twice from offset zero and requires byte-for-byte equality between the two snapshots as well as exact length agreement with the descriptor. After that stable read, it `lstat()`s the repository path again and requires the path to still name the same regular non-link device/inode as the descriptor before decoding any release value. JSON decoding uses an object-pairs hook that rejects duplicate members before `version` is read. + +The double read plus final pathname identity check is deliberately small and deterministic: the largest projection is 256 KiB, there is no network or subprocess boundary, and release preflight runs before packaging rather than in a latency-sensitive product path. It detects same-size in-place mutation and path replacement during admission without adding a new lock owner or relying on filesystem timestamp granularity. + +JSON decoding also supplies an explicit `parse_constant` rejection hook. `NaN`, `Infinity`, and `-Infinity` therefore fail as malformed release metadata instead of entering the object graph as Python floating-point extensions. This keeps the gate aligned with RFC 8259 and with stricter downstream JSON consumers while preserving the existing duplicate-member error path. + +## RED → repair evidence + +- RED `9d1dc2f43e4149df9bcef8afa8859d872b663600` adds release-identity regression cases for prerelease, build metadata, leading-zero components, incomplete versions, and a `v`-prefixed version authority. The predecessor guard accepted those values when all projections agreed. +- Causal fix `e268c9bbb0dd9a0e977a5b757c8426fe8d2112be` adds the canonical numeric-triplet grammar gate to `verify_release_identity.py` before package/Tauri/tag projection comparison. +- Fresh range RED `1cf96561008d11f6afc06f1c3ca1eff85fd7bd03` adds overflow cases for major, minor, and patch at `u64::MAX + 1`. The grammar-only predecessor accepts those strings while the native `StableVersion` rejects them. +- Causal range fix `1c44f25790ef27691a9ae86484f67e87c725c16f` makes release preflight enforce the exact unsigned-64-bit component ceiling without widening the accepted syntax or adding a new version owner. +- File-admission RED `af893377a17c527df951dc70836c942509c230c3` adds two hostile repository fixtures: a duplicate `package.json.version` whose last member matches the authoritative version, and a symlinked `VERSION` whose target contains an otherwise-valid version. The predecessor `Path.read_text()`/plain `json.loads()` path accepts both. +- Causal file-admission fix `42d34b5e0b0bb01ebc8c6801552e10b9857ab1f0` replaces pathname reads with bounded descriptor reads, rejects non-regular/link identities, verifies descriptor identity/size stability, and rejects duplicate JSON members before projection comparison. +- Strict-JSON RED `68f1bb5531879b8f75d08f1a7e0db84f0b4a36c6` adds `NaN`, `Infinity`, and `-Infinity` fixtures alongside an otherwise-correct release version. Python's default decoder accepts all three even though they are not valid JSON numbers. +- Causal strict-JSON fix `494aa0f5d0b966a1a6e2c5fcc64ab5655c56d5d0` supplies an explicit `parse_constant` rejection hook so non-standard constants fail before any release version projection is consumed. +- Same-size mutation RED `a418640ae4c3775f967bab695422c77ee3d6f32e` rewrites `package.json` from `1.2.3` to `9.9.9` immediately after the first descriptor read while preserving byte length. The predecessor identity/size-only check can return the old bytes even though the repository file has already changed. +- Causal snapshot fix `438dca03c6cfcd2f1d2a1fd6084483d98879270f` performs a second bounded read on the same descriptor from offset zero and requires exact byte equality and stable descriptor identity/size before decoding. +- Path-replacement RED `f0b38c0a1b880afc6a219a05195301ba7fc477ba` atomically replaces `package.json` with a different regular inode immediately after the first descriptor read. The predecessor double-read sees two identical snapshots from the still-open old descriptor and can therefore authorize bytes no longer named by the repository path. +- Causal path-identity fix `906b4f42f065dd92c9d8274f4e198f5a3d33ed49` revalidates the repository path after the stable second read and requires it to still be a regular non-link file with the same device/inode as the admitted descriptor. +- The checked-in current authority remains `0.1.3`; these repairs change future admission, not the identity of the current source tree. + +## Alternatives rejected + +### Rely on package-manager version parsing + +Rejected. Release authority is consumed by Python preflight, Tauri configuration, native Distribution code, Git tags, and updater publication. A package manager accepting a string is not a cross-boundary release contract. + +### Treat the regular expression as equivalent to the Rust parser + +Rejected. Lexical grammar and numeric domain are different constraints. An unbounded decimal token can satisfy the regular expression while overflowing `u64`, which would recreate publication/runtime drift at the exact trust boundary this guard owns. + +### Convert arbitrary decimal strings directly with Python `int` + +Rejected. Python integers are not the runtime domain, and very large decimal conversions introduce interpreter-specific digit limits and needless work. Length plus lexicographic comparison against the fixed 20-digit `u64::MAX` representation expresses the actual native contract directly. + +### Trust Git checkout path shape and plain JSON parsing + +Rejected. Git can represent symlinks, and JSON duplicate-member behavior is parser-dependent. A release gate should not silently follow a different file object or let a last-member-wins parser choose release identity when another consumer could observe a different projection. + +### Accept Python's non-standard JSON numeric constants + +Rejected. RFC 8259 does not permit `NaN` or infinities as JSON numbers. Allowing them because Python can materialize them would make preflight validity depend on a decoder extension that stricter release consumers are not required to share. + +### Read once and compare only inode and size + +Rejected. Same-length in-place writes leave inode and size unchanged. The release gate must know that the bytes it parsed remained stable while it admitted them, not merely that the same file object retained the same length. + +### Trust a stable descriptor without rechecking the named path + +Rejected. Atomic pathname replacement does not change the already-open descriptor. Two identical reads can therefore prove the old file is stable while saying nothing about whether the repository path still names that file. A bounded final `lstat()` ties the admitted bytes back to the release projection path without reopening and parsing a second file object. + +### Use timestamps as the content-stability authority + +Rejected. Filesystem timestamp precision and update semantics vary by platform, and timestamps are metadata rather than the release bytes themselves. Two bounded reads of at most 256 KiB compare the actual content with negligible release-time cost. + +### Add a long-lived repository lock + +Rejected. Release preflight does not own Git checkout mutation or the entire packaging transaction. A new lock would create a second repository writer protocol and still would not make later consumers immutable. The guard instead proves stability for its own bounded read boundary and leaves post-gate repository immutability to the release workflow/commit identity contract. + +### Validate only in the updater-manifest builder + +Rejected. Tag packaging and release identity exist before manifest construction. The earliest shared release gate must reject an identity the runtime cannot consume rather than allowing earlier artifacts to be written and failing later. + +### Permit full SemVer in publication while keeping numeric-only runtime ordering + +Rejected. Prerelease precedence and build metadata semantics would then differ across publication and consumption. Stable channel remains numeric-only until a beta-channel design owns ordering, rollback, replay, and compatibility semantics end to end. + +## Claim boundary + +This repair proves that repository-controlled stable release preflight and the native updater decision core agree on version syntax and numeric component range, and that the three release-identity projection files are admitted through bounded, non-link, duplicate-rejecting, strict-standard-JSON descriptors whose two read snapshots must agree byte-for-byte and whose repository paths must still name the same descriptor identity at the end of admission. It does not make the entire checked-out repository immutable against a privileged local actor after the gate completes, prevent a later post-gate checkout mutation, authenticate remote updater metadata, verify updater signatures, provision signing authority, prove packaged update/recovery behavior, or make the current blocked updater/model policies commercially releasable. + +Hosted exact-head CI and independent review remain required before merge. Version/file-admission agreement does not substitute for Windows/macOS signing, updater-key authority, immutable release publication, or rights-cleared real-audio scientific acceptance. + +## References + +Bray, T. (2017). *The JavaScript Object Notation (JSON) Data Interchange Format* (RFC 8259). RFC Editor. https://www.rfc-editor.org/rfc/rfc8259 + +Preston-Werner, T. (n.d.). *Semantic Versioning 2.0.0*. https://semver.org/spec/v2.0.0.html + +Python Software Foundation. (2026). *json — JSON encoder and decoder: `parse_constant`*. Python 3 standard library documentation. + +Python Software Foundation. (2026). *os — Miscellaneous operating system interfaces: `open`, `fstat`, `lseek`, and `lstat`*. Python 3 standard library documentation. diff --git a/docs/traceability/umxhq-model-acquisition.md b/docs/traceability/umxhq-model-acquisition.md new file mode 100644 index 000000000..7ceed04eb --- /dev/null +++ b/docs/traceability/umxhq-model-acquisition.md @@ -0,0 +1,164 @@ +# Explicit UMX-HQ weight acquisition + +Status: implemented in the Draft Distribution owner, not a commercial model release. +Owner: #1126 / #1180. Weight-rights evidence: #1181. Offline evaluator: #828 / #770. +Implementation baseline: `528ba04cbaf2228bd819e3313f06f849ab027fac`. + +## Command + +Use the repository's Node 22 build environment. No npm install, Python, torch, +external downloader executable, credentials or paid API is needed for this command. +The parent directory must already exist and be a trusted private build workspace. +Use a new destination for each attempt; an existing path is never overwritten. + +First acquisition, explicitly accepting publisher-record bootstrap evidence: + +```sh +node scripts/release/download_umxhq.mjs --bootstrap --output ../umxhq-candidate +``` + +After independently reviewing and retaining that acquisition receipt, repeat with +full SHA-256 pins and another new output directory: + +```sh +node scripts/release/download_umxhq.mjs --pin ../umxhq-candidate/acquisition-receipt.json --output ../umxhq-reacquired +``` + +`--help` and module import perform no download. Missing, duplicate, conflicting or +unknown options fail before acquisition. There is no arbitrary URL/model option. +The command is not called by analysis, install hooks, or release packaging. CI runs +only its offline transport-fixture tests, not original-model downloads on each PR. + +## Acquisition contract + +The source record is fixed to `https://zenodo.org/api/records/3370489`, DOI +`10.5281/zenodo.3370489`, version `1.0.1`, and `metadata.license.id=mit-license`. +The four targets are fixed: + +| Target | Original filename | +|---|---| +| vocals | `vocals-b62c91ce.pth` | +| bass | `bass-8d85a5bd.pth` | +| drums | `drums-9619578f.pth` | +| other | `other-b52fbbf7.pth` | + +Download URLs are constructed exclusively under +`https://zenodo.org/records/3370489/files/`. The remote record's download URLs are +never followed. Training logs, ZIP files, UMXL, mirrors, Demucs and other models are +not acquisition targets. No checkpoint is imported, unpacked, deserialized or run. + +Before accepting file bytes, the command requires the exact published size and +checks the legacy publisher MD5. It computes full SHA-256 incrementally while +writing the bounded stream. Pinned reacquisition additionally requires every full +SHA-256 and size from the separately supplied receipt. Record input is at most +1 MiB, each checkpoint at most 64 MiB, and the notice at most 16 KiB. A four-file +attempt therefore has an explicit bounded storage requirement, not an unbounded +cache. No transparent compression, redirects or retries are enabled. + +**Bootstrap MD5 and the newly calculated SHA-256 are not independent publisher +signatures or commercial-release admission.** The first receipt is an observation +of an authenticated HTTPS acquisition. Review and retain its full hashes before +using `--pin`. A subsequent hash match proves byte identity relative to those pins, +not quality, licensor authority, training-data rights or absence of runtime bugs. + +## Rights and notices + +The model-weight permission basis is the original Zenodo model record, not the +source repository's code license inferred to cover every checkpoint. Preserve +`zenodo-record.json` and its SHA-256 as the retrieved weight-license evidence. +The public MIT grant does not require a separate OEM negotiation; normal license +obligations, third-party rights and the lack of indemnity remain distinct. + +The command also acquires and verifies the actual upstream notice at: + +`https://raw.githubusercontent.com/sigsep/open-unmix-pytorch/814f144e34b2d1ed517eb605ce928dcb838abbed/LICENSE` + +Its SHA-256 is +`4f7b047ffafb9fbb39a40d605bab961b9b030711addce6e9d23886c2ae3b105e`. +`scripts/release/fixtures/openunmix-LICENSE.txt` preserves those exact notice bytes +for the offline contract tests. The notice preserves Inria's named 2019 copyright +and full permission/warranty text. Do not substitute it for the separate Zenodo +weight-license record or extend it to UMXL. + +## Output and offline evaluator handoff + +A successful directory contains four `.pth` files, `zenodo-record.json`, +`LICENSE.openunmix`, and `acquisition-receipt.json`; no `INCOMPLETE` marker remains. +The receipt's `checkpoints` mapping uses the same target names and +`filename`, `size_bytes`, `sha256` fields as #828's `CheckpointReceipt`. +Its `record_kind` is `candidate_acquisition_receipt_not_release_manifest` and +`release_admitted` is always `false`. It does not modify the checked-in candidate +coordinates, production model policy, supplemental shipped inventory or default +model. No code in this change bypasses the existing release preflight. + +The consumer must reject a directory with an `INCOMPLETE` marker or missing receipt, +reverify local file bytes against independently reviewed pins and retain the +existing descriptor/path controls before passing immutable bytes to the evaluator. +No missing-file recovery path inside inference may invoke this acquisition command. + +## Interruption, failure and retry + +SIGINT/SIGTERM cancel the request and bounded stream; cancellation exits 130. +Other acquisition errors exit 1; argument errors exit 2. Failure diagnostics omit +provider payloads, local paths and credentials. Files are created exclusively with +restrictive permissions and published with no-overwrite hard links, then the +partial pathname is removed. Unsupported filesystem operations fail closed. + +Incomplete attempts retain an `INCOMPLETE` marker and may retain bounded `.part` +files for inspection. They are never reused as successful cache entries. No +recursive deletion, overwrite, silent resume or retry occurs. Inspect/remove only +the failed attempt you own, or choose a new private destination. A process/power +failure around receipt publication is not success while `INCOMPLETE` remains. +This tool does not claim crash-proof directory durability or protection against a +host administrator or malicious same-user process replacing ancestor directories. + +## Implementation boundary and removal condition + +This dependency-free Node command is a **build/qualification acquisition tool**, +using the already-required Node build environment and its built-in TLS/crypto/files. +It adds no desktop runtime, no Python downloader, no package-manager dependency, +no model binary to Git, and no lockfile exception. The production HTTP/downloader, +update admission and filesystem authority remain the existing Rust Distribution +components. It must never become a second application networking implementation. +When the released Rust acquisition API covers this bootstrap task, delegate this +command to that owner and retire its build-only transport while preserving the +same exact-source, pinning and negative-test contracts. + +## Security Notes + +### Attack surface + +Publisher metadata and byte streams, TLS process state, output paths, local pin +files, cancellation and interrupted writes. + +### Trust boundary + +Explicit operator/build-host acquisition is separate from model loading, scientific +acceptance and commercial release. The build host and private parent workspace +are trusted. Remote links and model bytes are data, never executable instructions. + +### Mitigations + +Fixed source URLs; explicit TLS certificate verification with built-in roots and +no shared/proxy agent; direct 200 responses only; byte/content-encoding checks; +complete four-target schema; descriptor-bound bounded pin reads; exclusive output; +path-identity checks; no-overwrite file publication; mandatory receipt/marker +protocol; full SHA-256 pins for reacquisition; no inference or release-policy writes. + +### Remaining risks + +First acquisition relies on publisher HTTPS and metadata, not a signed full-digest +release manifest. MD5 is only legacy consistency evidence. Same-user/admin races, +filesystem crash durability, full native packaging and actual pretrained quality +remain outside this tool's claims. Node/runtime vulnerabilities still require the +existing supply-chain and native build gates; they are not waived here. + +### Test points + +Run `node --test scripts/release/download_umxhq.test.mjs scripts/release/download_umxhq_cli.test.mjs`. +The existing `.github/workflows/ci.yml` Distribution-download matrix runs this same +command on Linux, Windows and macOS before its existing Rust tests. Fixtures test +success, SHA-256-pinned reacquisition, corruption/truncation/overflow, bad metadata, +redirect/compression/HTML/rate-limit responses, cancellation, path substitution, +pin-file races, notice tampering, CLI dispatch and no-overwrite behavior. No fixture +is a real pretrained model or source-separation quality result. diff --git a/docs/traceability/updater-bounded-download.md b/docs/traceability/updater-bounded-download.md new file mode 100644 index 000000000..e84e16fa7 --- /dev/null +++ b/docs/traceability/updater-bounded-download.md @@ -0,0 +1,121 @@ +# Updater bounded-download traceability + +BandScope의 Distribution/update 경계는 updater artifact를 신뢰하기 전에 remote response가 메모리·디스크 자원을 무제한 소비하지 못하도록 막아야 합니다. Current Tauri updater API는 `Update::download()`가 검증된 artifact를 `Vec`로 반환하므로, 그 경로 자체를 commercial hostile-response resource admission 근거로 사용할 수 없습니다. + +## 문제와 제약 + +`latest.json`의 `bandscope.artifacts[target].sizeBytes`는 publication-time evidence입니다. Remote endpoint가 그 값을 지킨다는 보장은 없고, Tauri artifact signature verification은 download가 끝난 뒤 일어납니다. 따라서 URL namespace pinning과 declared size만으로는 oversized/chunked response, partial response, disk-full 또는 sink failure를 fail closed한다고 주장할 수 없습니다. + +이 단계에서는 metadata authenticity와 updater signing authority가 아직 provision되지 않았으므로 네트워크 fetch, signature verification, anti-replay state mutation을 한 번에 구현하지 않습니다. 대신 이후 adapter가 반드시 통과해야 하는 byte-admission과 temporary staging primitive를 Rust로 분리합니다. + +## RED → causal fix + +- RED `1e1f1c2e867caacbedc1975c4b475f2a07c28abd`: repository-owned native Distribution suite가 `apps/desktop/distribution-download/Cargo.toml`을 반드시 실행하도록 먼저 요구했습니다. 이 head에서는 crate가 존재하지 않아 contract가 실패합니다. +- Fix `f183c0ca2a16d0324b0c33341dfc575503568e53`: dependency-free `bandscope-distribution-download` Rust crate를 추가했습니다. `ArtifactDownloadAdmission`은 authenticated expected size와 optional HTTP `Content-Length`를 받아 streaming chunk를 caller-owned sink에 기록하기 전에 누적 byte ceiling을 검사합니다. +- Staging RED `dcc04b78b7d51c5e79f39594ac4f02090930792e`: exclusive temporary file, cancellation cleanup, exact-receipt seal, partial-download cleanup, existing-path/path-traversal rejection을 integration contract로 먼저 요구했습니다. +- Staging fix `ed079fdc4b6150515a1352e892307d6b24bedf6e`: `StagedArtifactFile`과 `SealedArtifactFile`을 추가해 app-owned staging directory 안의 direct portable basename만 `create_new`로 생성하고, response bytes는 public raw-write API가 아니라 `admit_chunk`를 통해서만 descriptor로 보냅니다. Seal은 flush → `sync_all()` → descriptor metadata regular-file/size 확인 후에만 성공하며 still-open descriptor를 반환합니다. 당시 구현은 seal 전 drop/cancel/error 뒤 staging path를 best-effort 제거했습니다. +- Coverage `762024843218a86567c855ee1474a10549a3032a`: receipt-size mismatch cleanup, missing/non-directory staging root와 Unix symlink staging-root rejection까지 추가했습니다. +- Trust-promotion RED `a956bcfab7670aa7a461c8929c75cda8b79ba118`: exact-size seal만 성공하면 `SealedArtifactFile` drop 뒤에도 bytes가 남는 기존 동작을 뒤집어, digest/signature trust promotion 전 sealed artifact가 정상 retention 경로로 남아서는 안 된다는 integration contract를 먼저 만들었습니다. +- Causal fix `e76abddb0c40293901cd8672919172d47a93b5b9`: seal은 더 이상 artifact retention을 의미하지 않습니다. `SealedArtifactFile`이 descriptor cleanup 책임을 넘겨받고 당시 구현은 drop 시 descriptor를 먼저 닫은 뒤 staging path를 제거했습니다. 아직 별도의 verified-artifact promotion type은 만들지 않았으므로 unverified sealed bytes를 신뢰된 장기 보존으로 승격하는 public 경로도 없습니다. +- Descriptor-capability RED `56aa7467a43299500e79d2e26469b252ae9519c0`: sealed artifact 검증자가 path reopen 없이 byte zero부터 exact descriptor bytes를 읽을 수 있는 read-only stream contract를 먼저 추가했습니다. 당시 `SealedArtifactFile`에는 `reader()`가 없고 대신 write-enabled staging `File`을 `&File`로 직접 노출하고 있어 RED입니다. +- Compatibility cleanup `13ca9b7862f59c06f5dcc0337c846c050a3c7199`: 기존 staging lifecycle test가 raw `File` accessor에 의존하지 않도록 정리해 capability 제거를 준비했습니다. +- Causal fix `6144302ed807367742f87247b353742f213dbedb`: public `&File` accessor를 제거하고 `SealedArtifactReader`를 추가했습니다. Reader는 Unix/macOS에서 `FileExt::read_at`, Windows에서 `FileExt::seek_read`를 사용해 still-open descriptor를 path reopen 없이 positional read하며 `Read`만 구현합니다. Staging descriptor는 내부적으로 read/write로 열려 있어도 downstream verifier가 그 write capability를 회수할 public API가 없습니다. +- Post-seal growth RED `e1274bee951b2eb1bb58d3dbbb59d21289434384`: exact-size seal 이후 같은 inode가 외부 경로로 append되더라도 verifier stream이 최초 admitted byte boundary를 넘어 읽어서는 안 된다는 integration contract를 추가했습니다. 기존 reader는 descriptor EOF까지 읽기 때문에 appended tail까지 반환하므로 RED입니다. +- Causal fix `c4510966b874778a67f3c50f09acaf858fe7c70c`: `SealedArtifactReader`에 `remaining_bytes`를 두고 모든 positional read를 seal 당시 `bytes_written` 범위로 제한했습니다. Reader는 admitted range를 모두 읽은 뒤에는 descriptor가 더 길어져도 EOF를 반환하며, admitted range가 중간에 짧아지면 `UnexpectedEof`로 fail closed합니다. +- Truncation coverage `e294147e3d93757b7a6115222fb78317152ecc74`: seal 뒤 descriptor가 admitted size 아래로 줄어드는 경우 verifier read가 정상 completion으로 끝나지 않고 `UnexpectedEof`를 반환하는 회귀 테스트를 추가했습니다. +- Restart-recovery RED `5b0ddb585ee1eb7ddadaa66eeab267c6f55d6467`: process kill/power loss가 `Drop`을 건너뛰어 exact staging basename의 regular file을 남긴 상황을 재현하고, 다음 실행이 stale bytes를 신뢰하지 않으면서 새 attempt를 시작해야 한다는 integration contract를 추가했습니다. 기존 `create_new`-only 구현은 `DestinationExists`로 실패합니다. +- Restart causal fix `b7a1839d5941c52800bbeaf22921e143060d1ff6`: app-owned non-symlink staging root와 portable basename을 먼저 검증한 뒤 exact child를 `symlink_metadata`로 분류합니다. Existing regular file만 interrupted unverified attempt로 제거하고 다시 `create_new`하며, symlink/directory 등 non-regular entry는 자동 제거하지 않고 fail closed합니다. +- Concurrent-writer RED `82843b4833df264aa6d5530d9f46545b1179a0bb`: 살아 있는 첫 staging attempt가 partial bytes를 보유한 동안 두 번째 attempt가 같은 regular pathname을 crash residue로 오인해 reclaim해서는 안 된다는 계약을 추가했습니다. Restart-only 구현은 live regular child와 stale regular child를 구별할 ownership evidence가 없어 실패합니다. +- Causal fix `40cd7543fab6ac6cb203e310b16058645edcedae`: artifact pathname을 검사하거나 stale regular child를 제거하기 전에 app-owned staging directory의 persistent `.bandscope-staging.lock`을 열고 `File::try_lock()` exclusive lease를 획득합니다. 다른 cooperating BandScope handle/process가 lock을 보유하면 `ConcurrentAttempt`로 fail closed합니다. Lease는 `StagedArtifactFile`에서 `SealedArtifactFile`로 함께 이동하고 descriptor cleanup 뒤에만 해제됩니다. +- Fixture adaptation `ebf94287ea54d329a3276f02a5251054c9b2d20c`: persistent lease sentinel과 ephemeral artifact cleanup을 test teardown에서 구분했습니다. +- Edge coverage `d2d187288ef27e7fabdacde062d83423bfa2e243`: sealed-but-unverified artifact가 lease를 계속 보유하는지, cleanup 뒤 fresh attempt가 가능한지, Unix에서 lease sentinel symlink를 따라가지 않는지를 검증합니다. +- Restart/concurrency traceability `ebefa230880f3e460be012af9cbc42651814c73f`: stale recovery, active-process ownership, persistent sentinel, OS lock의 claim boundary와 기각 대안을 별도 traceability 문서에 연결했습니다. +- Windows pathname RED `308f4a618cbbfd07fc9380b721f9987b3cb373d1`: cancelled/sealed artifact가 열려 있는 동안 owned file을 다른 이름으로 이동하고 original basename에 unrelated replacement를 만든 뒤 Drop했을 때 replacement bytes가 살아 있어야 한다는 기존 Unix contract를 Windows까지 확장했습니다. 당시 non-Unix fallback은 descriptor close 후 remembered pathname을 무조건 삭제하므로 RED입니다. +- Windows causal fix `1d603316b0ff4ff8b737aea2ef57c76250e326d0`: Unix는 still-open descriptor `(dev, ino)`와 현재 direct regular pathname identity가 일치할 때만 unlink합니다. Stable Rust에서 동등한 Windows by-handle identity를 증명할 수 없는 경로는 descriptor를 닫되 remembered pathname을 삭제하지 않습니다. 다른 객체를 지울 가능성보다 unverified scratch를 임시로 남기는 쪽을 선택했습니다. +- Windows deferred-reclaim coverage `1a6ee097106cf2204cf9bdbcc0040999988f4e14`: deferred Windows scratch가 다음 staging attempt의 shared lease 아래에서 stale direct regular child로 회수되고 byte zero에서 새 attempt가 시작됨을 고정합니다. +- Path-identity traceability `0bae56fe905ed165c6973867f6c7e896c431195f`: Windows 기본 sharing, 기각 대안, storage-retention tradeoff와 residual pathname race의 claim boundary를 `updater-staging-path-identity.md`에 기록했습니다. +- Test-contract adaptation `7996cc2a1ae722093ab02c5a590ccc562a2b058b`, `2acb2f3708b6ef6fb664e7dcc2741fa219c199ed`, `4c94b4698c79fd32f659fb58106f2da0aa510d1a`, `2e4c355df5c45117c01ff1585e6478a8951ad168`, `6a85d358d46194d60a852f59463fa23252785992`: sealed-reader, staging-lifecycle, and transport integration fixtures를 OS별 cleanup contract 및 persistent lease sentinel semantics에 맞췄습니다. Windows에서 deferred scratch를 trust success로 간주하지 않고 fixture teardown 또는 다음 leased attempt가 명시적으로 회수합니다. + +## 실행 계약 + +`ArtifactDownloadAdmission`은 다음 invariant를 가집니다. + +- expected artifact size는 0보다 크고 2 GiB 이하이어야 합니다. +- HTTP `Content-Length`가 존재하면 authenticated expected size와 정확히 같아야 body admission을 시작할 수 있습니다. +- caller가 넘기는 한 chunk는 1 MiB 이하이어야 합니다. +- 누적 byte 수가 expected size를 넘기는 chunk는 sink에 쓰기 전에 거부합니다. +- sink write가 일부 진행된 뒤 실패할 가능성을 고려해 write failure 이후 attempt를 poisoned 상태로 만들고, 이후 chunk나 success receipt를 허용하지 않습니다. +- response가 expected size보다 짧게 끝나면 `finish()`은 `Incomplete`를 반환합니다. +- exact byte count를 모두 기록했을 때만 `DownloadReceipt`가 생성됩니다. + +`StagedArtifactFile`은 그 receipt가 실제 temporary artifact lifecycle로 승격될 때 다음 invariant를 추가합니다. + +- staging root는 이미 존재하는 non-symlink directory여야 합니다. Directory 생성이나 임의 parent traversal은 이 crate가 수행하지 않습니다. +- artifact name은 bounded ASCII portable basename이고 `/`, `\\`, percent encoding, hidden/path-like name과 Windows reserved device stem을 허용하지 않습니다. +- stale artifact classification보다 먼저 persistent staging sentinel의 exclusive OS file lease를 획득합니다. 이미 cooperating process가 lease를 보유하면 `ConcurrentAttempt`로 실패하고 existing artifact pathname을 건드리지 않습니다. +- lease sentinel은 coordination object일 뿐 content trust evidence가 아닙니다. 정상 종료 뒤에도 pathname은 남을 수 있고 active ownership은 open handle의 OS lock으로 판단합니다. +- lease sentinel이 symlink 또는 non-regular object이면 따라가거나 자동 교체하지 않고 fail closed합니다. +- lease를 획득한 뒤 같은 exact basename의 pre-existing regular file만 interrupted unverified attempt로 간주합니다. 해당 bytes는 재사용/resume하지 않고 제거한 뒤 byte zero에서 새 `create_new` attempt를 시작합니다. +- pre-existing symlink, directory 또는 기타 non-regular artifact destination은 stale regular artifact로 자동 정리하지 않습니다. Lease를 획득한 뒤 cleanup/create 사이에 path를 다른 actor가 선점해도 `create_new`가 overwrite하지 않고 `DestinationExists`로 실패합니다. +- response write는 `ArtifactDownloadAdmission`을 통과해야 하므로 staged descriptor에 caller가 raw bytes를 직접 쓰는 public API가 없습니다. +- cancel, overrun, sink failure 또는 seal failure 상태로 drop되면 descriptor ownership은 종료되지만 pathname cleanup은 OS별입니다. Unix는 현재 pathname이 still-open descriptor와 같은 direct regular object일 때만 unlink합니다. Windows는 stable code에서 그 identity를 증명할 수 없으므로 pathname 삭제를 추측하지 않고 unverified scratch를 다음 leased attempt까지 남길 수 있습니다. +- seal은 userspace flush와 descriptor `sync_all()` 이후 descriptor가 regular file인지, exact receipt size와 같은지 다시 확인합니다. +- 성공한 `SealedArtifactFile`은 descriptor와 staging lease를 함께 계속 열어 두므로 후속 digest/signature verification이 path reopen보다 exact staged bytes에 결합되고, 검증 중 다른 cooperating attempt가 pathname을 stale로 reclaim하지 못합니다. +- sealed verifier access는 `SealedArtifactReader`의 positional `Read` stream으로 제한합니다. 내부 staging `File`은 write-enabled이지만 raw `&File`을 public하게 반환하지 않으므로 verifier가 `Write for &File` 또는 platform `FileExt` write API로 sealed bytes를 바꾸는 capability를 얻지 않습니다. +- `SealedArtifactReader`는 seal 당시 admitted byte count까지만 읽습니다. Seal 뒤 같은 inode가 더 길어져도 appended bytes는 verifier input이 되지 않으며, admitted range가 짧아지면 정상 EOF가 아니라 `UnexpectedEof`로 거부합니다. 따라서 verifier input의 resource bound가 path-side file growth 때문에 다시 열리지 않습니다. +- exact-size seal은 신뢰 승격이 아닙니다. Unix에서는 identity가 일치하는 staging pathname을 Drop에서 제거합니다. Windows에서는 pathname ownership을 증명할 수 없을 때 descriptor만 닫고 unverified scratch를 남길 수 있으며, 다음 attempt가 shared lease를 획득한 뒤 stale direct regular child로만 회수합니다. 어느 경우에도 deferred bytes는 verified artifact나 freshness authority가 아닙니다. +- verified artifact promotion은 이 scratch basename을 장기 보존 위치로 재사용해서는 안 됩니다. 검증된 bytes를 별도 retained/known-good owner로 이동한 뒤에만 launch 간 보존을 허용해야 합니다. + +Unit/integration tests는 exact chunked completion, missing `Content-Length`, header mismatch, overrun-before-write, oversized single chunk, truncated response, partial sink failure, zero/over-ceiling expected size, OS별 cancellation cleanup, exact seal 후 Unix unlink/Windows deferred reclamation, pathname replacement 보존, descriptor-bound read-only sealed stream, seal 후 external growth에 대한 admitted-range cap, seal 후 truncation fail-closed, failed-admission cleanup, receipt mismatch, stale regular destination restart recovery, active concurrent staging rejection, sealed lease retention/release, persistent lease sentinel teardown, path-like name, invalid staging root, Unix symlink root·lease sentinel·artifact destination 보존을 다룹니다. Python production logic은 추가하지 않았고 repository harness는 locked Rust suite를 validation boundary로 호출합니다. + +## 기각한 대안 + +Tauri의 기존 `download()` callback에서 누적 `chunk_length`만 세는 방식은 기각합니다. Callback은 이미 Tauri 내부 buffering 이후의 progress signal일 뿐, BandScope가 response body를 hard bound하는 write boundary가 아닙니다. + +Declared `sizeBytes`와 `Content-Length`를 동일시하는 방식도 기각합니다. `Content-Length`는 transport metadata라서 없거나 거짓일 수 있으며, cumulative byte admission이 별도로 필요합니다. + +전체 artifact를 먼저 `Vec`로 받은 뒤 길이를 검사하는 방식도 기각합니다. Resource exhaustion이 일어난 뒤 검사하는 것이므로 commercial resource-admission 요구를 만족하지 않습니다. + +Generic temporary pathname에 overwrite-open하고 나중에 검사하는 방식도 기각합니다. Existing file/symlink를 교체하거나 path-like name이 app-owned staging root를 벗어날 수 있고, cancel/error 뒤 partial artifact를 성공 candidate처럼 남길 수 있습니다. + +Crash 뒤 남은 regular staging file을 그대로 resume하는 방식도 기각합니다. 이전 process의 response completion, metadata/signature context와 admitted byte boundary를 증명할 수 없으므로 stale bytes는 새 response와 혼합하지 않고 제거한 뒤 처음부터 받습니다. + +모든 pre-existing destination을 자동 삭제하는 방식도 기각합니다. Symlink나 directory 같은 non-regular entry를 stale partial과 동일 취급하면 app-owned scratch 경계를 벗어난 mutation 가능성이 생깁니다. Regular child만 reclaim하고 non-regular entry는 fail closed합니다. + +Artifact file 자체만 lock하는 방식도 기각합니다. Portable `create_new`와 file-lock acquisition 사이를 하나의 atomic create+lock operation으로 보장할 수 없어 새 pathname이 다른 process에 관찰되는 순간과 active ownership establishment가 분리됩니다. 별도 persistent sentinel의 lease를 먼저 획득해 stale classification 자체를 직렬화합니다. + +Lease sentinel을 정상 drop마다 삭제하는 방식도 기각합니다. Locked sentinel pathname을 unlink하고 새 inode를 만들 수 있게 하면 기존 inode를 열어 기다리던 process와 새 process가 서로 다른 lock domain을 가질 수 있습니다. Sentinel pathname은 유지하고 open handle의 lock 보유 여부만 active ownership으로 사용합니다. + +Exact-size seal을 곧바로 artifact retention으로 취급하는 방식도 기각합니다. Byte count와 `sync_all()`은 digest, updater signature, remote metadata authenticity를 증명하지 않습니다. Windows에서 안전한 pathname unlink를 증명할 수 없어 unverified scratch가 다음 leased attempt까지 남을 수는 있지만, 이는 trust promotion이나 known-good retention이 아니라 cleanup을 보수적으로 지연한 것입니다. + +Sealed artifact에서 raw `&File`을 verifier에 넘기는 방식도 기각합니다. Rust standard library는 `Write for &File`을 구현하고 있고 staging descriptor 자체가 write access로 열린 상태이므로, immutable borrow처럼 보이는 API가 실제로는 sealed bytes를 바꿀 수 있는 write capability를 노출합니다. 별도 path reopen은 descriptor identity를 잃으므로, 동일 open descriptor에 대한 positional read-only wrapper를 사용합니다. + +Descriptor EOF까지 무제한 읽는 방식도 기각합니다. Seal 당시에는 exact size였더라도 이후 같은 inode가 path-side append로 커질 수 있습니다. Verifier가 EOF까지 `read_to_end`하면 byte-admission에서 닫았던 resource bound가 다시 열리고, digest/signature input 범위도 original receipt보다 넓어집니다. Reader가 admitted byte count를 자체적으로 소유하고 그 범위를 넘지 않게 해야 합니다. + +## Claim boundary + +현재 crate는 **network-library-independent streaming + staging primitive**입니다. 실제 production updater가 아직 이 crate를 통해 HTTP body를 수신하지 않으므로 end-to-end bounded download가 완료됐다고 주장하지 않습니다. Source-level lease는 cooperating BandScope processes 사이에서 live attempt와 crash residue를 구분하지만 임의의 로컬 악성 process에 대한 mandatory filesystem isolation은 아닙니다. Rust file lock은 platform에 따라 advisory 또는 mandatory일 수 있고, staging root ACL/ownership hardening과 pathname TOCTOU 방어는 별도 security boundary입니다. + +Stale regular child recovery와 active-writer tests는 process-kill 뒤 동일 update가 영구 차단되거나 다른 live BandScope attempt가 pathname을 reclaim하는 source 경로를 닫습니다. Unix pathname cleanup은 metadata-check 이후 unlink까지의 hostile same-user TOCTOU를 완전히 제거한다고 주장하지 않습니다. Windows는 stable Rust에서 descriptor/path identity를 확인하지 못하므로 destructive Drop cleanup을 하지 않고 stale scratch retention을 허용합니다. 이 retention은 다음 leased attempt에서 bounded regular-child recovery가 가능한 범위이며, packaged Windows/macOS power-loss durability, antivirus/file-lock, disk-full, filesystem crash 전체를 증명하지 않습니다. `sync_all()`과 cleanup tests를 packaged durability와 동일시하지 않으며 이 crate는 SHA-256, updater signature, metadata authenticity, installer trust도 검증하지 않습니다. + +다음 repository-owned 단계는 production network adapter가 full-response buffering 없이 bounded chunks를 이 primitive에 전달하도록 연결하는 것입니다. 그 adapter는 canonical release origin/redirect 정책을 보존하고 implicit redirect/transparent decompression을 끄며, cancel/network error/disk-full을 staged-file cleanup 또는 명시적인 deferred-scratch recovery 상태로 귀결시켜야 합니다. 그 뒤 organization-approved updater key가 provision되면 still-open sealed descriptor의 signature와 digest/size를 authenticated release identity에 묶고, 그 검증을 통과한 bytes만 별도의 verified-artifact promotion 경계로 보존한 뒤 `distribution-core`와 `distribution-state`로 freshness authority를 넘겨야 합니다. + +## Security Notes + +Attack surface는 updater HTTP response body, transport length metadata, temporary artifact directory/path, staging lease, staged descriptor와 cancellation/error paths입니다. Remote response는 canonical release namespace를 통과해도 untrusted입니다. Byte/staging admission failure는 installer 실행이나 highest-seen state mutation으로 승격되지 않아야 하며, staging root는 Distribution-owned app storage로 제한해야 합니다. Restart recovery는 stale bytes를 살리는 기능이 아니라 active cooperating owner가 없음을 lease로 확인한 뒤 app-owned scratch의 exact regular child를 제거하고 새 admission을 시작하는 기능입니다. Lease sentinel과 artifact pathname의 symlink/non-regular object는 자동 정리하지 않습니다. Unix Drop은 descriptor identity가 일치하는 direct regular pathname만 제거하고, Windows Drop은 stable code에서 identity를 증명할 수 없으면 pathname 삭제를 지연합니다. Deferred Windows scratch는 여전히 unverified이며 다음 leased attempt가 stale regular child로만 회수합니다. Sealed descriptor의 raw write capability는 verifier에 노출하지 않으며, 후속 검증은 descriptor-bound read-only stream을 사용해야 합니다. 그 stream은 seal 당시 admitted byte count를 상한으로 삼아 post-seal growth를 무시하고 early truncation을 error로 처리해야 합니다. Audio/project bytes나 paths는 updater request/receipt에 포함하지 않습니다. + +## References + +Tauri Contributors. (2026). *Updater*. Tauri v2 documentation. https://v2.tauri.app/plugin/updater/ + +Tauri Contributors. (2026). *tauri-plugin-updater 2.11.0*. docs.rs. https://docs.rs/tauri-plugin-updater/latest/tauri_plugin_updater/struct.Update.html + +Rust Project Developers. (2026). *File and TryLockError in std::fs* (Rust 1.98.1). https://doc.rust-lang.org/std/fs/struct.File.html#method.try_lock + +Rust Project Developers. (2026). *Read in std::io* (Rust 1.98). https://doc.rust-lang.org/std/io/trait.Read.html + +Rust Project Developers. (2026). *Write in std::io* (Rust 1.98). https://doc.rust-lang.org/std/io/trait.Write.html + +Rust Project Developers. (2026). *FileExt in std::os::unix::fs* (Rust 1.98). https://doc.rust-lang.org/std/os/unix/fs/trait.FileExt.html + +Rust Project Developers. (2026). *FileExt in std::os::windows::fs* (Rust 1.98). https://doc.rust-lang.org/std/os/windows/fs/trait.FileExt.html diff --git a/docs/traceability/updater-highest-seen-concurrency.md b/docs/traceability/updater-highest-seen-concurrency.md new file mode 100644 index 000000000..586fdc621 --- /dev/null +++ b/docs/traceability/updater-highest-seen-concurrency.md @@ -0,0 +1,74 @@ +# Updater highest-seen state concurrency traceability + +## 문제 + +`bandscope-distribution-state`는 authenticated release의 highest-seen identity를 app-local state로 보존합니다. 이 authority가 두 desktop process 사이에서 직렬화되지 않으면 두 writer가 같은 snapshot에서 출발해 중복/충돌 record를 만들거나 reader가 concurrent update 직전 값을 정상 authority로 사용할 수 있습니다. 그래서 최초 read부터 tail repair, commit, sync까지 sibling OS lease가 필요합니다. + +별도 filesystem finding도 있습니다. Unix에서 `highest-seen.log`가 다른 pathname과 hard link로 같은 inode를 공유한 채 in-place truncate/append되면 BandScope가 다른 alias의 bytes까지 변경할 수 있습니다. Windows에서는 같은 문제를 해결하려고 `std::os::windows::fs::MetadataExt::number_of_links()`를 직접 사용했던 선행 수리가 있었지만, Rust 1.98.1은 이 API를 nightly-only `windows_by_handle` 기능으로 문서화합니다. Hosted Distribution lane은 의도적으로 `cargo +stable`을 사용하므로 그 구현은 commercial evidence가 될 수 없었습니다. + +## 결정 + +Process serialization은 기존 sibling lease를 유지합니다. `load_highest_seen()`과 `remember_highest_seen()`은 `.bandscope-highest-seen.lock`에 `File::try_lock()`을 획득한 뒤에만 state를 읽거나 갱신합니다. Contention은 `StateError::ConcurrentMutation`으로 fail closed합니다. + +Filesystem alias 통제는 OS capability에 맞게 다르게 구현합니다. + +- **Unix/macOS/Linux**: pathname metadata와 opened descriptor 모두 stable `MetadataExt::nlink() == 1`이어야 합니다. Existing multi-link state는 `NotRegularFile`로 거부하고 기존 append/torn-tail truncation semantics를 유지합니다. +- **Windows**: stable Rust에서 link count를 읽는다고 가장하지 않습니다. Existing state는 read-only admission 후, mutation이 필요할 때 current committed log bytes를 sibling `.bandscope-highest-seen.next`에 `create_new`로 작성하고 `sync_all()`한 다음 `std::fs::rename()`으로 state pathname만 교체합니다. 따라서 pre-existing hard-link alias가 있더라도 alias가 가리키는 기존 file record를 truncate/append하지 않습니다. Exact-repeat with no torn tail is write-free. Recoverable tail repair도 same-path truncation 대신 synchronized snapshot replacement를 사용합니다. + +Rust `std::fs::rename`은 destination이 존재하면 replacement semantics를 제공하며, Windows 10 1607+에서는 지원 filesystem에서 `FileRenameInfoEx` 기반으로 Unix와 같은 replacement behavior를 사용합니다. 이 선택은 nightly toolchain, shell command, unsafe FFI를 도입하지 않고 stable owner code에서 alias mutation을 피하기 위한 것입니다. + +## RED와 수리 lineage + +- `e25fd44daa62f994d40a378de8c06a87eed5fb86`: sibling lease contention에서 reader/writer가 진행하면 안 된다는 cross-process RED. +- `dd2ae7f45468ad06bf50a83d279dcb8facf1d783`: state read/repair/append 전체를 sibling OS lease로 직렬화. +- `b6334829b474a60ba2ad0e7a7d77ec93822b20f2`: pre-existing hard-link alias가 다른 pathname bytes를 변경할 수 있다는 filesystem RED. +- `4f8c9336141456ad9f669f88fa5373aa7902c9ca`: Unix/Windows link-count admission을 처음 도입했으나, Windows `number_of_links()`가 stable API가 아니라는 후속 finding이 남음. +- #1220: stable Windows CI와 nightly-only `windows_by_handle` 사용 불일치를 repair finding으로 승격. +- `e07a50ddc343a43e525ccd9c9e6c621f3abfe8df`: Windows mutation을 in-place append/truncate에서 synchronized sibling snapshot + pathname replacement로 전환하고 nightly-only metadata call을 제거. +- `53037926b0d09ccc2b05cebc5b864c47e20ae620`: direct link-count rejection contract를 Unix로 한정. +- `2f231d65c0736abfb83feb9223c008357457e5f7`: Windows에서 hard-linked existing state의 monotonic update와 torn-tail repair가 alias bytes를 변경하지 않고 state pathname만 새 committed snapshot으로 전진해야 한다는 platform contract 추가. +- `8832e62fcc2e711a325d1468f836c06ba91bca33` → `c5a09d5c0d14dbdba776997ba920dd99dbbba2cf`: state/runtime/transport standalone owners를 Ubuntu, Windows 2025, macOS 15에서 exact stable cargo tests로 실행하고 final `ci / build-and-test`가 이 lane에 의존하도록 hosted evidence graph를 수리. + +## 대안과 기각 이유 + +Windows CI를 nightly로 바꾸는 방식은 repository toolchain contract를 한 platform metadata helper 때문에 약화하므로 기각했습니다. Windows test만 제외하는 방식은 protected evidence를 줄이므로 기각했습니다. Link count를 확인하지 못하면서 unconditional `true`를 반환하고 기존 in-place append/truncation을 유지하는 방식도 alias mutation을 그대로 남기므로 기각했습니다. + +`fsutil`/PowerShell을 runtime에서 호출하는 방식은 locale, binary availability, process execution surface와 operational dependency를 freshness state에 추가하므로 기각했습니다. Win32 FFI를 owner code에 직접 추가하는 방식도 crate의 `unsafe_code = "forbid"` 경계를 깨므로 채택하지 않았습니다. 새로운 safe dependency를 도입하는 방안은 가능하지만 현재 문제는 std-only snapshot publication으로 해결할 수 있어 dependency-policy 비용을 만들 이유가 없습니다. + +Windows에서도 물리적 append-only file을 고집하는 방식보다, logical committed log bytes를 synchronized sibling에 작성하고 pathname replacement하는 방식을 선택했습니다. Anti-replay 판단에 필요한 것은 ordered committed records와 crash-safe previous authority 보존이지, 동일 file record에 대한 in-place append 자체가 아닙니다. + +## Claim boundary와 위험 + +Sibling lease는 cooperating BandScope processes를 직렬화합니다. Advisory lock을 무시하고 app-local directory를 직접 조작할 수 있는 동일 사용자/관리자 actor에 대한 sandbox 경계로 보지 않습니다. + +Unix link-count admission은 검사 시점의 pre-existing alias를 차단하지만 검사 이후 hostile hard-link/path replacement TOCTOU를 완전히 제거하지 않습니다. Windows snapshot publication은 pre-existing hard-link alias를 in-place 변경하지 않지만, sibling scratch pathname과 final rename 주변의 hostile same-user race를 cryptographic filesystem capability로 제거한 것은 아닙니다. + +Windows에서 `File::sync_all()`은 snapshot file content durability를 요청하지만 Rust std는 이번 owner에서 parent-directory durability를 Unix와 동일 방식으로 증명하지 않습니다. 따라서 packaged Windows process-kill/power-loss acceptance가 통과하기 전에는 full crash/power-loss durability를 주장하지 않습니다. Network filesystem/SMB/NFS semantics도 commercial local-profile acceptance 범위 밖에서 별도 검증이 필요합니다. + +`load_highest_seen()`은 Windows hard-linked state를 read-only로 받아들일 수 있습니다. BandScope-owned mutation은 alias file record를 수정하지 않으며 다음 real update 또는 torn-tail repair에서 state pathname을 독립 snapshot으로 교체합니다. Same-user external alias writer가 lease를 무시하고 bytes를 동시에 바꾸는 공격은 별도 hostile-filesystem boundary입니다. + +## 효과와 후속조치 + +정상 cooperating desktop processes는 하나의 freshness authority를 순차적으로 읽고 갱신합니다. Unix는 existing multi-link state를 거부하고, Windows는 stable Rust에서 alias file record를 직접 변경하지 않는 publication semantics를 사용합니다. Replay/equivocation parser와 bounded state format은 그대로 유지됩니다. + +Exact current-head Windows/macOS/Linux hosted tests가 terminal GREEN이 되기 전에는 source와 이 문서만으로 platform parity 완료를 주장하지 않습니다. #1220은 exact-head stable Windows evidence가 확인된 뒤에만 resolved 처리합니다. + +이 state owner를 production updater flow에 연결하는 순서도 바뀌지 않습니다. Remote metadata authenticity → updater artifact cryptographic signature verification → exact sealed-descriptor digest/authenticated-size binding → explicit verified-artifact promotion → anti-replay decision → highest-seen durable mutation 순입니다. Packaged multi-process contention, process-kill, restart, power-loss, disk-full과 last-known-good rollback은 별도 release acceptance가 필요합니다. + +## Security Notes + +State log와 sibling lease/snapshot path에는 release identity만 저장하며 project/audio path, credential, PII, private key를 넣지 않습니다. Lease contention은 성공으로 우회하지 않습니다. Sibling snapshot은 `create_new`를 사용하고 pre-existing symlink/non-regular scratch path를 fail closed합니다. Windows fix는 stable Rust 표준 library 범위에서 동작하며 unsafe owner code, shell execution, nightly feature, 새 dependency를 추가하지 않습니다. + +Hosted CI는 기존 gate를 제거하지 않고 standalone Distribution tests를 final `ci / build-and-test` prerequisite로 유지합니다. + +## 참고문헌 + +Rust Project. (2026). *std::fs: Filesystem manipulation operations*. Rust 1.98.1 standard library documentation. https://doc.rust-lang.org/std/fs/ + +Rust Project. (2026). *rename in std::fs*. Rust 1.98.1 standard library documentation. https://doc.rust-lang.org/std/fs/fn.rename.html + +Rust Project. (2026). *MetadataExt in std::os::unix::fs*. Rust 1.98.1 standard library documentation. https://doc.rust-lang.org/std/os/unix/fs/trait.MetadataExt.html + +Rust Project. (2026). *MetadataExt in std::os::windows::fs*. Rust 1.98.1 standard library documentation. https://doc.rust-lang.org/std/os/windows/fs/trait.MetadataExt.html + +Rust Project. (2026). *TryLockError in std::fs*. Rust 1.98.1 standard library documentation. https://doc.rust-lang.org/std/fs/enum.TryLockError.html diff --git a/docs/traceability/updater-release-admission.md b/docs/traceability/updater-release-admission.md new file mode 100644 index 000000000..58a855003 --- /dev/null +++ b/docs/traceability/updater-release-admission.md @@ -0,0 +1,144 @@ +# Updater release admission traceability + +## Decision + +BandScope treats desktop update authority as a Distribution/update release input, not as an incidental Tauri setting. `release/updater-policy.json` is the repository-owned admission record. The protected release preflight consumes it through `scripts/checks/verify_release_updater_policy.py`. + +The current policy is deliberately `blocked`. BandScope does not yet have an organization-approved updater signing public key and immutable production HTTPS endpoint. A version tag therefore cannot become a commercial release merely because ordinary desktop packaging succeeds. + +## Problem + +Tauri v2 requires update artifacts to be signed and verifies them with a public key embedded in updater configuration. The updater signature check cannot be disabled. Tauri also requires `bundle.createUpdaterArtifacts` to generate update bundles/signatures and requires production endpoints to use TLS unless an explicitly dangerous insecure-transport option is enabled. Its updater setup additionally requires the `tauri-plugin-updater` Rust dependency and runtime plugin initialization. + +The first updater-admission slice closed configuration-only authority drift, but fresh review found a second executable gap: a future policy could be `admitted`, `tauri.conf.json` could contain the correct public key/endpoints and updater-artifact setting, yet the shipped desktop binary could omit `tauri-plugin-updater` or never initialize it. The preflight would then report commercial updater admission for a build with no compiled updater runtime. + +After that repair, the release artifact graph still had a third gap. Tauri v2 emits Windows installer `.sig` files and a macOS `.app.tar.gz` updater bundle plus `.sig`, but BandScope's release packager copied only standard DMG/EXE/MSI outputs. Source/config/runtime admission therefore did not prove that the exact generated updater payload/signature bytes were carried into the release candidate and bound to its receipt. + +These are Distribution release-truth defects, not UI or Active Player concerns. Distribution must bind authority, compiled runtime, generated updater payload/signature bytes, and eventual manifest/publication evidence without moving secret signing authority into source. + +## Constraints + +- Private updater signing keys never belong in repository files, build artifacts, logs, or policy JSON. +- A public verification key is safe to distribute, but the organization-approved key value is still release authority and must not be invented by an automation writer. +- Production updater endpoints must be exact policy inputs; a generic arbitrary-URL updater would violate the local-first and narrow-capability boundary. +- Ordinary startup and local rehearsal analysis must remain usable while the updater service is unavailable. +- Distribution owns update publication and trust. Active Player, Project Persistence, Signal/MIR, and Resource Admission do not receive duplicate updater authority. +- Mutable Git/path updater dependencies are not commercial admission evidence. The release gate requires a versioned dependency and an immutable registry lock entry with checksum. +- Presence of `.sig` bytes is release evidence, not by itself proof that the signature verifies against the approved public key. + +## Implemented contract + +`verify_release_updater_policy.py` reads only fixed repository-relative policy, Tauri configuration, desktop Cargo manifest/lock, and desktop runtime source paths. Inputs are bounded regular non-link files read from stable descriptors; JSON duplicate members are rejected. + +For `state=blocked`, the guard requires: + +- `publicKey` is `null`; +- `endpoints` is empty; +- a non-empty reason is recorded; +- Tauri updater artifact generation is absent/disabled; +- Tauri updater plugin configuration is absent; +- tag/release callers using `require_admitted=True` fail closed. + +The current blocked repository does not need to install an updater dependency merely to prove that updates are disabled. + +For a future `state=admitted`, the guard requires: + +- one bounded literal public verification key; +- one to four unique HTTPS endpoints without userinfo or fragments; +- exact key/endpoint equality between policy and Tauri configuration; +- `bundle.createUpdaterArtifacts=true`; +- `dangerousInsecureTransportProtocol` is not enabled; +- a valid SemVer `minimumSupportedVersion` and an explicit stable/beta channel; +- exactly one `tauri-plugin-updater` dependency declaration in the desktop root or target-specific Cargo dependency tables; +- a versioned, non-optional updater dependency with no mutable `path` or `git` source; +- exactly one `tauri-plugin-updater` package in `Cargo.lock`, from a registry source with a full registry checksum; +- an executable desktop source initializer matching `.plugin(tauri_plugin_updater::Builder::new().build())` after comments and string literals are blanked so documentation/example text cannot satisfy release admission. + +`verify_release_identity.py` composes this guard with the existing version and commercial-model admission guards. `package_desktop_artifact.py` invokes that release preflight before creating `artifacts/` for version tags, so updater admission is on the same fail-closed path as tag packaging rather than a detached audit. + +For an admitted tag path, `package_desktop_artifact.py` now also requires the generated Tauri v2 updater outputs: + +- Windows: every packaged NSIS/MSI installer must have its adjacent `.sig`; the copied standard installer must remain byte-identical to the Tauri updater bundle it represents. +- macOS: exactly one target-local `*.app.tar.gz` updater bundle and adjacent `.sig` must exist in Tauri's macOS bundle directory. +- `.sig` evidence must be regular, non-link, non-empty and no larger than 64 KiB. +- source and copied updater bytes are compared by exact size/full SHA-256. +- `release-receipt.json` re-admits those copied bytes immediately before publication and records bundle/signature names, sizes and full SHA-256 values under `updaterArtifacts`. + +This is artifact identity binding. It deliberately does not perform private-key operations or promote `.sig` presence into a cryptographic-validity claim. + +### RED → repair lineage + +Runtime-wiring slice: + +- `8843a308303d7731a175abfc0b90fb365cb7518e` adds the realistic RED: configuration-only admission must fail when the updater crate or runtime initializer is absent. +- `9869d32bdad9787f3af6556f73d74070f8541b5f` binds admitted policy to bounded Cargo manifest/lock evidence and the desktop runtime initializer. +- `6d91f7c7bc117da9ece7563e45a7d88de09b09d4` updates the existing admitted-policy fixtures so configuration tests exercise a genuinely wired updater graph rather than an impossible config-only state. + +Generated-artifact slice: + +- `421aaeec44fcb93f0250f44487d56a7b711aede0` adds RED coverage for missing Windows `.sig`, missing macOS `.app.tar.gz`/`.sig`, exact receipt binding and post-copy signature drift. +- `0e012723e2bff7068d162b721a29d3141c036175` packages the platform-correct Tauri v2 updater bundle/signature evidence and binds exact copied bytes to the release receipt. +- `0fa723801b72c57a0bee8cc706581b0e2b3129d2` updates the release-receipt traceability with the platform artifact semantics and claim limits. + +## Alternatives rejected + +### Check only whether a public key string exists + +Rejected. A key without exact endpoint/config projection still allows authority drift, and a string-presence check does not prove updater artifacts are generated. + +### Treat `tauri.conf.json` as proof that the updater exists in the binary + +Rejected. Configuration can describe a plugin that Cargo does not compile or the application never initializes. Commercial admission has to agree across policy, Tauri config, Cargo manifest/lock, and runtime construction. + +### Accept a Cargo dependency without checking the runtime initializer + +Rejected. A locked crate can remain unused. Dependency presence is supply-chain evidence, not evidence that the desktop runtime actually installs the updater plugin. + +### Treat `createUpdaterArtifacts=true` as proof that updater bytes are in the release + +Rejected. Configuration expresses intent. The release candidate must contain the platform-specific generated updater bundle/signature bytes and bind their exact identity to the release receipt. + +### Treat macOS DMG as the updater payload + +Rejected. Tauri v2 generates a separate `.app.tar.gz` update bundle on macOS. The DMG remains the notarized installer evidence; the updater tarball/signature is a distinct release artifact. + +### Enable Tauri updater with placeholder key or endpoint + +Rejected. Placeholder release authority is materially worse than an explicit blocked state because it can be mistaken for production readiness or accidentally shipped. + +### Put the private signing key in policy + +Rejected. Tauri's private signing key is secret release authority. Repository policy may bind the public verification key only; private-key custody belongs to the external signing/secret-management boundary. + +## Claim boundary + +Current source-level admission proves that BandScope cannot label a version-tag build commercially updater-ready while updater authority is absent, admitted Tauri configuration drifts, the updater crate is missing from the immutable Cargo graph, or the desktop runtime omits the updater plugin initializer. The package path also fails closed when the expected platform-specific updater bundle/signature evidence is absent or drifts before the release receipt is published. + +It does **not** yet prove: + +- that an approved signing key has been provisioned; +- that a production updater endpoint has been provisioned and is operational; +- that copied `.sig` bytes cryptographically verify against the approved release public key; +- that a static/dynamic updater manifest is generated from and immutably bound to the exact `updaterArtifacts` receipt entries; +- that the runtime successfully checks, downloads, verifies, installs, restarts, and recovers on packaged Windows/macOS builds; +- that wrong-key/wrong-signature, digest mismatch, truncated/replayed/stale metadata, partial download, disk-full, cancellation, first-launch failure, staged rollout, deferral, retry, offline operation, or rollback behavior has passed acceptance; +- that project-schema compatibility permits a given rollback; +- that lexical source wiring evidence alone establishes behavioral updater correctness. Hosted compilation and packaged runtime acceptance remain separate gates. + +Those remain repository-owned work under #960 once external updater key/endpoint authority is available, except for signer/key ownership itself. + +## Test evidence + +`services/analysis-engine/tests/test_release_updater_policy.py` covers checked-in blocked authority, tag-preflight composition, exact admitted key/HTTPS endpoint projection, endpoint drift/insecure transport, missing updater artifact generation, partially enabled blocked state, duplicate JSON members, and an admitted config projected through a valid locked updater/runtime fixture. + +`services/analysis-engine/tests/test_release_updater_runtime_wiring.py` covers missing compiled dependency, locked dependency without runtime initializer, and the positive immutable dependency/runtime wiring contract. + +`services/analysis-engine/tests/test_release_updater_artifact_binding.py` covers missing Windows signature, missing macOS updater bundle/signature, platform-correct copy/binding, post-copy drift rejection, and non-tag independence. + +Hosted current-head CI remains authoritative for merge/release status. No predecessor-head GREEN or review transfers after these source commits. + +## References + +Tauri Contributors. (2026). *Updater*. Tauri v2 documentation. https://v2.tauri.app/plugin/updater/ + +Semantic Versioning. (n.d.). *Semantic Versioning 2.0.0*. https://semver.org/spec/v2.0.0.html diff --git a/docs/traceability/updater-security-metadata.md b/docs/traceability/updater-security-metadata.md new file mode 100644 index 000000000..59f26bea5 --- /dev/null +++ b/docs/traceability/updater-security-metadata.md @@ -0,0 +1,109 @@ +# Updater security metadata traceability + +BandScope의 Distribution/update bounded context는 updater artifact 서명, remote metadata, local freshness state를 같은 신뢰 수준으로 취급하지 않습니다. `latest.json`은 Tauri가 요구하는 `version`, target별 `url`·`signature`와 BandScope의 release receipt에서 파생한 `bandscope` 확장 필드를 함께 싣지만, JSON 응답 자체가 updater artifact 서명으로 인증되는 것은 아닙니다. + +## 확인된 trust-boundary 오류와 수정 + +기존 문서는 Tauri `Update.raw_json`을 향후 "authenticated metadata"처럼 연결할 수 있다고 적었습니다. current Tauri v2 계약과 구현을 다시 확인하면 이 표현은 부정확합니다. + +- Tauri static updater JSON은 `version`, target별 `url`·`signature`를 제공합니다. `Update.raw_json`은 서버 JSON 응답을 그대로 보존하는 API입니다. +- Tauri의 `Update::download`는 updater bytes를 내려받은 뒤 `verify_signature(&buffer, &self.signature, &pubkey)`를 호출합니다. 즉 승인된 public key는 **다운로드한 updater artifact bytes**를 인증합니다. `raw_json`의 BandScope 확장 필드 전체를 별도로 서명·인증한다는 계약은 없습니다. +- 따라서 `sourceCommit`, `minimumSupportedVersion`, target별 SHA-256 같은 `bandscope` 필드를 syntax 검증했다는 이유만으로 highest-seen authority에 기록하면 안 됩니다. Endpoint 또는 metadata publication 경로가 변조된 경우 signed artifact와 독립적으로 version/source/digest 문맥을 오염시킬 수 있습니다. + +이 finding 때문에 runtime은 state writer를 `raw_json`에 곧바로 연결하지 않습니다. `apps/desktop/distribution-runtime`은 remote JSON을 **provisional metadata**로만 admit하며, state를 쓰거나 anti-replay core에 authenticated candidate를 반환하지 않습니다. + +Runtime-admission lineage: + +- RED `e525aa1fb4bb7d51cd33d2f1f410e339b1f71725`: repository CI가 별도 `distribution-runtime` locked Rust suite를 요구하도록 확장했습니다. +- Foundation `5c95912ffedbd69b1bb33773520b73cc68f9dc3c` / `b9f72beb826d5a0dc01b2d82cffbc814c2f91e2a`: runtime crate와 lock graph를 만들었습니다. +- Causal boundary `85db601ff0771ef59e0601d2c1c2296f827bc5d3`: 최대 256 KiB remote JSON, duplicate/unknown member 거부, 네 release target exact set, bounded signature/URL, exact-tag HTTPS URL, updater artifact size ceiling, exact source/digest/version syntax을 Rust로 검증하되 결과 타입을 `ProvisionalUpdateMetadata`로 제한했습니다. app-local-data의 highest-seen 위치도 fixed path로 projection할 뿐 directory/file을 만들지 않습니다. +- `def74eff06c1d80521fb43336d461e206937c438` / `418c68d06c7ec2ba4bb2bc6f199ea11482c2f501`: provisional runtime crate에서 durable-state dependency를 제거해 remote metadata parsing과 trust-state mutation 사이의 우발적 결합을 없앴습니다. +- `59bc8c8c772a75d95d806dc5161b4b9935bcc2f8` / `daad6e54e4b3f4735cf10cbe421dd018a70e145c`: exact-tag 문자열 포함 여부만 보던 URL admission을 BandScope의 현재 GitHub release namespace로 고정했습니다. `github.com/ContextualWisdomLab/bandscope/releases/download/v/` 이외의 host/repository/path, query, fragment, userinfo 형태, backslash, percent-encoded 또는 path-like asset name은 provisional 단계에서 거부합니다. 첫 commit의 Rust generic-pattern 표현은 hosted compiler에 의존하지 않도록 두 번째 commit에서 명시적인 char checks와 exact tag 비교로 정리했습니다. +- RED `9f7bddd3c5660739f4c4e1a060b6e6a3cd807824`: strict parser가 target별 URL·signature를 검증하고도 버리기 때문에 production transport가 같은 remote JSON을 다시 해석해야 하는 경계를 재현했습니다. `ProvisionalUpdateMetadata`가 선택 target의 exact admitted URL과 signature를 제공해야 한다는 integration contract를 먼저 추가했습니다. +- Causal fix `663affcbc940269928a4eb95eb329d99b6de57c4`: 선택 target의 URL·signature를 **같은 bounded strict parse 결과**에 보존하고 `artifact_url()` / `artifact_signature()`로만 노출했습니다. 이 값들은 여전히 provisional이며 metadata authenticity나 signature 성공을 뜻하지 않습니다. 목적은 production transport가 별도·느슨한 JSON reparse를 만들어 semantics를 갈라놓는 것을 막는 것입니다. + +## Artifact URL admission + +`platforms[target].url`은 metadata authenticity와 별개의 network/resource-admission 입력입니다. Artifact signature가 최종 실행 무결성을 보호하더라도, 서명 검증은 download 뒤에 일어나므로 remote JSON이 임의 host나 URL parser ambiguity를 선택하도록 두면 signature failure 이전에 원하지 않는 network destination과 response body를 소비할 수 있습니다. + +현재 publisher인 `build_updater_manifest.py`는 GitHub Actions의 exact repository slug와 exact release tag를 사용해 `https://github.com/ContextualWisdomLab/bandscope/releases/download/v/` 형태를 생성합니다. Runtime provisional admission도 같은 product-owned namespace만 허용합니다. URL 문자열 안에 `/releases/download/v.../`가 단순히 포함됐다는 이유만으로 허용하지 않으며, query/fragment에 해당 문자열을 숨기거나 `github.com@evil.example` 같은 userinfo 형태를 사용하는 입력도 거부합니다. + +선택 target의 admitted URL과 signature는 이제 `ProvisionalUpdateMetadata`에 같이 묶입니다. Transport adapter는 raw JSON을 다시 parse하지 않고 이 값만 소비해야 합니다. 다만 이 결합은 parser-consistency 경계이지 authenticity 경계가 아닙니다. 아직 인증되지 않은 remote metadata의 URL·signature라는 점은 변하지 않습니다. + +이 pin은 remote metadata를 인증하지 않습니다. 또한 GitHub 자체 compromise, organization/repository write compromise, malicious but correctly namespaced asset, oversized body를 해결하지 않습니다. 역할은 "untrusted metadata가 download destination 자체를 임의 host/path로 확장하지 못하게 한다"는 좁은 resource/network boundary입니다. 향후 Distribution이 publication backend를 바꾸려면 runtime의 canonical release-origin contract도 같은 owner에서 versioned migration으로 변경해야 합니다. + +## Manifest evidence + +`build_updater_manifest.py`는 release graph를 `select_release_assets.py`로 다시 admit한 뒤 target receipt에서 다음 값을 `bandscope` 객체에 기록합니다. + +- `schemaVersion: 1` +- exact 40-hex `sourceCommit` +- `release/updater-policy.json`의 `minimumSupportedVersion` +- Windows amd64/arm64, macOS amd64/arm64 각각의 updater bundle `sizeBytes`와 full SHA-256 + +정책 파일은 fixed repository-relative path의 bounded regular non-link file로 읽고 duplicate JSON member와 descriptor drift를 거부합니다. Receipt의 source commit, version/tag, updater artifact identity가 release graph와 다르면 publication을 중단합니다. 이 값들은 **publication integrity evidence**이며 remote client가 받았을 때 자동으로 authenticated metadata가 되는 것은 아닙니다. + +## Rust decision core와 durable state + +`apps/desktop/distribution-core`는 filesystem/network/Tauri/installer I/O가 없는 deterministic policy owner입니다. 이미 인증된 release identity만 받는다는 전제에서 canonical stable version, exact source/digest identity, target, compatibility floor, replay, rollback, same-version equivocation, project-schema-aware known-good rollback을 결정합니다. + +`apps/desktop/distribution-state`는 Distribution-owned highest-seen identity만 저장합니다. bounded append-only log를 사용하고 regular non-link state file, monotonic record, exact identity, torn-final-record prefix만 admit합니다. 새 record는 append 후 `sync_all()`과 resulting length 확인이 끝나야 성공입니다. 이 state는 Project Persistence가 소유하는 project bytes/schema와 분리됩니다. + +중요한 순서는 다음과 같습니다. + +1. Remote updater JSON은 untrusted/provisional input으로 bounded parsing하고 현재 product release namespace 밖 URL을 거부합니다. +2. Metadata의 version/source/digest 문맥을 조직이 승인한 방식으로 인증합니다. 현재 이 authority는 아직 구현·provision되지 않았습니다. +3. Updater artifact bytes는 Tauri updater public key로 signature verification을 통과해야 합니다. +4. Authenticated metadata가 주장한 artifact digest/size와 실제 verified artifact가 일치해야 합니다. +5. 그 뒤에만 `distribution-core`와 `distribution-state`를 통해 highest-seen freshness authority를 갱신할 수 있습니다. + +현재 2번이 없으므로 5번을 runtime에 연결하지 않는 것이 fail-closed 동작입니다. 설치를 미룬 release까지 pre-install highest-seen으로 기억하려면 metadata 자체의 authenticity가 필요합니다. 그것 없이 remote version만 먼저 저장하는 것은 freeze/replay 방어가 아니라 local state poisoning 경로가 될 수 있습니다. + +## Resource-admission 상태 + +Tauri current source의 `Update::download`는 HTTP body chunk를 `Vec`에 누적한 다음 signature를 검증합니다. BandScope manifest는 declared artifact size를 bounded field로 갖지만 remote server가 그 값을 지킨다는 보장은 없습니다. 그래서 `apps/desktop/distribution-download`에 별도 Rust boundary를 두었습니다. 현재 이 boundary는 `(0, 2 GiB]` expected size, optional `Content-Length` exact match, 1 MiB caller chunk ceiling, cumulative overrun 차단, exclusive `create_new` staging, error/cancel cleanup, exact-size seal, descriptor-bound read-only verifier와 seal-time byte ceiling을 구현합니다. Sealed-but-unverified bytes는 drop 시 정리되며 trust promotion이 아닙니다. + +남은 gap은 **production HTTP adapter가 아직 이 boundary를 실제 response path로 사용하지 않는다는 점**입니다. URL namespace pinning과 `distribution-download`가 각각 존재한다는 사실만으로 end-to-end bounded download를 주장할 수 없습니다. Production adapter는 strict parse에서 보존한 `artifact_url()`을 소비하고 redirect/effective-origin을 명시적으로 검증하며 response chunks를 `distribution-download`로 전달해야 합니다. 그 뒤에도 metadata authenticity, artifact signature, exact digest/size binding과 verified-artifact promotion이 별도로 필요합니다. + +## 보안 경계와 기각한 대안 + +`bandscope` JSON 필드 자체, HTTPS endpoint만의 존재, GitHub immutable-release attestation, updater artifact `.sig` 가운데 어느 하나도 remote metadata 전체의 독립적인 freshness authority를 대신하지 않습니다. GitHub attestation은 published release asset 집합의 publication evidence이고, Tauri `.sig`는 updater artifact bytes의 authenticity/integrity evidence입니다. + +`raw_json`을 "Tauri가 받았으므로 authenticated"라고 간주하는 방식은 기각합니다. artifact signature가 통과하기 전 remote JSON을 highest-seen state에 쓰는 방식도 기각합니다. URL 안에 exact-tag path 조각이 포함되기만 하면 임의 host를 허용하는 방식도 기각합니다. Strict parser가 이미 검증한 selected URL/signature를 버리고 transport layer가 raw JSON을 별도 parser로 다시 읽는 방식도 기각합니다. Metadata signature 또는 TUF류 protocol을 도입한다면 BandScope release/update owner에서 versioned contract와 key lifecycle, rotation/recovery, expiry/freeze semantics까지 함께 설계해야 하며 다른 bounded context에 검증 로직을 복제하지 않습니다. + +TUF는 metadata 자체를 threshold signature로 인증하고 version rollback과 expiry/freeze를 확인하며 metadata download에도 명시적인 byte ceiling을 요구합니다. BandScope가 향후 TUF 또는 동등한 metadata-authentication 계층을 채택한다면 이 특성을 축소해서 "서명 하나 추가"로 대체하지 않습니다. 현재 구현은 TUF 준수를 주장하지 않습니다. + +현재 `release/updater-policy.json`은 organization-approved updater public key와 production endpoint가 없어 `blocked`입니다. private key·public key·endpoint를 source에서 만들어내지 않습니다. Windows/macOS publisher identity와 notarization authority도 별도 외부 prerequisite입니다. + +## 남은 runtime integration + +Repository-owned 다음 단계는 다음 순서가 맞습니다. + +- production HTTP adapter가 `ProvisionalUpdateMetadata::artifact_url()` / `artifact_signature()`를 소비하고 raw JSON을 재해석하지 않도록 연결 +- response redirect/effective-origin과 실제 response byte stream을 `distribution-download`에 연결해 disk-full/cancel/network-error cleanup까지 검증 +- remote metadata authenticity를 위한 canonical owner 계약과 verification path 결정 및 RED→GREEN 구현 +- authenticated metadata와 같은 sealed descriptor에서 검증한 updater signature·digest·size binding +- 검증을 통과한 bytes에만 verified-artifact promotion 허용 +- 그 이후에만 app-owned highest-seen state path와 `distribution-core`를 실제 updater flow에 연결 +- offline update-check 실패가 normal startup을 막지 않는지 검증 +- partial/truncated/oversized download, disk-full, cancel, first-launch failure 뒤 current installation/project 보존 +- last-known-good installer retention과 project-schema-compatible rollback +- packaged Windows/macOS에서 wrong key/signature/digest/target/replay 및 power-loss acceptance + +Positive production signature acceptance는 organization-approved updater authority가 provision된 뒤에만 수행합니다. + +## Security Notes + +Attack surface는 remote updater metadata, updater URL/destination, updater bytes/signatures, release receipts, locally persisted freshness state와 recovery decision입니다. Distribution만 이 trust chain을 소유합니다. Active Player, MIR, Project Persistence는 release/update authority를 복제하지 않습니다. Remote metadata는 bounded strict parser와 canonical release-namespace admission을 통과해도 provisional이며, authenticated evidence가 생기기 전 local freshness state를 mutate하지 않습니다. 원본 audio/project payload는 update metadata나 state에 포함하거나 endpoint로 전송하지 않습니다. + +## 참고문헌 + +Samuel, J., Mathewson, N., Cappos, J., & Dingledine, R. (2010). *Survivable key compromise in software update systems*. Proceedings of the 17th ACM Conference on Computer and Communications Security, 61–72. https://ssl.engineering.nyu.edu/papers/samuel_tuf_ccs_2010.pdf + +Tauri Contributors. (2026). *Updater*. Tauri v2 documentation. https://v2.tauri.app/plugin/updater/ + +Tauri Contributors. (2026). *Command line interface: signer*. Tauri v2 documentation. https://v2.tauri.app/reference/cli/ + +Tauri Contributors. (2026). *tauri-plugin-updater: updater.rs*. https://github.com/tauri-apps/plugins-workspace/blob/v2/plugins/updater/src/updater.rs + +The Update Framework. (2026). *The Update Framework specification and security model*. https://theupdateframework.github.io/specification/draft/ diff --git a/docs/traceability/updater-staging-path-identity.md b/docs/traceability/updater-staging-path-identity.md new file mode 100644 index 000000000..aa2b5ce21 --- /dev/null +++ b/docs/traceability/updater-staging-path-identity.md @@ -0,0 +1,61 @@ +# Updater staging cleanup path identity + +## Problem + +`distribution-download` retained the staging descriptor and lease through cancellation or sealing, but cleanup was pathname-only: `Drop` closed the owned descriptor and then unconditionally removed the remembered pathname. If the owned staging entry was renamed away while the descriptor remained open and an unrelated regular file was created at the original basename, cleanup could delete that replacement object even though BandScope never opened or wrote it. + +The staging lease serializes cooperating BandScope attempts. It is not a filesystem namespace capability and does not stop another same-user process from renaming or replacing a pathname. Cleanup therefore must not infer object ownership from a stale path string. + +The first repair closed this on Unix by comparing the still-open descriptor's `(dev, ino)` with the current pathname. Fresh review found that Windows still used the old close-then-`remove_file(path)` fallback. Rust 1.98.1 documents that Windows `OpenOptions` uses `FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE` by default, so another process can rename or delete the file while BandScope still has the handle open. The Windows fallback therefore preserved the exact replacement-path deletion class that the Unix repair was intended to remove. + +## RED evidence + +Commit `4aca07e177198b75cfe166208b48808704b170e4` originally added `apps/desktop/distribution-download/tests/path_replacement_cleanup.rs` for Unix desktop semantics. + +Commit `308f4a618cbbfd07fc9380b721f9987b3cb373d1` extends those same cancelled and sealed replacement-path contracts to Windows. On the preceding implementation both tests reach the non-Unix cleanup helper, which closes the descriptor and unconditionally removes the remembered pathname; the unrelated replacement therefore does not survive. + +The regression covers both lifecycle paths: + +- a cancelled `StagedArtifactFile` whose owned file is renamed away before `Drop`; +- a `SealedArtifactFile` whose owned file is renamed away before verifier-owner cleanup. + +Each case creates an unrelated replacement at the original staging basename before dropping the BandScope owner and requires those replacement bytes to survive. + +## Causal repair + +Commit `bc72745df92fc048b25a84cca349a6ecf0d9daf1` remains the Unix owner repair. Unix cleanup reads the still-open descriptor identity (`dev`, `ino`) and compares it with `symlink_metadata` for the current pathname. The pathname is removed only when it is a direct regular non-symlink object with the same device/inode identity as the owned descriptor. A missing, symlinked, non-regular, or replaced pathname is left untouched. + +Commit `1d603316b0ff4ff8b737aea2ef57c76250e326d0` removes the unsafe Windows/non-Unix pathname unlink. Where stable Rust cannot prove that a remembered path still denotes the owned descriptor, `Drop` now closes the descriptor and leaves the app-owned scratch pathname untouched. This chooses a bounded stale-file cost over deleting an object whose ownership cannot be established. + +The existing `StagedArtifactFile::create` restart path already acquires `.bandscope-staging.lock` before classifying a pre-existing child as stale and only reclaims a direct regular file. Commit `1a6ee097106cf2204cf9bdbcc0040999988f4e14` adds a Windows-specific contract proving that a deferred stale `update.bin` survives `Drop` and is reclaimed by the next leased staging attempt. The fix therefore does not turn deferred cleanup into permanent accumulation during subsequent updater attempts. + +## Alternatives considered + +- **Close then unconditionally remove the remembered path** — rejected because the pathname may now identify another filesystem object. +- **Check only that the pathname exists and is a regular file** — rejected because type equality is not object identity. +- **Use unstable Windows by-handle identity APIs** — rejected because production Distribution builds use stable Rust and must not depend on nightly-only metadata extensions merely to claim parity. +- **Treat default Windows sharing as an ownership lock** — rejected. Rust documents that the default share mode includes `FILE_SHARE_DELETE`, which permits delete/rename operations by compatible subsequent handles. +- **Disable delete sharing and unlink after closing the descriptor** — rejected because closing first recreates a pathname replacement race between handle close and `remove_file(path)`. +- **Never reclaim stale scratch** — rejected. Windows now defers destructive pathname cleanup on `Drop`, but the next app-owned staging attempt still reclaims a stale regular child only while holding the shared staging lease. + +## Claim boundary and residual risk + +On Unix/macOS/Linux, this repair closes the deterministic case where a replacement pathname is already present when cleanup performs its identity comparison. It does not claim to defeat a malicious process that can win the remaining metadata-check-to-unlink race after the comparison. Descriptor-relative unlink or an equivalent OS capability would be required for that stronger hostile same-user guarantee. + +On Windows, this repair makes the stronger conservative claim that `Drop` will not delete any remembered staging pathname when stable source code cannot prove descriptor/path identity. It consequently leaves cancelled or sealed-but-unpromoted scratch bytes until the next staging attempt or another explicitly owner-safe cleanup path. This is a storage-retention tradeoff, not trust promotion: deferred bytes remain unverified scratch and are never accepted as a release artifact or freshness authority. + +The regression depends on ordinary Windows rename behavior while a Rust file handle is open. Rust's Windows `OpenOptionsExt` documentation states that the default share mode includes `FILE_SHARE_DELETE`, allowing delete/rename by another process while the handle is open. Microsoft documents that `FILE_SHARE_DELETE` permits subsequent delete/rename access and that path-based deletion acts on the current pathname target, which is why close-then-delete cannot establish ownership. + +## Product effect + +Cancellation and unverified-artifact cleanup no longer intentionally deletes a pathname merely because it has the same basename as the staging object BandScope originally created on either Unix desktop targets or Windows. Unix removes only identity-matching paths; Windows defers path deletion to the next leased stale-file admission rather than guessing ownership. + +The release trust order remains: provisional metadata and transport admission → authenticated release identity → cryptographic updater signature verification → sealed-descriptor digest/authenticated-size binding → explicit verified-artifact promotion → anti-replay decision and durable highest-seen mutation. + +## References + +Microsoft. (n.d.). *CreateFileA function (fileapi.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilea + +Microsoft. (n.d.). *DeleteFile function (winbase.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-deletefile + +The Rust Project Developers. (2026). *OpenOptionsExt in std::os::windows::fs* (Rust standard library 1.98.1). https://doc.rust-lang.org/std/os/windows/fs/trait.OpenOptionsExt.html diff --git a/docs/traceability/updater-staging-restart-recovery.md b/docs/traceability/updater-staging-restart-recovery.md new file mode 100644 index 000000000..ae55dc258 --- /dev/null +++ b/docs/traceability/updater-staging-restart-recovery.md @@ -0,0 +1,75 @@ +# Updater staging restart recovery traceability + +BandScope의 updater staging은 신뢰 검증 전 bytes만 두는 scratch namespace입니다. Unix의 정상 cancel/error/drop은 descriptor identity가 일치하는 staging pathname을 제거합니다. Windows는 stable Rust에서 remembered pathname과 owned descriptor의 동일성을 안전하게 증명할 수 없으면 destructive pathname cleanup을 지연하며, 다음 leased attempt가 stale direct regular child를 회수합니다. 프로세스 강제 종료나 전원 상실은 어느 OS에서도 Rust `Drop`을 실행하지 않을 수 있습니다. 반대로 살아 있는 다른 BandScope 인스턴스의 regular staging file을 crash residue로 오인해 지우면 안 됩니다. Restart recovery와 concurrent ownership을 함께 만족해야 합니다. + +## 문제와 제약 + +최초 구현은 `create_new`만 사용했기 때문에 crash 뒤 남은 regular child가 다음 동일 업데이트를 영구적으로 `DestinationExists`에 가둘 수 있었습니다. 이를 고친 `b7a1839d5941c52800bbeaf22921e143060d1ff6`는 app-owned staging의 pre-existing regular child를 stale unverified bytes로 보고 제거했습니다. + +그 수리만으로는 충분하지 않았습니다. 다른 BandScope 프로세스가 같은 basename을 실제로 staging 중이어도 pathname만 보면 regular file이므로 두 번째 프로세스가 이를 stale로 오인해 unlink할 수 있었습니다. Unix에서는 첫 번째 writer가 이미 unlink된 inode에 계속 쓸 수 있고 두 번째 writer는 같은 pathname에 새 inode를 만들 수 있어, 두 live attempts가 서로 다른 bytes를 같은 logical staging identity로 취급할 수 있습니다. 첫 writer의 drop cleanup이 뒤늦게 두 번째 writer의 pathname을 제거할 위험도 있습니다. Windows의 open-file delete/rename sharing과 pathname replacement semantics도 별도로 다뤄야 하므로 cross-platform cleanup을 단일 pathname-only contract로 둘 수 없습니다. + +Stale bytes를 resume하거나 신뢰하는 것도 허용하지 않습니다. 이전 프로세스가 남긴 bytes에는 response completion, digest, updater signature, metadata authenticity 증거가 없습니다. Windows에서 정상 Drop 뒤 보수적으로 남겨 둔 bytes도 동일하게 unverified scratch입니다. 이 namespace에는 verified artifact를 장기 보존하지 않으며, 향후 promotion은 별도 retained/known-good owner가 맡습니다. + +## RED → causal fix + +- Restart RED `5b0ddb585ee1eb7ddadaa66eeab267c6f55d6467`: 이전 프로세스가 남긴 `update.bin` regular file은 재사용하지 않고 byte zero부터 새 exclusive attempt로 교체해야 하며 destination symlink는 stale regular file로 오인하지 않아야 한다는 계약을 추가했습니다. +- Restart causal fix `b7a1839d5941c52800bbeaf22921e143060d1ff6`: exact direct child가 regular file일 때만 stale unverified scratch로 제거한 뒤 `create_new`로 새 descriptor를 만듭니다. Symlink·directory·기타 non-regular object는 fail closed합니다. +- Concurrent-writer RED `82843b4833df264aa6d5530d9f46545b1179a0bb`: 첫 `StagedArtifactFile`이 partial bytes를 쓰고 살아 있는 동안 같은 staging namespace에서 두 번째 attempt가 기존 pathname을 reclaim해서는 안 되며 `ConcurrentAttempt`로 실패해야 한다는 계약을 추가했습니다. 기존 stale-recovery 구현은 live regular child도 삭제하므로 이 계약을 만족하지 못합니다. +- Causal fix `40cd7543fab6ac6cb203e310b16058645edcedae`: stale-file 분류보다 먼저 app-owned staging directory의 persistent `.bandscope-staging.lock`을 열고 `File::try_lock()` exclusive lease를 취득합니다. 이미 다른 BandScope handle/process가 lease를 갖고 있으면 `ConcurrentAttempt`로 fail closed합니다. Lease는 staged descriptor와 함께 유지되고 `seal` 시 `SealedArtifactFile`로 이동하여 digest/signature verification 전까지 같은 scratch namespace를 보호합니다. Descriptor cleanup이 끝난 뒤 handle을 닫아 lease를 해제합니다. +- Fixture adaptation `ebf94287ea54d329a3276f02a5251054c9b2d20c`: persistent lease sentinel은 crash-safe coordination object이므로 test teardown이 artifact cleanup과 sentinel cleanup을 구분하도록 고쳤습니다. +- Edge coverage `d2d187288ef27e7fabdacde062d83423bfa2e243`: sealed-but-unverified 상태에서도 lease가 유지되는지, drop 이후 새 attempt가 가능한지, Unix에서 lease sentinel symlink를 따라가지 않는지를 고정했습니다. +- Cross-platform fixture hardening `752b5343c809b8e8f76a9886295de42e19ebc3ff`: Rust가 file lock과 ordinary read/write의 상호작용을 platform-specific으로 명시하므로, lease를 보유한 sealed artifact를 별도 pathname handle로 읽는 테스트 가정을 제거하고 path 존재/ownership과 `ConcurrentAttempt`만 검증하도록 고쳤습니다. Product code나 trust semantics는 바꾸지 않습니다. +- Platform-evidence RED `41afd2abb6f3beeded35d2576f3f1e9532b75ce3`: Ubuntu-only native-suite execution만으로 Windows/macOS file-lock semantics를 release evidence로 삼지 못하도록, `ci.yml`이 Linux·Windows·macOS에서 exact `distribution-download` locked all-target test를 실행하고 protected `ci / build-and-test`가 그 matrix를 선행조건으로 가져야 한다는 repository contract를 추가했습니다. +- Platform-evidence fix `cfb3ec11503fd8b7abafce05f07ea916cc34153c`: `distribution-download-platform` CI matrix를 `ubuntu-latest`, `windows-2025`, `macos-15`로 추가하고 각 runner에서 `cargo +stable test --manifest-path apps/desktop/distribution-download/Cargo.toml --locked --all-targets`를 실행합니다. Main `ci / build-and-test`는 이 matrix와 npm lock validation을 모두 `needs`로 요구하므로 platform lease test가 실패한 상태에서 required main CI gate가 성공할 수 없습니다. +- Real-process coverage `44265a038bf1c162df15539de0bcfaaf6f286bea`: same-process handle contention만으로 process coordination을 추정하지 않도록 integration test가 현재 test binary를 별도 child process로 실행합니다. Child가 실제 staging lease와 artifact를 보유한 뒤 readiness signal을 내고, parent는 같은 staging namespace의 create가 `ConcurrentAttempt`로 실패하며 pathname이 보존되는지 확인합니다. Child process가 lease를 해제한 뒤 parent fresh attempt가 성공해야 test가 끝납니다. 이 test도 위 OS matrix에서 실행됩니다. +- Windows pathname RED `308f4a618cbbfd07fc9380b721f9987b3cb373d1`: owned staging file을 열린 상태에서 이동하고 original basename에 unrelated replacement를 만든 뒤 cancelled/sealed owner를 Drop했을 때 replacement가 살아 있어야 한다는 contract를 Windows까지 확장했습니다. 이전 non-Unix fallback은 descriptor를 닫고 pathname을 무조건 삭제하므로 이 case를 위반했습니다. +- Windows cleanup fix `1d603316b0ff4ff8b737aea2ef57c76250e326d0`: stable Rust에서 descriptor/path identity를 증명할 수 없는 non-Unix 경로는 remembered pathname을 삭제하지 않고 descriptor만 닫습니다. Unix는 `(dev, ino)` equality 확인 뒤에만 unlink합니다. +- Windows recovery coverage `1a6ee097106cf2204cf9bdbcc0040999988f4e14`: Windows Drop이 남긴 unverified regular scratch가 다음 attempt의 shared lease 아래에서 stale child로 회수되고 새 `create_new` file로 교체됨을 고정합니다. +- Cross-platform test adaptation `7996cc2a1ae722093ab02c5a590ccc562a2b058b`, `2acb2f3708b6ef6fb664e7dcc2741fa219c199ed`, `4c94b4698c79fd32f659fb58106f2da0aa510d1a`, `2e4c355df5c45117c01ff1585e6478a8951ad168`, `6a85d358d46194d60a852f59463fa23252785992`: lifecycle/reader/transport fixtures를 Unix unlink와 Windows deferred-reclaim semantics, persistent lease sentinel teardown에 맞췄습니다. + +## 실행 계약 + +- artifact basename을 검사하고 staging root가 direct non-symlink directory인지 확인한 뒤, stale artifact pathname을 읽거나 제거하기 전에 staging lease를 먼저 취득합니다. +- `.bandscope-staging.lock`은 조정용 sentinel입니다. 파일 내용은 trust evidence가 아니며 읽거나 해석하지 않습니다. Sentinel pathname은 정상 종료 뒤에도 남아 있을 수 있고, 실제 active ownership은 OS file lock으로 표현합니다. +- lease sentinel이 symlink 또는 non-regular object이면 이를 따라가거나 교체하지 않고 fail closed합니다. +- 다른 cooperating BandScope handle/process가 lease를 보유하면 `StagedArtifactFile::create`는 `ConcurrentAttempt`로 종료하며 기존 staging artifact를 건드리지 않습니다. +- lease를 획득한 뒤에만 pre-existing regular artifact를 이전 crash 또는 안전하게 deferred된 unverified residue로 간주할 수 있습니다. 해당 bytes는 resume하지 않고 제거한 뒤 `create_new`로 byte zero부터 시작합니다. +- `StagedArtifactFile`에서 `SealedArtifactFile`로 전환해도 lease를 유지합니다. Exact descriptor의 digest/signature 검증과 cleanup 사이에 다른 attempt가 pathname을 reclaim하지 못하게 하는 목적입니다. +- Drop은 descriptor ownership을 먼저 끝냅니다. Unix는 current direct regular pathname이 still-open descriptor와 같은 `(dev, ino)`일 때만 unlink합니다. Windows는 stable Rust에서 동일 identity를 증명할 수 없으므로 pathname deletion을 지연할 수 있습니다. 어느 경우든 lease handle은 descriptor cleanup 결정 뒤에 닫히며 다음 attempt가 lease를 얻을 수 있습니다. +- process termination 시 OS가 file handle을 닫으면 lock도 함께 해제되므로 persistent sentinel 자체가 영구 blocker가 되지 않습니다. +- symlink, directory 또는 기타 non-regular artifact destination은 자동 삭제하지 않습니다. +- verified artifact를 이 scratch namespace에 장기 보존하는 API는 없습니다. Windows deferred regular scratch는 verified retention이 아니며 다음 leased attempt에서 재사용 없이 폐기됩니다. +- platform-specific lock/cleanup behavior를 Linux-only unit evidence로 일반화하지 않습니다. Distribution staging/lease integration suite는 Linux·Windows·macOS hosted runner에서 exact-head 실행되어야 하며 main `ci / build-and-test`는 그 matrix를 통과한 뒤에만 시작할 수 있습니다. +- process-ownership claim은 별도 OS process가 lease를 보유하는 integration case를 포함해야 합니다. 같은 test process 안의 두 file handle만으로 cross-process exclusion을 증명했다고 보지 않습니다. + +## 선택과 기각한 대안 + +Artifact file 자체만 advisory-lock하는 방식은 선택하지 않았습니다. `create_new`와 lock 획득 사이에는 별도 process가 새 pathname을 관찰할 수 있어 create+lock을 하나의 portable atomic operation으로 만들 수 없고, stale classification과 live ownership을 안정적으로 직렬화하지 못합니다. + +기존 regular file을 그대로 열어 resume하는 방식도 기각합니다. 어느 byte까지 authenticated response였는지, 이전 process가 어떤 metadata/signature를 사용했는지 증명할 수 없고 partial bytes를 새 response와 혼합할 수 있습니다. + +`truncate(true)` 또는 overwrite-open으로 기존 artifact path를 바로 재사용하는 방식도 기각합니다. Symlink/non-regular destination을 따라가거나 덮어쓸 수 있고 exclusive ownership 증거가 약해집니다. + +Lease sentinel을 정상 drop마다 삭제하는 방식도 사용하지 않습니다. Lock holder가 sentinel pathname을 unlink하면 다른 process가 새 sentinel inode를 만들 수 있고, 기존 inode를 열어 기다리던 process와 lock domain이 갈라질 수 있습니다. Sentinel은 남겨 두고 OS lock의 보유 여부만 active ownership으로 사용합니다. + +Windows에서 descriptor를 닫은 뒤 remembered pathname을 무조건 삭제하는 방식도 기각합니다. 기본 file sharing 아래에서는 owned file이 열린 동안 pathname이 rename/replacement될 수 있고, close 이후 path delete는 현재 그 이름을 차지한 다른 object를 삭제할 수 있습니다. Stable Rust by-handle identity가 없는 현재 source boundary에서는 cleanup completeness보다 unrelated-file integrity를 우선합니다. + +Linux CI 한 곳에서만 lock suite를 실행하고 Windows/macOS 동작을 문서상 동일하다고 간주하는 방식도 기각합니다. Rust 자체가 file lock 구현과 read/write 상호작용을 platform-specific이라고 명시하므로, 판매 대상 desktop OS family에서 실행 evidence를 직접 확보해야 합니다. + +Same-process handle contention만으로 process-level lease를 증명하는 방식도 기각합니다. OS lock의 handle/process semantics는 platform-specific할 수 있으므로 별도 process가 실제 lock owner일 때의 exclusion과 release를 각 판매 대상 OS runner에서 실행합니다. + +## Claim boundary + +이 수리는 **cooperating BandScope processes 사이에서 active staging attempt를 crash residue로 오인해 reclaim하는 source-level race**, restart 뒤 stale regular file이 동일 update를 영구 차단하는 경로, 그리고 Windows에서 remembered pathname을 ownership evidence 없이 삭제하던 경로를 함께 닫습니다. `File::try_lock`은 플랫폼에 따라 advisory 또는 mandatory일 수 있으므로, 이 lease가 임의의 로컬 악성 프로세스가 직접 filesystem을 변조하는 것을 막는 mandatory sandbox라고 주장하지 않습니다. Staging root 자체의 ACL/ownership hardening과 Unix metadata-check→unlink TOCTOU도 별도 security boundary입니다. + +Cross-platform CI matrix와 real-process test는 Windows/macOS/Linux에서 현재 cooperating-process exclusion 및 OS별 cleanup contract가 실행된다는 evidence gate입니다. Packaged application process kill, power loss, disk-full, antivirus/file-lock, filesystem crash가 모두 검증됐다는 뜻은 아닙니다. Production HTTP adapter, cryptographic verification, verified-artifact promotion과 last-known-good retention은 별도 release gate입니다. + +## 근거 + +Rust Project. (2026). *std::fs::File::try_lock and TryLockError* (Rust 1.98.1 standard library). https://doc.rust-lang.org/std/fs/struct.File.html#method.try_lock + +Rust 표준 라이브러리는 `File::try_lock`/`TryLockError`를 Rust 1.89.0부터 stable로 제공하며, 다른 handle/process가 lock을 보유하면 `WouldBlock`으로 구분합니다. File handle이 닫히면 lock이 해제되고 Unix에서는 `flock`, Windows에서는 `LockFileEx` 계열에 대응하지만 ordinary read/write와의 세부 상호작용은 platform-specific이라고 명시합니다. BandScope는 이 API를 cooperating updater process 간 lease로만 사용하며 테스트도 lock 보유 중 별도 file read 가능성을 전제로 하지 않습니다. + +## Security Notes + +Staging bytes는 canonical release namespace에서 왔더라도 verification 전까지 untrusted입니다. Restart recovery는 stale bytes를 살리는 기능이 아니라 active owner가 없음을 lease로 확인한 뒤 제거하고 새 admission을 시작하는 기능입니다. Lease sentinel과 artifact destination의 symlink/non-regular object는 자동 정리 대상이 아닙니다. Windows에서 safe Drop unlink를 증명할 수 없어 남은 regular scratch도 trust evidence가 아니며 다음 leased attempt가 resume 없이 폐기합니다. Verified artifact는 staging scratch 밖의 별도 owner로 승격되어야 하며 audio/project content는 이 updater staging 경계에 들어오지 않습니다. \ No newline at end of file diff --git a/docs/traceability/updater-state-torn-tail-admission.md b/docs/traceability/updater-state-torn-tail-admission.md new file mode 100644 index 000000000..f30972294 --- /dev/null +++ b/docs/traceability/updater-state-torn-tail-admission.md @@ -0,0 +1,49 @@ +# Updater highest-seen torn-tail admission + +## Decision + +BandScope treats a non-newline-terminated highest-seen state tail as recoverable only when every byte can still be extended into a release record that `distribution-core::StableVersion` can admit. + +The state owner therefore applies the same `u64` component bound used by the canonical stable-version parser while checking an incomplete `MAJOR.MINOR.PATCH` prefix. A decimal component that already exceeds `u64::MAX` is corruption, not a recoverable torn write, even when it is only 20 digits long. + +## Problem + +`distribution-state` intentionally distinguishes a recoverable final torn append from committed-state corruption. Before this repair, `is_version_prefix()` checked digit/dot grammar, component count, leading zeroes, and a 20-character component ceiling, but did not verify that a non-empty component could fit the `u64` representation owned by `distribution-core`. + +That admitted impossible prefixes such as `v1|18446744073709551616` as recoverable. `load_highest_seen()` could consequently return `Ok(None)` (or the previous committed identity when a valid record preceded the tail), and the next writer could truncate the impossible tail as crash residue. Because `18446744073709551616` can never become a valid `StableVersion` component, silently repairing it would discard corruption rather than complete an interrupted valid record. + +## RED evidence + +Commit `e57864425816959910950052c3f9e40a7cadb8a4` adds `apps/desktop/distribution-state/tests/impossible_torn_tail.rs`. + +The regression contract covers overflow in each stable-version component: + +- `v1|18446744073709551616` +- `v1|1.18446744073709551616` +- `v1|1.2.18446744073709551616` + +All must return `StateError::Corrupt`. The same test preserves two positive boundaries: `v1|2.0` remains a recoverable partial version, and `v1|18446744073709551615` remains recoverable because the component is exactly `u64::MAX` and can still be extended with the remaining separators/components. + +## Causal fix + +Commit `e793d0018db3cc58a0c929485e9fccc8072bd57a` adds one condition to the existing version-prefix admission loop: every non-empty decimal component must parse as `u64`. + +This keeps the repair in the durable-state owner and reuses the representation already established by `distribution-core`; it does not introduce another SemVer implementation or widen the accepted release grammar. + +## Alternatives considered + +A 20-digit length check alone was rejected because the decimal range `18446744073709551616..=99999999999999999999` is 20 digits but outside `u64` and can never become valid by appending more bytes. + +Treating every non-newline tail as recoverable was rejected because it converts arbitrary state corruption into destructive truncation and weakens anti-replay evidence. + +Calling the complete `StableVersion::parse()` directly was rejected for incomplete forms such as `2.0`, which are intentionally valid crash prefixes but are not complete `MAJOR.MINOR.PATCH` values. Prefix admission therefore remains a separate state-format concern while sharing the canonical numeric bound. + +## Claim boundary + +This repair proves only that an incomplete state tail classified as recoverable is numerically extendable within BandScope's current `u64` stable-version representation. It does not authenticate state bytes, make advisory leases mandatory for non-cooperating processes, or prove packaged process-kill/power-loss behavior. + +The production trust order remains unchanged: authenticated updater metadata and release identity, verified updater artifact signature, exact sealed-descriptor digest/size binding, explicit verified-artifact promotion, anti-replay decision, then durable highest-seen mutation. + +## Follow-up + +Keep the new regression in the exact-head Rust test gate. Packaged Windows/macOS acceptance must still cover process kill, restart, power loss, competing-process lease behavior, and last-known-good rollback after the production updater flow is wired. diff --git a/docs/traceability/updater-transport-diagnostics.md b/docs/traceability/updater-transport-diagnostics.md new file mode 100644 index 000000000..1f804f738 --- /dev/null +++ b/docs/traceability/updater-transport-diagnostics.md @@ -0,0 +1,64 @@ +# Updater transport diagnostics traceability + +Status: source-repaired; hosted exact-head verification pending. + +## Problem + +The Distribution transport policy must preserve the exact release-asset redirect URL so the network adapter can request the admitted location and later prove exact effective-URL equality. GitHub release delivery may attach an opaque query component to the `release-assets.githubusercontent.com` URL. That query is provider-controlled transport data, not buyer-facing diagnostics. + +`AdmittedRedirect` and `AdmittedDownloadHead` originally derived Rust `Debug`. Formatting either value therefore emitted the complete redirect/effective URL, including the opaque query. A later error path, structured diagnostic, panic assertion, or support bundle that formats these values could copy provider query data into logs even though query contents are unnecessary for diagnosis. RFC 3986 defines the query as a distinct URI component carrying non-hierarchical data; OWASP logging guidance recommends removing, masking, sanitizing, hashing, or encrypting access/session-style values instead of recording them directly. + +The same diagnostics surface also retained the provisional Tauri signature string. `distribution-runtime` permits a signature field up to 64 KiB before the transport layer checks its canonical base64 envelope. The signature is public verification material rather than a credential, but dumping an attacker-controlled bounded field of that size into ordinary `Debug` output creates avoidable log amplification and carries no useful operational signal. Transport diagnostics need to know that signature evidence exists, not reproduce it. + +Fresh review found the same signature exposure one boundary earlier. `ProvisionalUpdateMetadata` still derived `Debug`, so formatting the strictly parsed but unauthenticated metadata copied the exact selected signature before `distribution-transport` had any opportunity to redact it. Fixing only transport types therefore left a direct 64 KiB remote-input log-amplification surface in the metadata-admission owner itself. + +The later sealed-evidence repair introduced another diagnostic surface. `SealedTransportArtifact` deliberately owns the full provisional candidate identity and artifact evidence together with the exact sealed descriptor. A derived debug representation would therefore risk copying the source commit, digest, updater signature, or opaque CDN query into ordinary logs at the point where the artifact is most likely to be inspected during verification failures. The sealed wrapper requires the same bounded diagnostic contract as its predecessor types. + +## Constraints + +- Preserve the exact redirect URL internally and through `AdmittedRedirect::location()` because the production network adapter must request exactly the admitted value. +- Preserve exact `AdmittedDownloadHead::effective_url()` and `SealedTransportArtifact::effective_url()` for response/evidence binding. Redaction must affect diagnostics only, never transport equality or verification input. +- Preserve exact `artifact_signature()` in provisional metadata, admitted heads, and sealed transport evidence for the later Tauri verification boundary; diagnostic redaction must not mutate or replace verification input. +- Keep the full provisional source commit, target, versions and SHA-256 bound to `SealedTransportArtifact`, while omitting those high-cardinality/verification values from ordinary sealed debug output. +- Do not guess the provider's query parameter names or attempt semantic parsing of opaque query data. +- Do not add a URL or logging dependency for this narrow boundary. +- Keep ordinary `Debug` usability for tests and diagnostics while preventing opaque query payloads or full provisional signatures from appearing in formatted values. +- This change is log-surface minimization. It does not authenticate remote metadata, make a redirect trustworthy, or establish updater cryptographic verification. + +## RED → repair evidence + +- `c3dbebeff5ad4c9abe7e6d61cdded840bbfd9d3c` adds a regression that admits a valid one-hop CDN URL containing an opaque query, then requires both the redirect decision and final download-head `Debug` surfaces to contain a redaction marker while excluding the query value. The predecessor derived `Debug` prints the complete URL and therefore violates this contract. +- `be91505c0df2dc496849ae8b289f252643f9e055` removes derived `Debug` from the two URL-bearing types and implements bounded custom formatting. Only the substring after the first `?` is replaced with ``; the exact stored URL and public exact-value accessors are unchanged. +- `935266a1065787443a2a441bcfdc2933905d1157` extends the diagnostics RED to require `ReleaseTransportPolicy` and `AdmittedDownloadHead` debug output to contain only `` while the exact `artifact_signature()` accessor still returns the admitted value. The predecessor custom download-head debug and derived policy debug both expose the full signature string. +- `1a4e8f541e3017f0e666935c3e003027b871d131` replaces policy derived debug with bounded custom formatting and redacts the signature field in both transport policy and download-head diagnostics. Signature validation, storage, equality and exact accessor behavior are unchanged. +- `027ba1474281b0ed968f039eb20073f1b7b9b2e9` adds a runtime-boundary regression requiring `ProvisionalUpdateMetadata` diagnostics to exclude the exact remote signature while its verification accessor remains byte-for-byte unchanged. The predecessor derived `Debug` violates this contract. +- `90855ccdb8ecb1a1166a6c2e614b6851ae26f662` replaces the provisional metadata derived `Debug` with bounded custom formatting. Candidate identity, declared size and canonical release URL remain diagnosable; only the full signature field becomes the fixed `` marker. +- `dea8f7bd68470871701b5dc01d44875bc33c0909` extends the current diagnostics contract through `SealedTransportArtifact`: after a real bounded staging/seal transition, debug output must retain the redaction markers while excluding the provider query value, updater signature, source commit and SHA-256. Exact accessors and descriptor ownership remain unchanged. + +## Selected design + +A private `RedactedUrl` formatter owns URL diagnostic rendering in `distribution-transport`. It does not allocate a second transport identity, modify stored state, normalize the URL, or feed back into policy decisions. URLs without a query render unchanged. URLs with a query retain the scheme/authority/path for operational diagnosis and render only a fixed redaction marker for the query component. + +The query redaction is intentionally applied to `AdmittedRedirect`, `AdmittedDownloadHead`, and `SealedTransportArtifact`: the first holds the URL before the follow-up request, the second retains the same effective URL after the admitted `200`, and the sealed wrapper carries that evidence beside the exact descriptor for later verification. Fixing only an earlier type would let the same opaque query reappear after the next state transition. + +Signature diagnostics use the same fixed `` marker in `ProvisionalUpdateMetadata`, `ReleaseTransportPolicy`, `AdmittedDownloadHead`, and `SealedTransportArtifact`. The actual signature remains private state exposed through the exact verification accessor. This bounds normal debug output independently of the remote signature-size allowance and closes the earlier metadata-owner leak rather than relying on every downstream caller to remember not to format the provisional aggregate. + +`SealedTransportArtifact` also omits source commit and SHA-256 from its custom debug representation. Both remain exact typed evidence through explicit accessors, but neither is necessary for a routine debug summary that already identifies the artifact name, redacted effective URL, expected size and actual sealed byte count. This keeps verification material available to the cryptographic owner without making it the default log payload. + +The canonical initial GitHub release URL remains visible in provisional/transport diagnostics because strict admission rejects query, fragment, whitespace, alternate authority and path-like asset syntax before the value exists in these types. That bounded URL is operationally useful for identifying the release target. The opaque CDN query remains redacted because its contents are not part of BandScope's release identity and need not be copied into diagnostic systems. + +## Claim boundary and remaining work + +This repair prevents automatic Rust `Debug` output for provisional updater metadata, Distribution transport policy, redirect decisions, final download heads, and sealed transport artifacts from exposing full provisional signature text; URL-bearing transport values also omit CDN redirect query contents, and sealed transport debug omits source commit and SHA-256. It does not prove that callers never log the explicit `artifact_signature()`, `source_commit()`, `expected_artifact_sha256()`, `location()`, or `effective_url()` accessors. Those exact accessors remain necessary for verification/network boundaries and must be handled as transport/security data. + +OWASP's Logging Cheat Sheet explicitly treats event data from other trust zones as untrusted and recommends excluding, masking, sanitizing, hashing, or encrypting data that should not be recorded directly. The fixed diagnostic markers implement that minimization at the type boundary rather than relying only on call-site discipline. + +The future production HTTP adapter must avoid logging full provider redirect URLs, must still disable implicit redirects and automatic decompression, and must stream only admitted response bytes through `distribution-download`. + +Remote metadata authentication, sealed-descriptor digest/signature verification, verified-artifact promotion, signer authority, packaged fault injection, and anti-replay state wiring remain separate release gates. + +## References + +Berners-Lee, T., Fielding, R., & Masinter, L. (2005). *Uniform Resource Identifier: Generic Syntax* (RFC 3986). RFC Editor. https://www.rfc-editor.org/rfc/rfc3986 + +OWASP Foundation. (2026). *Logging Cheat Sheet*. OWASP Cheat Sheet Series. https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html diff --git a/docs/traceability/updater-transport-policy.md b/docs/traceability/updater-transport-policy.md new file mode 100644 index 000000000..61a48e817 --- /dev/null +++ b/docs/traceability/updater-transport-policy.md @@ -0,0 +1,89 @@ +# Updater transport admission traceability + +Status: implemented deterministic policy boundary; production network adapter still pending. + +## Problem + +BandScope already has a strict provisional updater-metadata parser and a bounded streaming/staging primitive. Distribution still needs an executable boundary between those owners so a future HTTP adapter cannot reparse `raw_json`, follow redirects implicitly, transform response bytes before staging, or lose the exact response origin that produced an updater artifact. + +GitHub release-asset delivery may terminate directly with `200` or use a `302` hop. Rejecting every redirect would therefore break the supported release path, while delegating redirect decisions to an HTTP client would make the final network destination library-controlled rather than Distribution-controlled. + +Updater signatures and SHA-256 evidence describe exact published artifact bytes. Content codings such as gzip or brotli can make a client expose decoded bytes while response framing still describes the encoded representation. Distribution therefore rejects transformed response bodies before filesystem mutation and requires the eventual network adapter to disable transparent decoding. + +A redirect decision is also attempt state, not just a destination URL. The first repair bound `AdmittedRedirect` to the initial URL plus provisional artifact size, digest and updater signature. Fresh review found that this was still narrower than the release candidate carried by `ProvisionalUpdateMetadata`: two metadata projections could reuse the same versioned artifact URL, size, digest and signature while changing `sourceCommit` or `minimumSupportedVersion`. The old redirect token would then be accepted by the second policy even though it originated from a different provisional release candidate. This does not by itself create cryptographic trust, but it breaks evidence continuity before metadata authentication and makes later sealed-descriptor promotion ambiguous. + +The same continuity problem existed one transition later. `AdmittedDownloadHead` exposed the artifact URL/size/digest/signature, but did not retain the full provisional candidate identity. `TransportDownload::finish` then returned a bare `SealedArtifactFile`. A later verifier could therefore hold the correct descriptor while accidentally pairing it with source-commit, target, version, minimum-supported-version or artifact evidence retained from a different provisional candidate. The bytes remained bounded and synchronized, but the type system no longer proved which admitted candidate produced them. + +## Constraints + +- Consume `ProvisionalUpdateMetadata` directly; do not introduce a second remote-JSON parser. +- URL, signature, expected size, SHA-256, version, source commit and minimum-supported version remain provisional until a later authenticated metadata binding succeeds. +- `distribution-runtime` owns the four-target updater document schema and requires every supported platform signature to use the canonical RFC 4648 standard-base64 outer envelope before it can construct `ProvisionalUpdateMetadata`. This is syntax admission, not minisign verification. +- `distribution-transport` must not duplicate the signature-envelope parser or invent a second metadata interpretation. +- Disable automatic redirect semantics in the eventual network adapter. Every followed location must originate from an explicit `ResponseDecision`. +- Admit a direct `200` only when the HTTP client's effective URL exactly equals the canonical BandScope release URL already admitted by `distribution-runtime`. +- Admit at most one `302` hop, currently to `https://release-assets.githubusercontent.com/`. A CDN-host change fails closed until this BandScope egress decision is reviewed. +- A redirect token is valid only for the same provisional release candidate and artifact evidence that created it: version components, source commit, target, minimum-supported-version components, initial URL, declared size, SHA-256 and updater signature must all remain identical. +- A redirected request must terminate in `200` at the exact admitted redirect URL. Redirect chaining is rejected. +- Reject every response `Content-Encoding` other than explicit `identity` before staging-file creation. Omitted `Content-Encoding` remains admissible. The production adapter must also disable transparent decompression so header evidence and delivered bytes cannot diverge. +- Response bodies reach disk only through `distribution-download`, preserving expected-size, optional `Content-Length`, per-chunk, cumulative-overrun, poison and cleanup contracts. +- Content-encoding and content-length mismatch are evaluated before staging-file creation. +- Starting staging consumes the admitted response head. Candidate identity, effective URL, expected size, SHA-256 and updater signature remain attached to that one staging attempt and must survive sealing in the same move-only value as the exact descriptor. +- A successfully staged artifact is still unverified scratch. This owner performs no updater-signature verification, digest trust promotion, installation or highest-seen mutation. + +## Alternatives considered + +Implicit HTTP-client redirects were rejected because they conceal origin changes. Rejecting all redirects was rejected because GitHub release-asset delivery may legitimately use `302`. Re-parsing updater JSON in the network adapter was rejected because it would create a second, potentially looser interpretation of untrusted metadata. Adding a random per-attempt nonce was also rejected: the complete candidate and artifact identity needed to prove deterministic policy continuity is already bounded by `distribution-runtime`, so a nonce would add nondeterminism without adding release semantics. + +Binding a redirect token only to source/destination URLs was rejected because metadata evidence can change while a canonical release URL remains stable. The earlier size/digest/signature binding closed one form of cross-policy replay but still omitted source commit and minimum-supported version. The selected design therefore carries the full bounded candidate identity needed by this owner, while keeping ordinary `Debug` output free of updater signatures and opaque CDN queries. + +Returning a bare `SealedArtifactFile` and asking future verification code to keep a separate metadata object synchronized was rejected for the same reason. Descriptor identity is only useful if the digest/signature/candidate evidence being checked belongs to that descriptor. The selected `SealedTransportArtifact` keeps the exact sealed descriptor and its provisional transport evidence together without reopening the path or promoting trust. + +Allowing HTTP content coding and trusting a client to reconstruct equivalent bytes was rejected because automatic decompression is library/configuration dependent. Reimplementing minisign verification here was also rejected: Tauri/updater verification remains the cryptographic owner, while BandScope's transport layer only admits bounded response semantics and exact bytes. + +## Selected design + +`apps/desktop/distribution-runtime` owns strict updater-document parsing. It validates all four platform entries, including canonical standard-base64 signature envelopes, and returns one selected `ProvisionalUpdateMetadata`. The result remains unauthenticated remote metadata. + +`apps/desktop/distribution-transport` sits between that metadata owner and `distribution-download`. `ReleaseTransportPolicy::from_provisional` copies the canonical initial URL, safe artifact basename, declared byte size, SHA-256 and updater signature. It also builds a private `ProvisionalPolicyIdentity` from the already-bounded version components, source commit, target and minimum-supported-version components. No raw metadata is reparsed. + +`admit_initial_response` accepts exact-URL `200`, or returns an explicit one-hop `AdmittedRedirect` for an allowed GitHub release-asset CDN location. The redirect token privately carries both the full provisional candidate identity and the artifact evidence that created it. `admit_redirect_response` first requires that complete identity to equal the current policy, then requires the response to terminate in `200` at the exact admitted location. Only after those checks can an `AdmittedDownloadHead` expose a body to bounded staging. + +`AdmittedDownloadHead` now carries the same private candidate identity. `start_staging` consumes that head and moves it into `TransportDownload`, so a response head cannot be detached from or reused independently of the staging attempt it admitted. `TransportDownload::finish` returns `SealedTransportArtifact`, which owns both the exact `SealedArtifactFile` and the head evidence. Read-only access to the bytes delegates to `SealedArtifactFile::reader`, so later digest/signature verification can consume the same descriptor without reopening the staging pathname. + +`distribution-download` remains the filesystem/descriptor owner. `TransportDownload` only forwards bounded chunks and completion into its existing staging/seal contract. Cancellation retains the same owner cleanup behavior; the transport crate does not reopen an artifact or create a second descriptor authority. + +`scripts/release/build_updater_manifest.py` remains the publication-side companion for the outer signature-envelope contract after exact `.sig` receipt binding. It does not claim to perform minisign verification. + +The transport API intentionally contains no socket/client, installer, freshness repository, project persistence dependency, or verified-artifact type. `SealedTransportArtifact` is explicitly still unverified and cleanup-on-drop. The eventual HTTP adapter must be added separately with the repository's reviewed dependency admission and exact standalone lock graph; cryptographic verification and verified-artifact promotion remain later owners. + +## RED → repair evidence + +- `8f39dfc57026a25389f985e06dacee025818c5b2` introduced the original hostile/product transport cases for direct download, one-hop redirect, hostile origin, redirect chaining, framing and cancellation. +- `1b4f7a0a840d917f54fdb6b78ec861ba4b5ba0f7` placed the standalone transport crate in the native CI suite; `4964c3cd1472ed6ac9c7a9223d3da533e1af6096` connected one-hop `302` admission to bounded staging. +- `8313e9fb2fe66711e2c3e0432413a94355fdf6e7` / `6e5e42f2a20001009330c438178afa1ca811ab51` established the first canonical base64 envelope contract. `03c1314884a4044129ead75db59d341b80ed4499` / `2c7c772abcceff96dafceaaaa3b6a4e2af5f8cbc` added the matching publication-side envelope gate. +- `e37632589960cd3571c99eafafdcf205734bb21b` / `606095ec6f2ae9b5d22a777f70806dc79baa8f36` established fail-closed content-coding admission before staging. +- `8d56ab1015077e256e560deba9611979cb81ec5d` / `4e28d0cf5edfb399e3ced07b12daf5c4a7aace62` moved canonical signature-envelope admission to the metadata owner for every supported target. `9b691d7f1e67dff23b26b1427a8c7bf63b6fd025` and `5b85e03fde690240df62ac18c4e49b9052047f83` then removed the duplicate transport parser and aligned the test contract; `26ff403041958c433239a2359e6cfc32a2b633b9` repaired the remaining strict-admission fixture. +- `11a5a47784a405e5cad973d3c40aa8fe18b40940` proved that a redirect created with one updater signature could cross into another policy. `35b851e4724ca625351a14df80f78e121a4f3d6a` repaired that class by binding size/digest/signature to the redirect token. +- `fd29aa1c238c4c0d0c7bec914f219b877b2e88b3` proved at source level that the same artifact URL/size/digest/signature could still let a redirect token cross to metadata with a different source commit or minimum-supported version. `6b0fe81407c6c20492dc31164124309ebc83dafa` repaired that class by carrying the private full candidate identity through redirect admission. +- `9901b2d906f6f0d418bfd4860b9e404f9da27533` adds the current source-level RED: after sealing, the returned value must still expose the exact provisional candidate identity and artifact evidence that admitted the same descriptor. The RED was committed before the causal source change; it was not separately claimed as a hosted failing run. +- `e42f6e6d8b035e6a87ee4b5eb7845874f5d76465` is the causal repair. `AdmittedDownloadHead` now owns the private candidate identity, staging consumes the head, and `TransportDownload::finish` returns a move-only `SealedTransportArtifact` that owns both evidence and descriptor. +- `3ef753fb5c6b195c75c9b483d65506256f60765d` extends the regression through the descriptor-preserving read path and verifies the exact sealed bytes without reopening the pathname. + +Hosted exact-head checks remain authoritative for compilation and cross-platform evidence; predecessor results do not transfer after a head change. + +## Security Notes + +Untrusted inputs are the four-target provisional metadata document, signature envelopes, HTTP status, client-reported effective URL, redirect `Location`, `Content-Encoding`, `Content-Length` and response chunks. The metadata owner applies structural admission before transport policy exists. Transport then uses exact URL equality, bounded HTTPS redirect admission, one-hop depth, full provisional candidate plus artifact-evidence binding, fail-closed content-coding admission and existing bounded chunk/file admission. + +The private candidate identity and `SealedTransportArtifact` are intentionally not trust promotion. A malicious or compromised metadata source can still provide a self-consistent false candidate until the later metadata-authentication owner verifies it. This boundary also does not cryptographically verify the updater signature, hash the sealed descriptor, disable decompression by itself, perform network I/O, or prove packaged Windows/macOS behavior. Those claims remain release gates. + +## References + +GitHub. (2026). *REST API endpoints for release assets*. GitHub Docs. https://docs.github.com/en/rest/releases/assets + +Tauri Programme. (2026). *Updater plugin*. Tauri v2 documentation. https://v2.tauri.app/plugin/updater/ + +Tauri Programme. (2026). `plugins/updater/src/updater.rs`. *tauri-apps/plugins-workspace*. https://github.com/tauri-apps/plugins-workspace/blob/0850317b5c85092cbf4ea9caf4ef3a9c771fcf27/plugins/updater/src/updater.rs + +Tauri Programme. (2026). `crates/tauri-cli/src/helpers/updater_signature.rs`. *tauri-apps/tauri*. https://github.com/tauri-apps/tauri/blob/e2c54be1055851686b1b57b69cfa7d6b5a0f552f/crates/tauri-cli/src/helpers/updater_signature.rs diff --git a/release/model-artifact-policy.json b/release/model-artifact-policy.json new file mode 100644 index 000000000..5eb770724 --- /dev/null +++ b/release/model-artifact-policy.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 1, + "releaseStatus": "blocked", + "blockedArtifact": { + "modelId": "demucs/htdemucs", + "checkpoint": "955717e8-8726e21a.th", + "reason": "commercial-rights-not-established", + "primaryEvidence": "https://github.com/facebookresearch/demucs/issues/327#issuecomment-1134828611" + }, + "admittedArtifact": null +} diff --git a/release/updater-policy.json b/release/updater-policy.json new file mode 100644 index 000000000..09185fb52 --- /dev/null +++ b/release/updater-policy.json @@ -0,0 +1,9 @@ +{ + "schemaVersion": 1, + "state": "blocked", + "channel": "stable", + "minimumSupportedVersion": "0.1.3", + "publicKey": null, + "endpoints": [], + "reason": "Organization-approved updater signing public key and immutable HTTPS release endpoint are not provisioned." +} diff --git a/scripts/checks/verify_distribution_http_dependencies.py b/scripts/checks/verify_distribution_http_dependencies.py new file mode 100755 index 000000000..77d1ed019 --- /dev/null +++ b/scripts/checks/verify_distribution_http_dependencies.py @@ -0,0 +1,419 @@ +#!/usr/bin/env python3 +"""Fail closed when Distribution HTTP dependency admission would select an unsafe TLS graph.""" + +from __future__ import annotations + +import sys +import tomllib +from pathlib import Path +from typing import Any + +DISTRIBUTION_TRANSPORT_MANIFEST = Path("apps/desktop/distribution-transport/Cargo.toml") +DISTRIBUTION_TRANSPORT_LOCK = Path("apps/desktop/distribution-transport/Cargo.lock") +RUSTLS_ENCRYPTION_LEVEL_ADVISORY = "RUSTSEC-2026-0285" +RUSTLS_AFFECTED_MIN = (0, 23, 13) +RUSTLS_PATCHED_MIN = (0, 23, 45) +REQWEST_REVIEWED_MIN = (0, 13, 5) +REQWEST_REVIEWED_UPPER = (0, 14, 0) +REQWEST_APPROVED_FEATURES = frozenset({"rustls"}) +REQWEST_APPROVED_DECLARATION_KEYS = frozenset( + {"version", "package", "default-features", "features"} +) +CRATES_IO_LOCK_SOURCE = "registry+https://github.com/rust-lang/crates.io-index" +LOCAL_DISTRIBUTION_LOCK_PACKAGES = frozenset( + { + "bandscope-distribution-core", + "bandscope-distribution-download", + "bandscope-distribution-runtime", + "bandscope-distribution-transport", + } +) + + +def _parsed_version(raw: str) -> tuple[tuple[int, int, int], bool] | None: + """Return numeric SemVer core plus whether a pre-release identifier is present.""" + without_build = raw.split("+", 1)[0] + core, separator, _prerelease = without_build.partition("-") + parts = core.split(".") + if len(parts) != 3 or any(not part.isdigit() for part in parts): + return None + major, minor, patch = (int(part) for part in parts) + return (major, minor, patch), bool(separator) + + +def _version_triplet(raw: str) -> tuple[int, int, int] | None: + """Return the numeric core used by owner version and advisory ranges.""" + parsed = _parsed_version(raw) + return None if parsed is None else parsed[0] + + +def _is_bounded_three_component_requirement(raw: str) -> bool: + """Return whether a Cargo requirement is one canonical three-component caret form.""" + version = _version_triplet(raw) + if version is None: + return False + canonical = ".".join(str(component) for component in version) + return raw == canonical + + +def _is_reviewed_reqwest_version(raw: str) -> bool: + """Return whether reqwest stays inside the reviewed stable 0.13 release line.""" + parsed = _parsed_version(raw) + if parsed is None: + return False + version, has_prerelease = parsed + return ( + not has_prerelease + and REQWEST_REVIEWED_MIN <= version < REQWEST_REVIEWED_UPPER + ) + + +def _is_affected_rustls(raw: str) -> bool: + """Return whether a rustls SemVer is inside RUSTSEC-2026-0285's affected range.""" + parsed = _parsed_version(raw) + if parsed is None: + return False + version, has_prerelease = parsed + at_or_after_affected_min = version > RUSTLS_AFFECTED_MIN or ( + version == RUSTLS_AFFECTED_MIN and not has_prerelease + ) + before_patched_stable = version < RUSTLS_PATCHED_MIN or ( + version == RUSTLS_PATCHED_MIN and has_prerelease + ) + return at_or_after_affected_min and before_patched_stable + + +def _dependency_package_name(dependency_name: str, declaration: Any) -> Any: + """Return the Cargo package selected by one dependency key or rename.""" + if isinstance(declaration, dict) and "package" in declaration: + return declaration["package"] + return dependency_name + + +def _workspace_dependencies(manifest: dict[str, Any]) -> dict[str, Any]: + """Return workspace dependency declarations visible to the root package.""" + workspace = manifest.get("workspace", {}) + if not isinstance(workspace, dict): + return {} + dependencies = workspace.get("dependencies", {}) + return dependencies if isinstance(dependencies, dict) else {} + + +def _resolved_package_name( + dependency_name: str, + declaration: Any, + workspace_dependencies: dict[str, Any], +) -> Any: + """Resolve package identity through Cargo workspace inheritance when present.""" + if isinstance(declaration, dict) and declaration.get("workspace") is True: + inherited = workspace_dependencies.get(dependency_name) + return _dependency_package_name(dependency_name, inherited) + return _dependency_package_name(dependency_name, declaration) + + +def _reqwest_declarations_in_table( + dependencies: Any, + *, + location_prefix: str, + workspace_dependencies: dict[str, Any], +) -> list[tuple[str, Any]]: + """Return reqwest package declarations from one normal dependency table.""" + if not isinstance(dependencies, dict): + return [] + return [ + (f"{location_prefix}.{dependency_name}", declaration) + for dependency_name, declaration in dependencies.items() + if _resolved_package_name( + dependency_name, + declaration, + workspace_dependencies, + ) + == "reqwest" + ] + + +def _direct_reqwest_declarations(manifest: dict[str, Any]) -> list[tuple[str, Any]]: + """Return runtime reqwest packages from unconditional and target-scoped dependencies.""" + workspace_dependencies = _workspace_dependencies(manifest) + declarations = _reqwest_declarations_in_table( + manifest.get("dependencies", {}), + location_prefix="dependencies", + workspace_dependencies=workspace_dependencies, + ) + + targets = manifest.get("target", {}) + if isinstance(targets, dict): + for selector, target_table in targets.items(): + if not isinstance(target_table, dict): + continue + declarations.extend( + _reqwest_declarations_in_table( + target_table.get("dependencies", {}), + location_prefix=f"target.{selector}.dependencies", + workspace_dependencies=workspace_dependencies, + ) + ) + return declarations + + +def _direct_reqwest_dependency_names(manifest: dict[str, Any]) -> frozenset[str]: + """Return Cargo dependency keys that resolve to the reqwest package.""" + workspace_dependencies = _workspace_dependencies(manifest) + names: set[str] = set() + + def collect(dependencies: Any) -> None: + if not isinstance(dependencies, dict): + return + for dependency_name, declaration in dependencies.items(): + if ( + _resolved_package_name( + dependency_name, + declaration, + workspace_dependencies, + ) + == "reqwest" + ): + names.add(dependency_name) + + collect(manifest.get("dependencies", {})) + targets = manifest.get("target", {}) + if isinstance(targets, dict): + for target_table in targets.values(): + if isinstance(target_table, dict): + collect(target_table.get("dependencies", {})) + return frozenset(names) + + +def _validate_reqwest_feature_forwarding(manifest: dict[str, Any]) -> list[str]: + """Reject root features that can add reqwest features outside its declaration.""" + dependency_names = _direct_reqwest_dependency_names(manifest) + if not dependency_names: + return [] + features = manifest.get("features", {}) + if not isinstance(features, dict): + return [] + + violations: list[str] = [] + for feature_name, members in features.items(): + if not isinstance(members, list): + continue + for member in members: + if not isinstance(member, str): + continue + for dependency_name in dependency_names: + direct_prefix = f"{dependency_name}/" + weak_prefix = f"{dependency_name}?/" + if member.startswith(direct_prefix) or member.startswith(weak_prefix): + violations.append( + f"{DISTRIBUTION_TRANSPORT_MANIFEST} [features.{feature_name}]: " + f"reqwest feature forwarding {member!r} is not admitted; select " + "the complete approved reqwest feature set only in the direct " + "runtime dependency declaration" + ) + return violations + + +def _validate_reqwest_declaration(location: str, reqwest: Any) -> list[str]: + """Validate one direct runtime reqwest declaration against owner policy.""" + prefix = f"{DISTRIBUTION_TRANSPORT_MANIFEST} [{location}]" + violations: list[str] = [] + if not isinstance(reqwest, dict): + return [ + f"{prefix}: direct reqwest must use a table with " + 'default-features = false and features = ["rustls"]' + ] + if reqwest.get("workspace") is True: + return [ + f"{prefix}: reqwest workspace inheritance is not admitted for the " + "Distribution transport; declare the package, default-features = false, " + 'and features = ["rustls"] directly in this runtime dependency table' + ] + + unapproved_keys = sorted( + set(reqwest).difference(REQWEST_APPROVED_DECLARATION_KEYS) + ) + if unapproved_keys: + violations.append( + f"{prefix}: reqwest source must be the versioned crates.io package; " + "git/path/alternate-registry and other declaration controls are not admitted; " + f"unapproved keys: {', '.join(unapproved_keys)}" + ) + version = reqwest.get("version") + if not isinstance(version, str) or not version.strip(): + violations.append( + f"{prefix}: reqwest source must include an explicit crates.io version requirement" + ) + elif not _is_bounded_three_component_requirement(version): + violations.append( + f"{prefix}: reqwest version must use one bounded three-component Cargo " + f"requirement such as 0.13.5; found {version!r}" + ) + elif not _is_reviewed_reqwest_version(version): + violations.append( + f"{prefix}: reqwest {version} is outside the reviewed reqwest range " + ">=0.13.5,<0.14.0; a downgrade or SemVer-line change requires a new " + "Distribution owner decision and evidence" + ) + + if reqwest.get("default-features") is not False: + violations.append( + f"{prefix}: reqwest default features must be disabled so TLS/backend " + "features are never selected implicitly" + ) + features = reqwest.get("features", []) + valid_feature_list = isinstance(features, list) and all( + isinstance(feature, str) for feature in features + ) + if not valid_feature_list or "rustls" not in features: + violations.append(f"{prefix}: reqwest must explicitly enable the rustls feature") + if valid_feature_list: + unapproved = sorted(set(features).difference(REQWEST_APPROVED_FEATURES)) + if unapproved: + violations.append( + f"{prefix}: reqwest must use only the approved Distribution feature set " + f"(rustls); unapproved features: {', '.join(unapproved)}" + ) + return violations + + +def _validate_crates_io_lock_source( + *, + package_name: str, + package: dict[str, Any], +) -> str | None: + """Return a violation when a security-owned package is not crates.io-backed.""" + source = package.get("source") + if source == CRATES_IO_LOCK_SOURCE: + return None + return ( + f"{DISTRIBUTION_TRANSPORT_LOCK}: {package_name} source must be canonical " + f"crates.io registry; found {source!r}" + ) + + +def _validate_lock_graph_sources(packages: Any) -> list[str]: + """Require every external package in the standalone HTTP graph to be crates.io-backed.""" + if not isinstance(packages, list): + return [f"{DISTRIBUTION_TRANSPORT_LOCK}: package graph must be a TOML array"] + + violations: list[str] = [] + for package in packages: + if not isinstance(package, dict): + violations.append( + f"{DISTRIBUTION_TRANSPORT_LOCK}: package graph contains a non-table entry" + ) + continue + package_name = package.get("name") + if not isinstance(package_name, str) or not package_name: + violations.append( + f"{DISTRIBUTION_TRANSPORT_LOCK}: package graph contains an invalid package name" + ) + continue + if package_name in LOCAL_DISTRIBUTION_LOCK_PACKAGES: + source = package.get("source") + if source is not None: + violations.append( + f"{DISTRIBUTION_TRANSPORT_LOCK}: local package {package_name} must remain " + f"path-owned without a registry/git source; found {source!r}" + ) + continue + source_violation = _validate_crates_io_lock_source( + package_name=package_name, + package=package, + ) + if source_violation: + violations.append(source_violation) + return violations + + +def verify_distribution_http_dependency_admission(repo_root: Path) -> list[str]: + """Verify direct reqwest admission before the production Distribution client compiles.""" + manifest_path = repo_root / DISTRIBUTION_TRANSPORT_MANIFEST + lock_path = repo_root / DISTRIBUTION_TRANSPORT_LOCK + manifest = tomllib.loads(manifest_path.read_text(encoding="utf-8")) + declarations = _direct_reqwest_declarations(manifest) + if not declarations: + return [] + + violations: list[str] = [] + for location, reqwest in declarations: + violations.extend(_validate_reqwest_declaration(location, reqwest)) + violations.extend(_validate_reqwest_feature_forwarding(manifest)) + + if not lock_path.exists(): + violations.append( + f"{DISTRIBUTION_TRANSPORT_LOCK}: direct reqwest requires a committed " + "standalone lockfile" + ) + return violations + + lock = tomllib.loads(lock_path.read_text(encoding="utf-8")) + packages = lock.get("package", []) + violations.extend(_validate_lock_graph_sources(packages)) + if not isinstance(packages, list): + return violations + + reqwest_packages = [ + package + for package in packages + if isinstance(package, dict) and package.get("name") == "reqwest" + ] + if not reqwest_packages: + violations.append( + f"{DISTRIBUTION_TRANSPORT_LOCK}: direct reqwest is missing from the " + "committed lock graph" + ) + for package in reqwest_packages: + version = str(package.get("version", "")) + if _version_triplet(version) is None: + violations.append( + f"{DISTRIBUTION_TRANSPORT_LOCK}: cannot parse reqwest version {version!r}" + ) + elif not _is_reviewed_reqwest_version(version): + violations.append( + f"{DISTRIBUTION_TRANSPORT_LOCK}: resolved reqwest {version} is outside " + "the reviewed stable reqwest range >=0.13.5,<0.14.0; refresh only " + "within the reviewed stable line or record a new Distribution owner decision" + ) + + rustls_packages = [ + package + for package in packages + if isinstance(package, dict) and package.get("name") == "rustls" + ] + if not rustls_packages: + violations.append( + f"{DISTRIBUTION_TRANSPORT_LOCK}: reqwest rustls backend is selected but " + "rustls is absent" + ) + return violations + + for package in rustls_packages: + version = str(package.get("version", "")) + if _version_triplet(version) is None: + violations.append( + f"{DISTRIBUTION_TRANSPORT_LOCK}: cannot parse rustls version {version!r}" + ) + elif _is_affected_rustls(version): + violations.append( + f"{DISTRIBUTION_TRANSPORT_LOCK}: rustls {version} is affected by " + f"{RUSTLS_ENCRYPTION_LEVEL_ADVISORY}; use the stable patched boundary " + "rustls >=0.23.45 or another unaffected line" + ) + return violations + + +def main() -> int: + """Run the repository-root Distribution dependency admission gate.""" + repo_root = Path(__file__).resolve().parents[2] + violations = verify_distribution_http_dependency_admission(repo_root) + if violations: + for violation in violations: + print(f"ERROR: {violation}", file=sys.stderr) + return 1 + print("Distribution HTTP dependency admission: PASS") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/checks/verify_release_identity.py b/scripts/checks/verify_release_identity.py new file mode 100644 index 000000000..8ba6f159a --- /dev/null +++ b/scripts/checks/verify_release_identity.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +"""Fail closed when BandScope release identity or release admission projections disagree. + +Security Notes: +- ``repository_root`` is an already-selected repository boundary. Version identity + reads only the fixed ``VERSION``, ``package.json``, and Tauri configuration. +- VERSION and JSON projections are read twice from the same bounded regular + non-link file descriptor; both byte snapshots plus descriptor identity/size + must remain stable, and the repository path is revalidated against that same + descriptor after the stable read before JSON/version values are trusted. +- JSON duplicate members and non-standard numeric constants are rejected before + any version value is compared. +- The CLI composes the sibling Distribution model-policy and updater-policy guards. + Normal branch/PR checks validate both policies; version-tag checks additionally + require exact commercially admitted model and updater release authority before + any platform build can start. +- VERSION and JSON fields are validated as exact, non-empty, trimmed strings + before comparison; malformed text or JSON fails closed without echoing values. +- Stable release versions use the same canonical numeric MAJOR.MINOR.PATCH grammar + and unsigned-64-bit component range as the native Distribution/update policy + core. Prerelease/build forms, leading zeros, and numeric overflow therefore + cannot enter packaging and later become updater metadata the runtime rejects. +- These guards have no network, filesystem-write, update, credential, signing, + or publication authority. They only return verified release inputs or failure. +""" + +from __future__ import annotations + +import importlib.util +import json +import os +import re +import stat +import sys +from pathlib import Path +from types import ModuleType +from typing import Any, Callable + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +_STABLE_VERSION_RE = re.compile( + r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$" +) +_U64_MAX_DECIMAL = "18446744073709551615" +_MAX_VERSION_BYTES = 128 +_MAX_RELEASE_METADATA_BYTES = 256 * 1024 + + +def _is_u64_decimal(component: str) -> bool: + """Return whether one canonical decimal component fits Rust ``u64``.""" + if len(component) < len(_U64_MAX_DECIMAL): + return True + if len(component) > len(_U64_MAX_DECIMAL): + return False + return component <= _U64_MAX_DECIMAL + + +def _is_canonical_stable_version(value: str) -> bool: + """Match the native ``StableVersion`` grammar and numeric range exactly.""" + match = _STABLE_VERSION_RE.fullmatch(value) + return match is not None and all( + _is_u64_decimal(component) for component in match.groups() + ) + + +def _reject_duplicate_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + """Build a JSON object while rejecting parser-dependent duplicate members.""" + document: dict[str, Any] = {} + for key, value in pairs: + if key in document: + raise ValueError(f"duplicate JSON member in release metadata: {key}") + document[key] = value + return document + + +def _reject_nonstandard_json_constant(value: str) -> None: + """Reject Python's non-standard NaN/Infinity JSON extensions.""" + raise json.JSONDecodeError("non-standard JSON constant", value, 0) + + +def _read_bounded_regular_text( + path: Path, *, maximum_bytes: int, label: str +) -> str: + """Read one bounded regular non-link file from one stable descriptor.""" + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags) + except OSError as read_error: + raise ValueError(f"{label} must be a regular non-link file") from read_error + try: + before = os.fstat(descriptor) + if not stat.S_ISREG(before.st_mode): + raise ValueError(f"{label} must be a regular non-link file") + try: + path_identity = os.lstat(path) + except OSError as identity_error: + raise ValueError(f"{label} changed while being opened") from identity_error + if ( + stat.S_ISLNK(path_identity.st_mode) + or not stat.S_ISREG(path_identity.st_mode) + or (path_identity.st_dev, path_identity.st_ino) + != (before.st_dev, before.st_ino) + ): + raise ValueError(f"{label} must be a regular non-link file") + if before.st_size < 1 or before.st_size > maximum_bytes: + raise ValueError(f"{label} exceeds its bounded size policy") + + def read_snapshot() -> bytes: + chunks: list[bytes] = [] + remaining = maximum_bytes + 1 + while remaining > 0: + chunk = os.read(descriptor, min(64 * 1024, remaining)) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + payload = b"".join(chunks) + if len(payload) > maximum_bytes: + raise ValueError(f"{label} exceeds its bounded size policy") + return payload + + payload = read_snapshot() + after_first = os.fstat(descriptor) + if ( + (before.st_dev, before.st_ino, before.st_size) + != (after_first.st_dev, after_first.st_ino, after_first.st_size) + or len(payload) != before.st_size + ): + raise ValueError(f"{label} changed while being read") + + try: + os.lseek(descriptor, 0, os.SEEK_SET) + except OSError as seek_error: + raise ValueError(f"{label} changed while being read") from seek_error + verification_payload = read_snapshot() + after_second = os.fstat(descriptor) + if ( + (before.st_dev, before.st_ino, before.st_size) + != (after_second.st_dev, after_second.st_ino, after_second.st_size) + or len(verification_payload) != before.st_size + or verification_payload != payload + ): + raise ValueError(f"{label} changed while being read") + + try: + final_path_identity = os.lstat(path) + except OSError as identity_error: + raise ValueError(f"{label} changed while being read") from identity_error + if ( + stat.S_ISLNK(final_path_identity.st_mode) + or not stat.S_ISREG(final_path_identity.st_mode) + or (final_path_identity.st_dev, final_path_identity.st_ino) + != (after_second.st_dev, after_second.st_ino) + ): + raise ValueError(f"{label} changed while being read") + + try: + return payload.decode("utf-8") + except UnicodeError as decode_error: + raise ValueError(f"{label} is not valid UTF-8") from decode_error + finally: + os.close(descriptor) + + +def _read_json_object(metadata_path: Path) -> dict[str, Any]: + """Read one bounded release metadata document and require a JSON object root.""" + raw_text = _read_bounded_regular_text( + metadata_path, + maximum_bytes=_MAX_RELEASE_METADATA_BYTES, + label=metadata_path.name, + ) + try: + metadata_document = json.loads( + raw_text, + object_pairs_hook=_reject_duplicate_pairs, + parse_constant=_reject_nonstandard_json_constant, + ) + except json.JSONDecodeError as metadata_error: + raise ValueError( + f"could not read release metadata: {metadata_path.name}" + ) from metadata_error + if not isinstance(metadata_document, dict): + raise ValueError(f"release metadata must be an object: {metadata_path.name}") + return metadata_document + + +def _required_string( + metadata_document: dict[str, Any], field_name: str, source_name: str +) -> str: + """Return a non-empty string field without coercing malformed metadata.""" + field_value = metadata_document.get(field_name) + if ( + not isinstance(field_value, str) + or not field_value.strip() + or field_value != field_value.strip() + ): + raise ValueError( + f"{source_name} {field_name} must be a non-empty trimmed string" + ) + return field_value + + +def _load_policy_module(filename: str, module_name: str, label: str) -> ModuleType: + """Load one adjacent Distribution policy guard without creating another owner.""" + guard_path = Path(__file__).with_name(filename) + guard_spec = importlib.util.spec_from_file_location(module_name, guard_path) + if guard_spec is None or guard_spec.loader is None: + raise ValueError(f"could not load {label}") + guard_module = importlib.util.module_from_spec(guard_spec) + try: + guard_spec.loader.exec_module(guard_module) + except (ImportError, OSError, SyntaxError) as load_error: + raise ValueError(f"could not load {label}") from load_error + return guard_module + + +def _model_policy_verifier() -> Callable[..., dict[str, Any]]: + """Return the sibling model-policy verifier and reject an incomplete module.""" + guard_module = _load_policy_module( + "verify_release_model_policy.py", + "bandscope_verify_release_model_policy", + "release model policy guard", + ) + verifier = getattr(guard_module, "verify_model_policy", None) + if not callable(verifier): + raise ValueError("release model policy guard lacks verify_model_policy") + return verifier + + +def _updater_policy_verifier() -> Callable[..., dict[str, Any]]: + """Return the sibling updater-policy verifier and reject an incomplete module.""" + guard_module = _load_policy_module( + "verify_release_updater_policy.py", + "bandscope_verify_release_updater_policy", + "release updater policy guard", + ) + verifier = getattr(guard_module, "verify_updater_policy", None) + if not callable(verifier): + raise ValueError("release updater policy guard lacks verify_updater_policy") + return verifier + + +def verify_release_identity( + repository_root: Path, release_tag: str | None = None +) -> str: + """Verify package, Tauri, and optional tag versions against ``VERSION``.""" + version_text = _read_bounded_regular_text( + repository_root / "VERSION", + maximum_bytes=_MAX_VERSION_BYTES, + label="VERSION", + ) + + version_lines = version_text.splitlines() + if ( + len(version_lines) != 1 + or not version_lines[0] + or version_lines[0] != version_lines[0].strip() + or version_text != f"{version_lines[0]}\n" + ): + raise ValueError("VERSION must contain exactly one non-empty version line") + release_version = version_lines[0] + if not _is_canonical_stable_version(release_version): + raise ValueError("VERSION must be canonical stable MAJOR.MINOR.PATCH") + + package_document = _read_json_object(repository_root / "package.json") + tauri_document = _read_json_object( + repository_root / "apps" / "desktop" / "src-tauri" / "tauri.conf.json" + ) + + package_version = _required_string( + package_document, "version", "package.json" + ) + tauri_version = _required_string( + tauri_document, "version", "tauri.conf.json" + ) + if package_version != release_version: + raise ValueError("package.json version does not match VERSION") + if tauri_version != release_version: + raise ValueError("tauri.conf.json version does not match VERSION") + + if release_tag is not None and release_tag != f"v{release_version}": + raise ValueError("release tag does not match VERSION") + + return release_version + + +def main() -> int: + """Run version, model-admission, and updater-admission release gates.""" + release_tag = ( + os.environ.get("GITHUB_REF_NAME") + if os.environ.get("GITHUB_REF_TYPE") == "tag" + else None + ) + try: + release_version = verify_release_identity( + _REPOSITORY_ROOT, release_tag=release_tag + ) + verify_model_policy = _model_policy_verifier() + verify_model_policy( + _REPOSITORY_ROOT, + require_admitted=release_tag is not None, + ) + verify_updater_policy = _updater_policy_verifier() + verify_updater_policy( + _REPOSITORY_ROOT, + require_admitted=release_tag is not None, + ) + except ValueError as identity_error: + print(f"release preflight check failed: {identity_error}", file=sys.stderr) + return 1 + print(f"BandScope release preflight verified: v{release_version}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/checks/verify_release_model_policy.py b/scripts/checks/verify_release_model_policy.py new file mode 100644 index 000000000..92bc5cc2e --- /dev/null +++ b/scripts/checks/verify_release_model_policy.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 +"""Validate Distribution-owned commercial model release admission. + +The release policy is deliberately separate from Signal/MIR runtime model selection. +It answers whether a specific immutable model artifact may enter a BandScope release; +it does not claim scientific accuracy or create commercial rights. + +Security Notes: +- Model policy, model bytes, release evidence, and supplemental inventory are untrusted + local inputs. +- Commercial admission uses fixed repository-relative evidence/inventory locations + rather than policy-controlled authority paths, preventing path traversal or aliasing. +- Model/evidence files are opened read-only with no-follow semantics where available, + must remain regular files, and are hashed from the same descriptor that is sized. +- Policy/inventory JSON is size-bounded and duplicate-member rejecting. +- No network lookup, credential access, deserialization, model execution, or write is + performed by this verifier. Missing, ambiguous, or drifting evidence fails closed. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import re +import stat +import sys +from typing import Any + +_POLICY_RELATIVE_PATH = Path("release/model-artifact-policy.json") +_INVENTORY_RELATIVE_PATH = Path("supply-chain/supplemental-component-inventory.json") +_MAX_POLICY_BYTES = 64 * 1024 +_MAX_INVENTORY_BYTES = 256 * 1024 +_MAX_EVIDENCE_BYTES = 4 * 1024 * 1024 +_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") +_ALLOWED_RELEASE_STATUSES = frozenset({"blocked", "admitted"}) +_ALLOWED_SERIALIZATIONS = frozenset({"safetensors", "onnx", "pytorch-demucs-trusted"}) +_POLICY_KEYS = frozenset( + {"schemaVersion", "releaseStatus", "blockedArtifact", "admittedArtifact"} +) +_BLOCKED_ARTIFACT_KEYS = frozenset( + {"modelId", "checkpoint", "reason", "primaryEvidence"} +) +_ADMITTED_ARTIFACT_KEYS = frozenset( + { + "modelId", + "modelVersion", + "path", + "sizeBytes", + "sha256", + "serialization", + "rightsEvidenceSha256", + "provenanceEvidenceSha256", + "loaderPolicySha256", + } +) +_EVIDENCE_FILES = ( + ("rightsEvidenceSha256", Path("release/evidence/model-rights.txt"), "rights"), + ( + "provenanceEvidenceSha256", + Path("release/evidence/model-provenance.json"), + "provenance", + ), + ( + "loaderPolicySha256", + Path("release/evidence/model-loader-policy.json"), + "loader policy", + ), +) + + +def _reject_duplicate_members(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + """Build a JSON object while rejecting last-value-wins authority ambiguity.""" + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate JSON member: {key}") + result[key] = value + return result + + +def _read_bounded_json_document( + path: Path, + *, + label: str, + maximum_bytes: int, + missing_message: str, +) -> dict[str, Any]: + """Read one bounded regular non-link JSON object with duplicate rejection.""" + if path.is_symlink(): + raise ValueError(f"{label} must be a regular non-link file") + try: + metadata = path.stat() + except FileNotFoundError as error: + raise ValueError(missing_message) from error + if not stat.S_ISREG(metadata.st_mode): + raise ValueError(f"{label} must be a regular non-link file") + if metadata.st_size <= 0 or metadata.st_size > maximum_bytes: + raise ValueError(f"{label} exceeds its bounded size") + + with path.open("rb") as source_file: + payload = source_file.read(maximum_bytes + 1) + if len(payload) != metadata.st_size or len(payload) > maximum_bytes: + raise ValueError(f"{label} changed while being read") + try: + decoded = payload.decode("utf-8") + except UnicodeDecodeError as error: + raise ValueError(f"{label} must be UTF-8") from error + try: + document = json.loads(decoded, object_pairs_hook=_reject_duplicate_members) + except json.JSONDecodeError as error: + raise ValueError(f"{label} must be valid JSON") from error + if not isinstance(document, dict): + raise ValueError(f"{label} root must be an object") + return document + + +def _read_bounded_json(path: Path) -> dict[str, Any]: + """Read one small model policy document through the generic JSON admission boundary.""" + return _read_bounded_json_document( + path, + label="model release policy", + maximum_bytes=_MAX_POLICY_BYTES, + missing_message="model release policy is missing", + ) + + +def _read_inventory(repository_root: Path) -> dict[str, Any]: + """Read the fixed supplemental component inventory required by admitted models.""" + return _read_bounded_json_document( + repository_root / _INVENTORY_RELATIVE_PATH, + label="supplemental model inventory", + maximum_bytes=_MAX_INVENTORY_BYTES, + missing_message="supplemental model inventory is missing", + ) + + +def _require_exact_keys(document: dict[str, Any], expected: frozenset[str], label: str) -> None: + """Reject missing or unknown authority fields at a release trust boundary.""" + actual = frozenset(document) + missing = sorted(expected - actual) + unexpected = sorted(actual - expected) + if missing: + raise ValueError(f"missing {label} fields: {', '.join(missing)}") + if unexpected: + raise ValueError(f"unexpected {label} fields: {', '.join(unexpected)}") + + +def _bounded_text(value: Any, label: str, *, maximum: int = 512) -> str: + """Admit one bounded single-line non-blank metadata string.""" + if not isinstance(value, str): + raise ValueError(f"{label} must be a string") + if value != value.strip() or not value or len(value) > maximum: + raise ValueError(f"{label} must be bounded non-blank text without padding") + if "\n" in value or "\r" in value or "\x00" in value: + raise ValueError(f"{label} must be a single-line text value") + return value + + +def _full_sha256(value: Any, label: str) -> str: + """Admit one lowercase full SHA-256 digest rather than a checksum prefix.""" + digest = _bounded_text(value, label, maximum=64) + if _SHA256_PATTERN.fullmatch(digest) is None: + raise ValueError(f"{label} must be a full SHA-256") + return digest + + +def _repository_relative_path(value: Any) -> PurePosixPath: + """Admit one normalized repository-relative artifact path without traversal.""" + path_text = _bounded_text(value, "model artifact path", maximum=512) + if "\\" in path_text: + raise ValueError("model artifact path must be repository-relative") + candidate = PurePosixPath(path_text) + if candidate.is_absolute() or any(part in {"", ".", ".."} for part in candidate.parts): + raise ValueError("model artifact path must be repository-relative") + if candidate.as_posix() != path_text: + raise ValueError("model artifact path must be repository-relative") + return candidate + + +def _validate_blocked_artifact(value: Any) -> dict[str, Any]: + """Validate the immutable description of the currently prohibited upstream artifact.""" + if not isinstance(value, dict): + raise ValueError("blockedArtifact must be an object") + _require_exact_keys(value, _BLOCKED_ARTIFACT_KEYS, "blockedArtifact") + _bounded_text(value["modelId"], "blockedArtifact.modelId", maximum=128) + _bounded_text(value["checkpoint"], "blockedArtifact.checkpoint", maximum=256) + _bounded_text(value["reason"], "blockedArtifact.reason", maximum=256) + evidence = _bounded_text( + value["primaryEvidence"], "blockedArtifact.primaryEvidence", maximum=1024 + ) + if not evidence.startswith("https://"): + raise ValueError("blockedArtifact.primaryEvidence must use HTTPS") + return value + + +def _validate_admitted_metadata(value: Any) -> dict[str, Any]: + """Validate immutable metadata required before model bytes can be release authority.""" + if not isinstance(value, dict): + raise ValueError("admittedArtifact must be an object") + _require_exact_keys(value, _ADMITTED_ARTIFACT_KEYS, "admittedArtifact") + _bounded_text(value["modelId"], "admittedArtifact.modelId", maximum=128) + _bounded_text(value["modelVersion"], "admittedArtifact.modelVersion", maximum=128) + _repository_relative_path(value["path"]) + size_bytes = value["sizeBytes"] + if isinstance(size_bytes, bool) or not isinstance(size_bytes, int) or size_bytes <= 0: + raise ValueError("admittedArtifact.sizeBytes must be a positive integer") + _full_sha256(value["sha256"], "sha256") + serialization = _bounded_text( + value["serialization"], "admittedArtifact.serialization", maximum=64 + ) + if serialization not in _ALLOWED_SERIALIZATIONS: + raise ValueError("admittedArtifact.serialization is not an admitted release format") + _full_sha256(value["rightsEvidenceSha256"], "rightsEvidenceSha256") + _full_sha256(value["provenanceEvidenceSha256"], "provenanceEvidenceSha256") + _full_sha256(value["loaderPolicySha256"], "loaderPolicySha256") + return value + + +def _verify_regular_file_digest( + path: Path, + *, + expected_digest: str, + label: str, + maximum_bytes: int | None = None, + expected_size: int | None = None, +) -> None: + """Verify immutable regular bytes from one descriptor without path re-resolution.""" + if path.is_symlink(): + raise ValueError(f"{label} must be a regular non-link file") + + open_flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + try: + file_descriptor = os.open(path, open_flags) + except FileNotFoundError as error: + raise ValueError(f"{label} is missing") from error + except OSError as error: + raise ValueError(f"{label} must be a regular non-link file") from error + + try: + initial_metadata = os.fstat(file_descriptor) + if not stat.S_ISREG(initial_metadata.st_mode): + raise ValueError(f"{label} must be a regular non-link file") + if initial_metadata.st_size <= 0: + raise ValueError(f"{label} must not be empty") + if expected_size is not None and initial_metadata.st_size != expected_size: + raise ValueError(f"{label} size does not match policy") + if maximum_bytes is not None and initial_metadata.st_size > maximum_bytes: + raise ValueError(f"{label} exceeds its bounded size") + + digest = hashlib.sha256() + observed_size = 0 + while True: + chunk = os.read(file_descriptor, 1024 * 1024) + if not chunk: + break + observed_size += len(chunk) + if expected_size is not None and observed_size > expected_size: + raise ValueError(f"{label} size does not match policy") + if maximum_bytes is not None and observed_size > maximum_bytes: + raise ValueError(f"{label} exceeds its bounded size") + digest.update(chunk) + + final_metadata = os.fstat(file_descriptor) + if ( + final_metadata.st_size != initial_metadata.st_size + or observed_size != initial_metadata.st_size + ): + raise ValueError(f"{label} changed while being read") + if digest.hexdigest() != expected_digest: + raise ValueError(f"{label} SHA-256 does not match policy") + finally: + os.close(file_descriptor) + + +def _verify_artifact_bytes(repository_root: Path, metadata: dict[str, Any]) -> None: + """Verify exact regular model bytes against immutable size and full-digest metadata.""" + relative_path = _repository_relative_path(metadata["path"]) + artifact_path = repository_root.joinpath(*relative_path.parts) + _verify_regular_file_digest( + artifact_path, + expected_digest=metadata["sha256"], + expected_size=metadata["sizeBytes"], + label="model artifact", + ) + + +def _verify_evidence_bytes(repository_root: Path, metadata: dict[str, Any]) -> None: + """Bind policy evidence digests to exact repository evidence bytes.""" + for digest_field, relative_path, evidence_label in _EVIDENCE_FILES: + _verify_regular_file_digest( + repository_root / relative_path, + expected_digest=metadata[digest_field], + maximum_bytes=_MAX_EVIDENCE_BYTES, + label=f"{evidence_label} evidence", + ) + + +def _verify_inventory_binding(repository_root: Path, metadata: dict[str, Any]) -> None: + """Bind an admitted model to exactly one matching supplemental inventory entry.""" + inventory = _read_inventory(repository_root) + model_artifacts = inventory.get("modelArtifacts") + if not isinstance(model_artifacts, list): + raise ValueError("supplemental model inventory modelArtifacts must be a list") + + matching_entries: list[dict[str, Any]] = [] + for entry in model_artifacts: + if not isinstance(entry, dict): + raise ValueError("supplemental model inventory entries must be objects") + if entry.get("name") == metadata["modelId"]: + matching_entries.append(entry) + if len(matching_entries) != 1: + raise ValueError("supplemental model inventory must contain exactly one admitted model") + + entry = matching_entries[0] + expected_checksum = f"sha256:{metadata['sha256']}" + if ( + entry.get("version") != metadata["modelVersion"] + or entry.get("storagePath") != metadata["path"] + or entry.get("checksum") != expected_checksum + ): + raise ValueError("supplemental model inventory does not match admitted artifact") + _bounded_text(entry.get("license"), "supplemental model inventory license", maximum=256) + _bounded_text( + entry.get("releaseUsage"), + "supplemental model inventory releaseUsage", + maximum=1024, + ) + + +def verify_model_policy( + repository_root: Path, *, require_admitted: bool = False +) -> dict[str, Any]: + """Validate model policy and exact artifact/evidence/inventory for admitted releases.""" + document = _read_bounded_json(repository_root / _POLICY_RELATIVE_PATH) + _require_exact_keys(document, _POLICY_KEYS, "policy") + if document["schemaVersion"] != 1: + raise ValueError("model release policy schemaVersion must be 1") + + release_status = document["releaseStatus"] + if release_status not in _ALLOWED_RELEASE_STATUSES: + raise ValueError("model release policy releaseStatus is unsupported") + _validate_blocked_artifact(document["blockedArtifact"]) + + admitted_artifact = document["admittedArtifact"] + if release_status == "blocked": + if admitted_artifact is not None: + raise ValueError("blocked model policy must not name an admittedArtifact") + if require_admitted: + raise ValueError("commercial model artifact is not admitted") + return document + + admitted_metadata = _validate_admitted_metadata(admitted_artifact) + _verify_artifact_bytes(repository_root, admitted_metadata) + _verify_evidence_bytes(repository_root, admitted_metadata) + _verify_inventory_binding(repository_root, admitted_metadata) + return document + + +def main(argv: list[str] | None = None) -> int: + """Run the repository release model policy guard as a fail-closed CLI.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--repository-root", + type=Path, + default=Path(__file__).resolve().parents[2], + help="Repository root containing release/model-artifact-policy.json.", + ) + parser.add_argument( + "--require-admitted", + action="store_true", + help="Require and byte-verify a commercially admitted model artifact.", + ) + arguments = parser.parse_args(argv) + try: + policy = verify_model_policy( + arguments.repository_root, require_admitted=arguments.require_admitted + ) + except ValueError as error: + print(f"Release model policy invalid: {error}", file=sys.stderr) + return 1 + print(f"Release model policy: {policy['releaseStatus']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/checks/verify_release_platform_trust.py b/scripts/checks/verify_release_platform_trust.py new file mode 100644 index 000000000..6d6d09cb9 --- /dev/null +++ b/scripts/checks/verify_release_platform_trust.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Verify platform-native trust on BandScope release artifacts before publication. + +Security Notes: + This verifier has read/execute authority only over repository-built release outputs and + fixed platform trust tools. Artifact paths are passed as subprocess arguments rather than + interpolated into shell text. Publisher identity comes from repository configuration, is + bounded, and is compared exactly. Command output is parsed only for the minimum signature + status/team fields and is never promoted into a filesystem path or command. Any missing + artifact, missing identity, malformed trust output, unsigned artifact, unexpected signer, + failed Gatekeeper assessment, or missing notarization ticket fails closed. +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from collections.abc import Callable, Sequence +from pathlib import Path +from typing import Any + +CommandRunner = Callable[..., Any] +_WINDOWS_SUFFIXES = {".exe", ".msi"} +_APPLE_TEAM_ID_PATTERN = re.compile(r"^[A-Z0-9]{10}$") +_WINDOWS_SIGNATURE_SCRIPT = r""" +$signature = Get-AuthenticodeSignature -LiteralPath $args[0] +$subject = $null +if ($null -ne $signature.SignerCertificate) { + $subject = $signature.SignerCertificate.Subject +} +[pscustomobject]@{ + Status = [string]$signature.Status + Subject = $subject +} | ConvertTo-Json -Compress +""".strip() + + +def _configured_identity(label: str, value: str, *, max_length: int) -> str: + """Return a bounded single-line configured signer identity or fail closed.""" + if not value or value != value.strip() or len(value) > max_length: + raise ValueError(f"{label} must be configured exactly for release verification") + if any(character in value for character in "\r\n\x00"): + raise ValueError(f"{label} must be configured exactly for release verification") + return value + + +def _regular_files(root: Path, suffixes: set[str], missing_label: str) -> list[Path]: + """Return direct regular non-link release files with one of the allowed suffixes.""" + if not root.is_dir() or root.is_symlink(): + raise ValueError(f"{missing_label} directory is unavailable") + matches: list[Path] = [] + for candidate in sorted(root.iterdir()): + if candidate.suffix.lower() not in suffixes: + continue + if candidate.is_symlink() or not candidate.is_file(): + raise ValueError(f"{missing_label} must be a regular non-link file") + matches.append(candidate) + if not matches: + raise ValueError(f"no {missing_label} was produced") + return matches + + +def _application_bundles(bundle_root: Path) -> list[Path]: + """Return direct regular macOS application bundles from the Tauri bundle directory.""" + if not bundle_root.is_dir() or bundle_root.is_symlink(): + raise ValueError("macOS application bundle directory is unavailable") + applications: list[Path] = [] + for candidate in sorted(bundle_root.glob("*.app")): + if candidate.is_symlink() or not candidate.is_dir(): + raise ValueError("macOS application bundle must be a regular non-link directory") + applications.append(candidate) + if not applications: + raise ValueError("no macOS application bundle was produced") + return applications + + +def _run_command( + command: Sequence[str], + *, + runner: CommandRunner, + failure_message: str, +) -> Any: + """Run one fixed trust command and translate any nonzero result into a bounded error.""" + try: + result = runner( + list(command), + capture_output=True, + text=True, + check=False, + ) + except OSError as command_error: + raise ValueError(failure_message) from command_error + if result.returncode != 0: + raise ValueError(failure_message) + return result + + +def verify_windows_artifacts( + artifact_root: Path, + expected_publisher_subject: str, + *, + runner: CommandRunner = subprocess.run, +) -> list[Path]: + """Verify Authenticode validity and the exact approved publisher for Windows installers.""" + expected_subject = _configured_identity( + "Windows publisher subject", expected_publisher_subject, max_length=512 + ) + installers = _regular_files( + artifact_root, _WINDOWS_SUFFIXES, "Windows release installer" + ) + for installer in installers: + result = _run_command( + [ + "pwsh", + "-NoProfile", + "-NonInteractive", + "-Command", + _WINDOWS_SIGNATURE_SCRIPT, + str(installer), + ], + runner=runner, + failure_message="Windows release installer does not have a valid Authenticode signature", + ) + try: + signature = json.loads(result.stdout) + except (json.JSONDecodeError, TypeError) as output_error: + raise ValueError( + "Windows release installer does not have a valid Authenticode signature" + ) from output_error + if not isinstance(signature, dict) or signature.get("Status") != "Valid": + raise ValueError( + "Windows release installer does not have a valid Authenticode signature" + ) + if signature.get("Subject") != expected_subject: + raise ValueError("Windows release installer is not signed by the approved Windows publisher") + return installers + + +def _macos_team_identifier(details: str) -> str | None: + """Extract the exact TeamIdentifier line from codesign display output.""" + for line in details.splitlines(): + if line.startswith("TeamIdentifier="): + return line.removeprefix("TeamIdentifier=") + return None + + +def verify_macos_artifacts( + artifact_root: Path, + bundle_root: Path, + expected_team_id: str, + *, + runner: CommandRunner = subprocess.run, +) -> tuple[list[Path], list[Path]]: + """Verify signed app bundles and stapled, Gatekeeper-accepted macOS disk images.""" + team_id = _configured_identity("Apple Team ID", expected_team_id, max_length=10) + if _APPLE_TEAM_ID_PATTERN.fullmatch(team_id) is None: + raise ValueError("Apple Team ID must be configured exactly for release verification") + + applications = _application_bundles(bundle_root) + disk_images = _regular_files(artifact_root, {".dmg"}, "macOS release disk image") + + for application in applications: + _run_command( + ["codesign", "--verify", "--deep", "--strict", str(application)], + runner=runner, + failure_message="macOS application bundle does not have a valid code signature", + ) + details = _run_command( + ["codesign", "--display", "--verbose=4", str(application)], + runner=runner, + failure_message="macOS application bundle signing identity could not be verified", + ) + signer_details = f"{details.stdout}\n{details.stderr}" + if _macos_team_identifier(signer_details) != team_id: + raise ValueError("macOS application bundle is not signed by the approved Apple Team ID") + + for disk_image in disk_images: + _run_command( + ["xcrun", "stapler", "validate", str(disk_image)], + runner=runner, + failure_message="macOS release disk image does not contain a valid notarization ticket", + ) + _run_command( + [ + "spctl", + "--assess", + "--type", + "open", + "--context", + "context:primary-signature", + "--verbose=2", + str(disk_image), + ], + runner=runner, + failure_message="macOS release disk image is not accepted by Gatekeeper", + ) + return applications, disk_images + + +def _parser() -> argparse.ArgumentParser: + """Build the command-line contract used by release jobs.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("platform", choices=("windows", "macos")) + parser.add_argument("artifact_root", type=Path) + parser.add_argument("--bundle-root", type=Path) + parser.add_argument("--expected-identity", required=True) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + """Verify one platform's release outputs and return a fail-closed process status.""" + arguments = _parser().parse_args(argv) + try: + if arguments.platform == "windows": + verified = verify_windows_artifacts( + arguments.artifact_root, arguments.expected_identity + ) + print(f"Verified {len(verified)} Windows release installer(s).") + return 0 + if arguments.bundle_root is None: + raise ValueError("macOS release verification requires --bundle-root") + applications, disk_images = verify_macos_artifacts( + arguments.artifact_root, + arguments.bundle_root, + arguments.expected_identity, + ) + print( + "Verified " + f"{len(applications)} macOS application bundle(s) and " + f"{len(disk_images)} notarized disk image(s)." + ) + return 0 + except ValueError as verification_error: + print(f"Release platform trust verification failed: {verification_error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/checks/verify_release_updater_policy.py b/scripts/checks/verify_release_updater_policy.py new file mode 100644 index 000000000..10e944d96 --- /dev/null +++ b/scripts/checks/verify_release_updater_policy.py @@ -0,0 +1,482 @@ +#!/usr/bin/env python3 +"""Verify BandScope's fail-closed commercial updater release policy. + +Security Notes: +- updater authority is read only from fixed repository-relative policy, Tauri + configuration, Cargo manifest/lock, and desktop runtime source paths; callers + cannot supply alternate files or remote URLs; +- JSON/TOML/source inputs are bounded regular non-link files whose opened + descriptor identity must remain stable while read; JSON rejects duplicates; +- an admitted updater requires Tauri v2 updater artifacts, an exact embedded + public verification key, exact HTTPS endpoints, a locked registry updater + plugin dependency, and an executable desktop runtime initializer; +- a blocked policy must keep updater artifact generation/plugin configuration + disabled, and a tag/release caller may require admission explicitly; +- this guard never reads private signing keys, downloads updates, signs bytes, + installs software, or decides organization signing-key ownership. +""" + +from __future__ import annotations + +import json +import os +import re +import stat +import sys +import tomllib +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +_POLICY_PATH = Path("release/updater-policy.json") +_TAURI_CONFIG_PATH = Path("apps/desktop/src-tauri/tauri.conf.json") +_TAURI_CARGO_MANIFEST_PATH = Path("apps/desktop/src-tauri/Cargo.toml") +_TAURI_CARGO_LOCK_PATH = Path("apps/desktop/src-tauri/Cargo.lock") +_TAURI_MAIN_PATH = Path("apps/desktop/src-tauri/src/main.rs") +_MAX_POLICY_BYTES = 64 * 1024 +_MAX_TAURI_CONFIG_BYTES = 256 * 1024 +_MAX_CARGO_MANIFEST_BYTES = 256 * 1024 +_MAX_CARGO_LOCK_BYTES = 4 * 1024 * 1024 +_MAX_TAURI_MAIN_BYTES = 2 * 1024 * 1024 +_MAX_PUBLIC_KEY_CHARACTERS = 16 * 1024 +_MAX_ENDPOINTS = 4 +_ALLOWED_POLICY_KEYS = frozenset( + { + "schemaVersion", + "state", + "channel", + "minimumSupportedVersion", + "publicKey", + "endpoints", + "reason", + } +) +_ALLOWED_STATES = frozenset({"blocked", "admitted"}) +_ALLOWED_CHANNELS = frozenset({"stable", "beta"}) +_SEMVER_RE = re.compile( + r"^(0|[1-9][0-9]*)\." + r"(0|[1-9][0-9]*)\." + r"(0|[1-9][0-9]*)" + r"(?:-((?:0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)" + r"(?:\.(?:0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$" +) +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_UPDATER_INITIALIZER_RE = re.compile( + r"\.plugin\s*\(\s*tauri_plugin_updater::Builder::new\s*\(\s*\)" + r"\s*\.build\s*\(\s*\)\s*\)" +) + + +def _reject_duplicate_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + """Build a JSON object while rejecting parser-dependent duplicate members.""" + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate JSON member: {key}") + result[key] = value + return result + + +def _stable_regular_file_bytes(path: Path, *, maximum_bytes: int, label: str) -> bytes: + """Read one bounded regular non-link file from a stable opened descriptor.""" + if path.is_symlink(): + raise ValueError(f"{label} must not be a symlink") + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags) + except OSError as read_error: + raise ValueError(f"could not open {label}") from read_error + try: + before = os.fstat(descriptor) + if not stat.S_ISREG(before.st_mode): + raise ValueError(f"{label} must be a regular file") + if before.st_size < 1 or before.st_size > maximum_bytes: + raise ValueError(f"{label} exceeds its bounded size policy") + chunks: list[bytes] = [] + remaining = maximum_bytes + 1 + while remaining > 0: + chunk = os.read(descriptor, min(64 * 1024, remaining)) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + payload = b"".join(chunks) + if len(payload) > maximum_bytes: + raise ValueError(f"{label} exceeds its bounded size policy") + after = os.fstat(descriptor) + before_identity = (before.st_dev, before.st_ino, before.st_size) + after_identity = (after.st_dev, after.st_ino, after.st_size) + if before_identity != after_identity or len(payload) != before.st_size: + raise ValueError(f"{label} changed while being read") + return payload + finally: + os.close(descriptor) + + +def _load_bounded_json_object(path: Path, *, maximum_bytes: int, label: str) -> dict[str, Any]: + """Decode one bounded UTF-8 JSON object with duplicate-member rejection.""" + raw_bytes = _stable_regular_file_bytes( + path, maximum_bytes=maximum_bytes, label=label + ) + try: + raw_text = raw_bytes.decode("utf-8") + document = json.loads(raw_text, object_pairs_hook=_reject_duplicate_pairs) + except (UnicodeError, json.JSONDecodeError) as decode_error: + raise ValueError(f"{label} is not valid UTF-8 JSON") from decode_error + if not isinstance(document, dict): + raise ValueError(f"{label} must contain one JSON object") + return document + + +def _load_bounded_toml_object(path: Path, *, maximum_bytes: int, label: str) -> dict[str, Any]: + """Decode one bounded UTF-8 TOML document from a stable regular file.""" + raw_bytes = _stable_regular_file_bytes( + path, maximum_bytes=maximum_bytes, label=label + ) + try: + document = tomllib.loads(raw_bytes.decode("utf-8")) + except (UnicodeError, tomllib.TOMLDecodeError) as decode_error: + raise ValueError(f"{label} is not valid UTF-8 TOML") from decode_error + if not isinstance(document, dict): + raise ValueError(f"{label} must contain one TOML document") + return document + + +def _required_trimmed_string(value: Any, *, field_name: str) -> str: + """Return one non-empty trimmed policy string without coercion.""" + if not isinstance(value, str) or not value or value != value.strip(): + raise ValueError(f"updater policy {field_name} must be a non-empty trimmed string") + return value + + +def _validated_endpoints(value: Any) -> list[str]: + """Return unique production HTTPS updater endpoints from policy authority.""" + if not isinstance(value, list) or not 1 <= len(value) <= _MAX_ENDPOINTS: + raise ValueError("admitted updater policy requires one to four HTTPS endpoints") + endpoints: list[str] = [] + for endpoint_value in value: + endpoint = _required_trimmed_string(endpoint_value, field_name="endpoint") + parsed = urlsplit(endpoint) + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.fragment + ): + raise ValueError("admitted updater endpoint must use HTTPS without userinfo or fragment") + endpoints.append(endpoint) + if len(set(endpoints)) != len(endpoints): + raise ValueError("admitted updater endpoints must be unique") + return endpoints + + +def _validated_public_key(value: Any) -> str: + """Return bounded literal public-key content for Tauri updater verification.""" + public_key = _required_trimmed_string(value, field_name="publicKey") + if len(public_key) > _MAX_PUBLIC_KEY_CHARACTERS: + raise ValueError("updater policy publicKey exceeds its bounded size policy") + return public_key + + +def _validate_minimum_supported_version(value: Any) -> str: + """Require a canonical SemVer minimum supported application version.""" + version = _required_trimmed_string(value, field_name="minimumSupportedVersion") + if _SEMVER_RE.fullmatch(version) is None: + raise ValueError("updater policy minimumSupportedVersion must be valid SemVer") + return version + + +def _tauri_updater_config(tauri_document: dict[str, Any]) -> dict[str, Any] | None: + """Return the configured Tauri updater object without inventing absent plugin state.""" + plugins = tauri_document.get("plugins") + if plugins is None: + return None + if not isinstance(plugins, dict): + raise ValueError("tauri.conf.json plugins must be an object") + updater = plugins.get("updater") + if updater is None: + return None + if not isinstance(updater, dict): + raise ValueError("tauri.conf.json updater plugin config must be an object") + return updater + + +def _create_updater_artifacts_value(tauri_document: dict[str, Any]) -> Any: + """Return Tauri's updater-artifact generation setting or ``None`` when absent.""" + bundle = tauri_document.get("bundle") + if bundle is None: + return None + if not isinstance(bundle, dict): + raise ValueError("tauri.conf.json bundle must be an object") + return bundle.get("createUpdaterArtifacts") + + +def _updater_dependency_declarations(cargo_document: dict[str, Any]) -> list[Any]: + """Return updater dependency declarations from Cargo root/target dependency tables.""" + declarations: list[Any] = [] + dependencies = cargo_document.get("dependencies") + if dependencies is not None: + if not isinstance(dependencies, dict): + raise ValueError("desktop Cargo.toml dependencies must be an object") + if "tauri-plugin-updater" in dependencies: + declarations.append(dependencies["tauri-plugin-updater"]) + + targets = cargo_document.get("target") + if targets is not None: + if not isinstance(targets, dict): + raise ValueError("desktop Cargo.toml target must be an object") + for target_value in targets.values(): + if not isinstance(target_value, dict): + raise ValueError("desktop Cargo.toml target entry must be an object") + target_dependencies = target_value.get("dependencies") + if target_dependencies is None: + continue + if not isinstance(target_dependencies, dict): + raise ValueError("desktop target dependencies must be an object") + if "tauri-plugin-updater" in target_dependencies: + declarations.append(target_dependencies["tauri-plugin-updater"]) + return declarations + + +def _validate_updater_dependency(declaration: Any) -> None: + """Require one versioned non-path/non-git updater dependency declaration.""" + if isinstance(declaration, str): + if not declaration or declaration != declaration.strip(): + raise ValueError("tauri-plugin-updater dependency version must be explicit") + return + if not isinstance(declaration, dict): + raise ValueError("tauri-plugin-updater dependency declaration is invalid") + version = declaration.get("version") + if not isinstance(version, str) or not version or version != version.strip(): + raise ValueError("tauri-plugin-updater dependency version must be explicit") + if "path" in declaration or "git" in declaration: + raise ValueError("tauri-plugin-updater dependency must use the locked registry graph") + if declaration.get("optional") is True: + raise ValueError("tauri-plugin-updater dependency must not be optional for release admission") + + +def _validate_locked_updater_package(cargo_lock: dict[str, Any]) -> None: + """Require exactly one immutable registry updater package in Cargo.lock.""" + packages = cargo_lock.get("package") + if not isinstance(packages, list): + raise ValueError("desktop Cargo.lock must contain package entries") + matches = [ + package + for package in packages + if isinstance(package, dict) and package.get("name") == "tauri-plugin-updater" + ] + if len(matches) != 1: + raise ValueError("desktop Cargo.lock must contain exactly one tauri-plugin-updater package") + package = matches[0] + version = package.get("version") + source = package.get("source") + checksum = package.get("checksum") + if not isinstance(version, str) or not version or version != version.strip(): + raise ValueError("locked tauri-plugin-updater version is invalid") + if not isinstance(source, str) or not source.startswith("registry+"): + raise ValueError("locked tauri-plugin-updater must come from a registry source") + if not isinstance(checksum, str) or _SHA256_RE.fullmatch(checksum) is None: + raise ValueError("locked tauri-plugin-updater must carry a full registry checksum") + + +def _rust_code_without_comments_or_strings(source: str) -> str: + """Blank Rust comments/string literals so runtime-wiring text cannot be spoofed there.""" + output: list[str] = [] + index = 0 + length = len(source) + block_depth = 0 + while index < length: + if block_depth: + if source.startswith("/*", index): + block_depth += 1 + output.extend(" ") + index += 2 + elif source.startswith("*/", index): + block_depth -= 1 + output.extend(" ") + index += 2 + else: + output.append("\n" if source[index] == "\n" else " ") + index += 1 + continue + if source.startswith("//", index): + line_end = source.find("\n", index) + if line_end == -1: + output.extend(" " * (length - index)) + break + output.extend(" " * (line_end - index)) + output.append("\n") + index = line_end + 1 + continue + if source.startswith("/*", index): + block_depth = 1 + output.extend(" ") + index += 2 + continue + if source[index] == "r": + raw_match = re.match(r'r(#{0,16})"', source[index:]) + if raw_match is not None: + hashes = raw_match.group(1) + prefix_length = len(raw_match.group(0)) + terminator = '"' + hashes + raw_end = source.find(terminator, index + prefix_length) + if raw_end == -1: + output.extend(" " * (length - index)) + break + end = raw_end + len(terminator) + output.extend(" " * (end - index)) + index = end + continue + if source[index] == '"': + output.append(" ") + index += 1 + escaped = False + while index < length: + character = source[index] + output.append("\n" if character == "\n" else " ") + index += 1 + if escaped: + escaped = False + continue + if character == "\\": + escaped = True + elif character == '"': + break + continue + output.append(source[index]) + index += 1 + return "".join(output) + + +def _validate_updater_runtime_wiring(repository_root: Path) -> None: + """Bind updater admission to the compiled Cargo graph and desktop initializer.""" + cargo_manifest = _load_bounded_toml_object( + repository_root / _TAURI_CARGO_MANIFEST_PATH, + maximum_bytes=_MAX_CARGO_MANIFEST_BYTES, + label="desktop Cargo.toml", + ) + declarations = _updater_dependency_declarations(cargo_manifest) + if len(declarations) != 1: + raise ValueError( + "admitted updater policy requires exactly one tauri-plugin-updater dependency" + ) + _validate_updater_dependency(declarations[0]) + + cargo_lock = _load_bounded_toml_object( + repository_root / _TAURI_CARGO_LOCK_PATH, + maximum_bytes=_MAX_CARGO_LOCK_BYTES, + label="desktop Cargo.lock", + ) + _validate_locked_updater_package(cargo_lock) + + main_bytes = _stable_regular_file_bytes( + repository_root / _TAURI_MAIN_PATH, + maximum_bytes=_MAX_TAURI_MAIN_BYTES, + label="desktop Tauri main.rs", + ) + try: + executable_source = _rust_code_without_comments_or_strings( + main_bytes.decode("utf-8") + ) + except UnicodeError as decode_error: + raise ValueError("desktop Tauri main.rs is not valid UTF-8") from decode_error + if _UPDATER_INITIALIZER_RE.search(executable_source) is None: + raise ValueError( + "admitted updater policy requires the Tauri updater runtime initializer" + ) + + +def verify_updater_policy( + repository_root: Path, *, require_admitted: bool = False +) -> dict[str, Any]: + """Verify updater authority and its exact Tauri/runtime projection.""" + policy = _load_bounded_json_object( + repository_root / _POLICY_PATH, + maximum_bytes=_MAX_POLICY_BYTES, + label="release updater policy", + ) + policy_keys = frozenset(policy) + if policy_keys != _ALLOWED_POLICY_KEYS: + missing = sorted(_ALLOWED_POLICY_KEYS - policy_keys) + extra = sorted(policy_keys - _ALLOWED_POLICY_KEYS) + detail_parts = [] + if missing: + detail_parts.append(f"missing={','.join(missing)}") + if extra: + detail_parts.append(f"extra={','.join(extra)}") + raise ValueError( + "release updater policy keys must match the versioned contract" + + (f" ({'; '.join(detail_parts)})" if detail_parts else "") + ) + if policy.get("schemaVersion") != 1: + raise ValueError("release updater policy schemaVersion must equal 1") + state = policy.get("state") + if state not in _ALLOWED_STATES: + raise ValueError("release updater policy state must be blocked or admitted") + channel = policy.get("channel") + if channel not in _ALLOWED_CHANNELS: + raise ValueError("release updater policy channel must be stable or beta") + _validate_minimum_supported_version(policy.get("minimumSupportedVersion")) + + tauri_document = _load_bounded_json_object( + repository_root / _TAURI_CONFIG_PATH, + maximum_bytes=_MAX_TAURI_CONFIG_BYTES, + label="tauri.conf.json", + ) + updater_config = _tauri_updater_config(tauri_document) + create_updater_artifacts = _create_updater_artifacts_value(tauri_document) + + if state == "blocked": + if policy.get("publicKey") is not None or policy.get("endpoints") != []: + raise ValueError("blocked updater policy cannot carry release authority") + _required_trimmed_string(policy.get("reason"), field_name="reason") + if create_updater_artifacts not in {None, False} or updater_config is not None: + raise ValueError("blocked updater policy cannot enable Tauri updater capability") + if require_admitted: + raise ValueError("commercial updater policy is blocked") + return policy + + if policy.get("reason") is not None: + raise ValueError("admitted updater policy reason must be null") + public_key = _validated_public_key(policy.get("publicKey")) + endpoints = _validated_endpoints(policy.get("endpoints")) + if create_updater_artifacts is not True: + raise ValueError("admitted updater policy requires bundle.createUpdaterArtifacts=true") + if updater_config is None: + raise ValueError("admitted updater policy requires Tauri updater plugin config") + if updater_config.get("dangerousInsecureTransportProtocol") is True: + raise ValueError("admitted updater policy forbids insecure transport") + if updater_config.get("pubkey") != public_key: + raise ValueError("Tauri updater public key does not match release updater policy") + if updater_config.get("endpoints") != endpoints: + raise ValueError("Tauri updater endpoints do not match release updater policy") + _validate_updater_runtime_wiring(repository_root) + return policy + + +def main() -> int: + """Verify repository updater policy, requiring admission for a version tag.""" + release_tag = ( + os.environ.get("GITHUB_REF_NAME") + if os.environ.get("GITHUB_REF_TYPE") == "tag" + else None + ) + try: + policy = verify_updater_policy( + _REPOSITORY_ROOT, require_admitted=release_tag is not None + ) + except ValueError as policy_error: + print(f"release updater policy check failed: {policy_error}", file=sys.stderr) + return 1 + print( + "BandScope updater policy verified: " + f"state={policy['state']} channel={policy['channel']}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/harness/quickcheck.sh b/scripts/harness/quickcheck.sh index f2b87e4e8..b2d6f0a64 100755 --- a/scripts/harness/quickcheck.sh +++ b/scripts/harness/quickcheck.sh @@ -8,7 +8,9 @@ python3 scripts/checks/verify_docs.py python3 scripts/checks/verify_security_notes.py python3 scripts/checks/security_gates.py python3 scripts/checks/verify_supply_chain.py +python3 scripts/checks/verify_distribution_http_dependencies.py python3 scripts/checks/verify_github_bootstrap_policy.py +python3 scripts/checks/verify_release_identity.py npm run lint npm run typecheck npm run test diff --git a/scripts/release/build_updater_manifest.py b/scripts/release/build_updater_manifest.py new file mode 100644 index 000000000..860cf74c3 --- /dev/null +++ b/scripts/release/build_updater_manifest.py @@ -0,0 +1,431 @@ +#!/usr/bin/env python3 +"""Build deterministic Tauri updater metadata from admitted release receipts. + +Security Notes: + This Distribution-owned builder does not discover or manufacture signing + authority. It first re-admits the extracted release graph through + ``select_release_assets`` and then derives one static updater entry per + supported target from the exact receipt-bound bundle and signature bytes. + Signature text is embedded only after a bounded stable regular-file read, + an exact size/SHA-256 comparison against the target receipt, and validation + of the canonical standard-base64/UTF-8 envelope consumed by Tauri before + minisign verification. The BandScope extension binds each target's exact + bundle size/digest, the full source commit, and the admitted + minimum-supported-version policy so a future runtime can make + replay/compatibility decisions from ``raw_json`` without trusting filenames + or mutable release aliases. Release URLs are exact-tag HTTPS URLs; no + mutable latest URL or untrusted receipt path is used as filesystem authority. +""" + +from __future__ import annotations + +import argparse +import base64 +import binascii +import hashlib +import json +import os +import re +import stat +import sys +from pathlib import Path +from typing import Any +from urllib.parse import quote, urlsplit + +import select_release_assets as release_assets + +_FULL_SHA_RE = re.compile(r"^[0-9a-f]{40}$") +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_REPOSITORY_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +_SEMVER_RE = re.compile( + r"^(0|[1-9][0-9]*)\." + r"(0|[1-9][0-9]*)\." + r"(0|[1-9][0-9]*)" + r"(?:-((?:0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)" + r"(?:\.(?:0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$" +) +_MAX_VERSION_BYTES = 256 +_MAX_SIGNATURE_BYTES = 64 * 1024 +_MAX_POLICY_BYTES = 64 * 1024 +_PLATFORM_KEYS = { + ("windows", "amd64"): "windows-x86_64", + ("windows", "arm64"): "windows-aarch64", + ("macos", "amd64"): "darwin-x86_64", + ("macos", "arm64"): "darwin-aarch64", +} + + +def _reject_duplicate_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + """Build one JSON object while refusing parser-dependent duplicate members.""" + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate JSON member: {key}") + result[key] = value + return result + + +def _stable_read_bytes(path: Path, *, label: str, maximum_bytes: int) -> bytes: + """Read one bounded regular non-link file from a stable descriptor.""" + if path.is_symlink(): + raise ValueError(f"{label} must be a regular non-link file") + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags) + except OSError as error: + raise ValueError(f"{label} could not be opened") from error + try: + before = os.fstat(descriptor) + if not stat.S_ISREG(before.st_mode): + raise ValueError(f"{label} must be a regular non-link file") + if before.st_size < 1: + raise ValueError(f"{label} must not be empty") + if before.st_size > maximum_bytes: + raise ValueError(f"{label} exceeds its bounded size policy") + payload = bytearray() + while len(payload) <= maximum_bytes: + chunk = os.read( + descriptor, + min(64 * 1024, maximum_bytes + 1 - len(payload)), + ) + if not chunk: + break + payload.extend(chunk) + after = os.fstat(descriptor) + if len(payload) > maximum_bytes: + raise ValueError(f"{label} exceeds its bounded size policy") + if (before.st_dev, before.st_ino, before.st_size) != ( + after.st_dev, + after.st_ino, + after.st_size, + ) or len(payload) != before.st_size: + raise ValueError(f"{label} changed while being read") + return bytes(payload) + finally: + os.close(descriptor) + + +def _version(repo_root: Path) -> str: + """Return the exact authoritative VERSION value.""" + raw = _stable_read_bytes( + repo_root / "VERSION", label="VERSION", maximum_bytes=_MAX_VERSION_BYTES + ) + try: + value = raw.decode("utf-8").strip() + except UnicodeError as error: + raise ValueError("VERSION must be UTF-8") from error + if not value or value != value.strip() or any( + character.isspace() for character in value + ): + raise ValueError("VERSION must contain one non-empty token") + return value + + +def _minimum_supported_version(repo_root: Path) -> str: + """Return the updater policy's version floor after bounded duplicate-safe admission.""" + raw = _stable_read_bytes( + repo_root / "release" / "updater-policy.json", + label="release updater policy", + maximum_bytes=_MAX_POLICY_BYTES, + ) + try: + document = json.loads( + raw.decode("utf-8"), object_pairs_hook=_reject_duplicate_pairs + ) + except (UnicodeError, json.JSONDecodeError) as error: + raise ValueError("release updater policy must be valid UTF-8 JSON") from error + if not isinstance(document, dict) or document.get("schemaVersion") != 1: + raise ValueError("release updater policy schemaVersion must equal 1") + value = document.get("minimumSupportedVersion") + if ( + not isinstance(value, str) + or not value + or value != value.strip() + or _SEMVER_RE.fullmatch(value) is None + ): + raise ValueError( + "release updater policy minimumSupportedVersion must be valid SemVer" + ) + return value + + +def _normalized_server_url(server_url: str) -> str: + """Return one HTTPS release origin without mutable URL components.""" + parsed = urlsplit(server_url.strip()) + if ( + parsed.scheme.lower() != "https" + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + or parsed.path not in {"", "/"} + ): + raise ValueError("release server URL must be an HTTPS origin") + authority = parsed.hostname + if parsed.port is not None: + authority = f"{authority}:{parsed.port}" + return f"https://{authority}" + + +def _normalized_repository(repository: str) -> str: + """Return an exact owner/repository slug suitable for a release URL.""" + value = repository.strip() + if _REPOSITORY_RE.fullmatch(value) is None: + raise ValueError("repository must be an exact owner/name slug") + return value + + +def _receipt_path( + repo_root: Path, target: tuple[str, str], source_commit: str +) -> Path: + """Return the fixed target receipt path for an exact release commit.""" + platform, arch = target + return ( + repo_root + / "artifacts" + / f"bandscope-{platform}-{arch}-{source_commit[:12]}.release-receipt.json" + ) + + +def _exact_updater_entry( + receipt: dict[str, Any], *, target: tuple[str, str] +) -> dict[str, Any]: + """Return exactly one updater artifact for one static-manifest target.""" + entries = receipt.get("updaterArtifacts") + if ( + not isinstance(entries, list) + or len(entries) != 1 + or not isinstance(entries[0], dict) + ): + raise ValueError( + f"exactly one updater artifact is required for {target[0]}-{target[1]}" + ) + return entries[0] + + +def _updater_identity_metadata( + entry: dict[str, Any], *, target: tuple[str, str] +) -> dict[str, object]: + """Return bounded exact bundle identity for BandScope updater security metadata.""" + size_bytes = entry.get("sizeBytes") + digest = entry.get("sha256") + if ( + isinstance(size_bytes, bool) + or not isinstance(size_bytes, int) + or size_bytes < 1 + ): + raise ValueError( + f"updater bundle size is invalid for {target[0]}-{target[1]}" + ) + if not isinstance(digest, str) or _SHA256_RE.fullmatch(digest) is None: + raise ValueError( + f"updater bundle digest is invalid for {target[0]}-{target[1]}" + ) + return {"sizeBytes": size_bytes, "sha256": digest} + + +def _signature_text( + repo_root: Path, + *, + entry: dict[str, Any], + target: tuple[str, str], +) -> str: + """Return receipt-bound Tauri signature content after exact byte admission.""" + signature_name = entry.get("signatureFile") + if not isinstance(signature_name, str) or Path(signature_name).name != signature_name: + raise ValueError("updater signature filename is invalid") + signature_path = repo_root / "artifacts" / signature_name + payload = _stable_read_bytes( + signature_path, + label=f"updater signature for {target[0]}-{target[1]}", + maximum_bytes=_MAX_SIGNATURE_BYTES, + ) + expected_size = entry.get("signatureSizeBytes") + expected_digest = entry.get("signatureSha256") + if ( + isinstance(expected_size, bool) + or not isinstance(expected_size, int) + or expected_size < 1 + ): + raise ValueError("updater signature receipt size is invalid") + if len(payload) != expected_size: + raise ValueError("updater signature size does not match release receipt") + digest = hashlib.sha256(payload).hexdigest() + if not isinstance(expected_digest, str) or digest != expected_digest: + raise ValueError("updater signature digest does not match release receipt") + try: + text = payload.decode("ascii") + except UnicodeError as error: + raise ValueError("updater signature must contain ASCII base64 text") from error + if not text or text != text.strip() or "\x00" in text: + raise ValueError("updater signature must contain canonical base64 text") + try: + decoded = base64.b64decode(text, validate=True) + except (binascii.Error, ValueError) as error: + raise ValueError("updater signature must contain canonical base64 text") from error + if base64.b64encode(decoded).decode("ascii") != text: + raise ValueError("updater signature must contain canonical base64 text") + try: + decoded.decode("utf-8") + except UnicodeError as error: + raise ValueError("updater signature base64 payload must decode to UTF-8") from error + return text + + +def build_manifest( + repo_root: Path, + *, + source_commit: str, + repository: str, + server_url: str, +) -> dict[str, Any]: + """Build deterministic Tauri static updater JSON from the exact release graph.""" + if _FULL_SHA_RE.fullmatch(source_commit) is None: + raise ValueError("updater manifest requires a full lowercase 40-hex Git SHA") + repository_slug = _normalized_repository(repository) + release_origin = _normalized_server_url(server_url) + + # Reuse the Distribution publication admission owner before reading any + # receipt-derived name. This rejects stray, incomplete, linked, or + # digest-drifting installer/updater graphs before manifest construction. + release_assets.select_release_assets(repo_root, git_sha=source_commit) + + version = _version(repo_root) + minimum_supported_version = _minimum_supported_version(repo_root) + platforms: dict[str, dict[str, str]] = {} + artifact_identities: dict[str, dict[str, object]] = {} + for target, platform_key in _PLATFORM_KEYS.items(): + receipt = release_assets._load_receipt( + _receipt_path(repo_root, target, source_commit) + ) + if receipt.get("version") != version or receipt.get("tag") != f"v{version}": + raise ValueError("release receipt version/tag does not match VERSION") + if receipt.get("sourceCommit") != source_commit: + raise ValueError("release receipt sourceCommit does not match updater source") + entry = _exact_updater_entry(receipt, target=target) + bundle_name = entry.get("bundle") + if not isinstance(bundle_name, str) or Path(bundle_name).name != bundle_name: + raise ValueError("updater bundle filename is invalid") + signature = _signature_text( + repo_root, + entry=entry, + target=target, + ) + download_url = ( + f"{release_origin}/{repository_slug}/releases/download/" + f"v{quote(version, safe='')}/{quote(bundle_name, safe='')}" + ) + platforms[platform_key] = { + "signature": signature, + "url": download_url, + } + artifact_identities[platform_key] = _updater_identity_metadata( + entry, target=target + ) + + return { + "version": version, + "platforms": platforms, + "bandscope": { + "schemaVersion": 1, + "sourceCommit": source_commit, + "minimumSupportedVersion": minimum_supported_version, + "artifacts": artifact_identities, + }, + } + + +def _manifest_bytes(manifest: dict[str, Any]) -> bytes: + """Serialize the static updater manifest deterministically.""" + return (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode("utf-8") + + +def _write_atomically(path: Path, payload: bytes) -> None: + """Publish manifest bytes atomically and sync the containing directory.""" + path.parent.mkdir(parents=True, exist_ok=True) + stage = path.with_name(f".{path.name}.{os.getpid()}.tmp") + try: + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0) + descriptor = os.open(stage, flags, 0o644) + try: + with os.fdopen(descriptor, "wb", closefd=False) as handle: + handle.write(payload) + handle.flush() + os.fsync(descriptor) + finally: + os.close(descriptor) + os.replace(stage, path) + if os.name != "nt": + directory_descriptor = os.open( + path.parent, + os.O_RDONLY | getattr(os, "O_CLOEXEC", 0), + ) + try: + os.fsync(directory_descriptor) + finally: + os.close(directory_descriptor) + finally: + try: + stage.unlink() + except FileNotFoundError: + # A successful os.replace consumed the staged pathname. + pass + + +def _check_output(path: Path, expected: bytes) -> None: + """Fail closed when an existing publication manifest drifted after generation.""" + actual = _stable_read_bytes( + path, + label="updater manifest", + maximum_bytes=max(len(expected), 256 * 1024), + ) + if actual != expected: + raise ValueError( + "updater manifest does not match receipt-authorized release bytes" + ) + + +def main() -> int: + """Build or verify one deterministic static updater manifest.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo-root", type=Path, default=Path.cwd()) + parser.add_argument("--git-sha", default=os.environ.get("GITHUB_SHA", "")) + parser.add_argument( + "--repository", default=os.environ.get("GITHUB_REPOSITORY", "") + ) + parser.add_argument( + "--server-url", default=os.environ.get("GITHUB_SERVER_URL", "") + ) + parser.add_argument("--output", type=Path, default=Path("latest.json")) + parser.add_argument( + "--check", + action="store_true", + help="Verify the existing output instead of rewriting it.", + ) + args = parser.parse_args() + + try: + manifest = build_manifest( + args.repo_root, + source_commit=str(args.git_sha).lower(), + repository=str(args.repository), + server_url=str(args.server_url), + ) + payload = _manifest_bytes(manifest) + output = args.output + if not output.is_absolute(): + output = args.repo_root / output + if args.check: + _check_output(output, payload) + else: + _write_atomically(output, payload) + except (OSError, ValueError) as error: + print(f"Updater manifest validation failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release/download_umxhq.mjs b/scripts/release/download_umxhq.mjs new file mode 100644 index 000000000..b08f7e321 --- /dev/null +++ b/scripts/release/download_umxhq.mjs @@ -0,0 +1,57 @@ +/** Explicit Distribution acquisition command; importing this module performs no I/O. */ +import process from 'node:process'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { acquireUmxhq, readPinFile } from './umxhq_acquisition.mjs'; + +const USAGE = 'Usage: node scripts/release/download_umxhq.mjs (--bootstrap | --pin RECEIPT.json) --output NEW_DIRECTORY\n' + + 'Acquires only UMX-HQ candidate weights. No model is installed or approved for release.\n'; +const SAFE_ERRORS = new Set(['cancelled', 'source_rejected', 'tls_environment_rejected', 'network_failed', + 'response_rejected', 'size_mismatch', 'transfer_failed', 'record_rejected', 'pins_rejected', + 'pin_file_rejected', 'directory_changed', 'file_changed', 'mode_required', 'destination_exists', + 'destination_unavailable', 'pinned_size_mismatch', 'notice_mismatch', 'checksum_mismatch', 'sha256_mismatch']); + +/** Unknown, duplicate or incomplete options cannot authorize network acquisition. */ +export function parseArguments(args) { + if (args.length === 1 && args[0] === '--help') return null; + const parsed = { bootstrap: false, pin: null, output: null }; const seen = new Set(); + for (let i = 0; i < args.length; i += 1) { + const key = args[i]; + if (seen.has(key) || !['--bootstrap', '--pin', '--output'].includes(key)) throw Error('arguments_rejected'); + seen.add(key); + if (key === '--bootstrap') parsed.bootstrap = true; + else { + const value = args[++i]; + if (!value || value.startsWith('--')) throw Error('arguments_rejected'); + parsed[key.slice(2)] = value; + } + } + if (!parsed.output || parsed.bootstrap === (parsed.pin !== null)) throw Error('arguments_rejected'); + return parsed; +} + +/** A successful acquisition is not a release, model load, or scientific acceptance. */ +export async function runCli(args, { acquire = acquireUmxhq, loadPin = readPinFile, + stdout = console.log, stderr = console.error } = {}) { + let parsed; + try { parsed = parseArguments(args); } + catch { stderr(USAGE); return 2; } + if (parsed === null) { stdout(USAGE); return 0; } + const controller = new AbortController(); const abort = () => controller.abort(); + process.on('SIGINT', abort); process.on('SIGTERM', abort); + try { + const pins = parsed.pin === null ? null : JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(await loadPin(parsed.pin))); + const receipt = await acquire({ output: parsed.output, bootstrap: parsed.bootstrap, pins, signal: controller.signal }); + stdout(JSON.stringify({ status: 'acquired', model_id: receipt.model_id, + receipt_filename: 'acquisition-receipt.json', release_admitted: false })); + return 0; + } catch (error) { + const code = SAFE_ERRORS.has(error.message) ? error.message : 'acquisition_failed'; + stderr(`model_download_failed: ${code}`); + return code === 'cancelled' ? 130 : 1; + } finally { process.off('SIGINT', abort); process.off('SIGTERM', abort); } +} + +if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) { + process.exitCode = await runCli(process.argv.slice(2)); +} diff --git a/scripts/release/download_umxhq.test.mjs b/scripts/release/download_umxhq.test.mjs new file mode 100644 index 000000000..53ebdb656 --- /dev/null +++ b/scripts/release/download_umxhq.test.mjs @@ -0,0 +1,287 @@ +/** Download contracts use transport fixtures, never model-quality evidence. */ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { existsSync } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { Readable } from 'node:stream'; +import https from 'node:https'; +import { EventEmitter } from 'node:events'; + +const implementation = new URL('./umxhq_acquisition.mjs', import.meta.url); +test('the explicit acquisition implementation exists', () => assert.ok(existsSync(implementation))); +if (existsSync(implementation)) { + const m = await import(implementation); + const hash = (bytes, algorithm = 'sha256') => createHash(algorithm).update(bytes).digest('hex'); + const body = (bytes, headers = {}, statusCode = 200) => Object.assign( + Readable.from([Buffer.from(bytes)]), { statusCode, headers }, + ); + const fixture = () => { + const weights = Object.fromEntries(Object.entries(m.FILENAMES).map(([stem, name]) => [name, Buffer.from(`UNIT CHECKPOINT ${stem}`)])); + const record = { + id: 3370489, + metadata: { doi: m.MODEL_RECORD, version: '1.0.1', license: { id: 'mit-license' } }, + files: Object.entries(weights).map(([key, value]) => ({ key, size: value.length, checksum: `md5:${hash(value, 'md5')}`, links: { self: 'https://evil.invalid/ignored' } })), + }; + record.files.push({ key: 'training-json-logs.zip', size: 42 }); + return { weights, record }; + }; + const withWorkspace = async (t) => { + const parent = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'umxhq-unit-'))); + t.after(() => fs.rm(parent, { recursive: true, force: true })); + return { parent, output: path.join(parent, 'new-bundle') }; + }; + const transport = (data, calls = []) => async (url) => { + calls.push(url); + if (url === m.RECORD_URL) return body(JSON.stringify(data.record), { 'content-type': 'application/json' }); + if (url === m.NOTICE_URL) return body(await fs.readFile(new URL('./fixtures/openunmix-LICENSE.txt', import.meta.url))); + const filename = new URL(url).pathname.split('/').at(-1); + assert.ok(filename in data.weights); + return body(data.weights[filename], { 'content-length': String(data.weights[filename].length) }); + }; + + test('bootstrap downloads all four fixed files, exact notice and a non-release receipt', async (t) => { + const { output } = await withWorkspace(t); const data = fixture(); const calls = []; + const receipt = await m.acquireUmxhq({ output, bootstrap: true, source: transport(data, calls) }); + assert.equal(receipt.release_admitted, false); + assert.equal(receipt.record_kind, 'candidate_acquisition_receipt_not_release_manifest'); + assert.equal(receipt.verification_mode, 'publisher_checksum_bootstrap'); + assert.deepEqual(Object.keys(receipt.checkpoints), Object.keys(m.FILENAMES)); + for (const [stem, name] of Object.entries(m.FILENAMES)) { + assert.deepEqual(await fs.readFile(path.join(output, name)), data.weights[name]); + assert.equal(receipt.checkpoints[stem].sha256, hash(data.weights[name])); + assert.equal(receipt.checkpoints[stem].size_bytes, data.weights[name].length); + } + assert.equal(calls.length, 6); + assert.ok(calls.every((url) => !url.includes('evil.invalid') && !url.includes('.zip'))); + assert.equal(existsSync(path.join(output, 'INCOMPLETE')), false); + assert.deepEqual(JSON.parse(await fs.readFile(path.join(output, 'acquisition-receipt.json'), 'utf8')), receipt); + }); + + test('reviewed full SHA256 pins permit a separate reproducible acquisition', async (t) => { + const { parent, output } = await withWorkspace(t); const data = fixture(); + const first = await m.acquireUmxhq({ output, bootstrap: true, source: transport(data) }); + const receipt = await m.acquireUmxhq({ output: path.join(parent, 'pinned'), pins: first, source: transport(data) }); + assert.equal(receipt.verification_mode, 'sha256_pinned_reacquisition'); + assert.deepEqual(receipt.checkpoints, first.checkpoints); + }); + + test('changed bytes cannot pass full SHA256 pins even when the publisher MD5 changes too', async (t) => { + const { parent, output } = await withWorkspace(t); const data = fixture(); + const pins = await m.acquireUmxhq({ output, bootstrap: true, source: transport(data) }); + const name = m.FILENAMES.vocals; data.weights[name] = Buffer.alloc(data.weights[name].length, 1); + data.record.files.find((f) => f.key === name).checksum = `md5:${hash(data.weights[name], 'md5')}`; + const target = path.join(parent, 'changed'); + await assert.rejects(m.acquireUmxhq({ output: target, pins, source: transport(data) }), /sha256_mismatch/); + assert.equal(existsSync(path.join(target, 'acquisition-receipt.json')), false); + }); + + for (const [name, change] of [ + ['wrong record', (x) => { x.id = 3370486; }], + ['wrong DOI', (x) => { x.metadata.doi = 'other'; }], + ['wrong version', (x) => { x.metadata.version = '2'; }], + ['noncommercial model', (x) => { x.metadata.license.id = 'cc-by-nc-sa-4.0'; }], + ['missing target', (x) => { x.files.shift(); }], + ['duplicate target', (x) => { x.files.push(x.files[0]); }], + ['over-limit artifact', (x) => { x.files[0].size = m.MAX_WEIGHT_BYTES + 1; }], + ['empty artifact', (x) => { x.files[0].size = 0; }], + ['fractional size', (x) => { x.files[0].size = 1.5; }], + ['boolean size', (x) => { x.files[0].size = true; }], + ['malformed checksum', (x) => { x.files[0].checksum = 'md5:xx'; }], + ]) { + test(`publisher metadata rejects ${name}`, () => { + const data = fixture(); change(data.record); + assert.throws(() => m.parseRecord(Buffer.from(JSON.stringify(data.record))), /record_rejected/); + }); + } + test('metadata is byte-bounded and must be JSON', () => { + assert.throws(() => m.parseRecord(Buffer.alloc(m.MAX_RECORD_BYTES + 1)), /record_rejected/); + assert.throws(() => m.parseRecord(Buffer.from('')), /record_rejected/); + }); + for (const mutation of [ + (p) => { p.model_id = 'sigsep/umxl'; }, + (p) => { p.release_admitted = true; }, + (p) => { delete p.checkpoints.other; }, + (p) => { p.checkpoints.vocals.filename = '../model.pth'; }, + (p) => { p.checkpoints.vocals.sha256 = null; }, + (p) => { p.checkpoints.vocals.size_bytes = -1; }, + ]) { + test('malformed pins are rejected before any HTTP or output write', async (t) => { + const { output } = await withWorkspace(t); const data = fixture(); + const p = { record_kind: 'candidate_acquisition_receipt_not_release_manifest', model_id: m.MODEL_ID, model_record: m.MODEL_RECORD, model_version: '1.0.1', release_admitted: false, checkpoints: Object.fromEntries(Object.entries(m.FILENAMES).map(([s, f]) => [s, { filename: f, size_bytes: data.weights[f].length, sha256: hash(data.weights[f]) }])) }; + mutation(p); + await assert.rejects(m.acquireUmxhq({ output, pins: p, source: () => assert.fail('HTTP reached') }), /pins_rejected/); + assert.equal(existsSync(output), false); + }); + } + test('bootstrap is explicit and mutually exclusive with pins', async (t) => { + const { output } = await withWorkspace(t); + await assert.rejects(m.acquireUmxhq({ output }), /mode_required/); + await assert.rejects(m.acquireUmxhq({ output, bootstrap: true, pins: {} }), /mode_required/); + assert.equal(existsSync(output), false); + }); + test('existing directory is never overwritten or reused', async (t) => { + const { output } = await withWorkspace(t); await fs.mkdir(output); await fs.writeFile(path.join(output, 'keep'), 'original'); + await assert.rejects(m.acquireUmxhq({ output, bootstrap: true, source: () => assert.fail('HTTP reached') }), /destination_exists/); + assert.equal(await fs.readFile(path.join(output, 'keep'), 'utf8'), 'original'); + }); + test('symlink destination is rejected before HTTP', async (t) => { + const { parent, output } = await withWorkspace(t); await fs.symlink(parent, output, 'junction'); + await assert.rejects(m.acquireUmxhq({ output, bootstrap: true, source: () => assert.fail('HTTP reached') }), /destination_exists/); + }); + test('pre-cancelled work creates nothing', async (t) => { + const { output } = await withWorkspace(t); + await assert.rejects(m.acquireUmxhq({ output, bootstrap: true, signal: AbortSignal.abort() }), /cancelled/); + assert.equal(existsSync(output), false); + }); + for (const [label, modify] of [ + ['truncation', (bytes) => bytes.subarray(0, -1)], + ['extra bytes', (bytes) => Buffer.concat([bytes, Buffer.from('x')])], + ['same-size corruption', (bytes) => Buffer.alloc(bytes.length, 1)], + ]) { + test(`${label} leaves an incomplete bundle and no success receipt`, async (t) => { + const { output } = await withWorkspace(t); const data = fixture(); const original = transport(data); + const source = async (url) => url.includes('.pth') ? body(modify(data.weights[new URL(url).pathname.split('/').at(-1)])) : original(url); + await assert.rejects(m.acquireUmxhq({ output, bootstrap: true, source }), /size_mismatch|checksum_mismatch/); + assert.equal(existsSync(path.join(output, 'INCOMPLETE')), true); + assert.equal(existsSync(path.join(output, 'acquisition-receipt.json')), false); + }); + } + for (const [label, headers, status] of [ + ['redirect', { location: 'https://evil.invalid' }, 302], + ['partial response', {}, 206], + ['rate limiting', {}, 429], + ['compression', { 'content-encoding': 'gzip' }, 200], + ['HTML error', { 'content-type': 'text/html' }, 200], + ['inconsistent length', { 'content-length': '99' }, 200], + ['ambiguous length', { 'content-length': ['4', '4'] }, 200], + ]) { + test(`HTTP ${label} cannot be accepted as a checkpoint`, async () => { + const response = body('test', headers, status); + await assert.rejects(m.readResponse(response, { limit: 16, expectedSize: 4 }), /response_rejected|size_mismatch/); + assert.equal(response.destroyed, true); + }); + } + test('network stream failure and cancellation close a partial response', async () => { + const controller = new AbortController(); + const response = Object.assign(Readable.from((async function* () { yield Buffer.from('one'); controller.abort(); yield Buffer.from('two'); })()), { statusCode: 200, headers: {} }); + await assert.rejects(m.readResponse(response, { limit: 16, signal: controller.signal }), /cancelled/); + const failed = Object.assign(Readable.from((async function* () { yield Buffer.from('x'); throw Error('network secret'); })()), { statusCode: 200, headers: {} }); + await assert.rejects(m.readResponse(failed, { limit: 16 }), /transfer_failed/); + }); + test('bounded responses enforce total size without Content-Length', async () => { + await assert.rejects(m.readResponse(body('12345'), { limit: 4 }), /size_mismatch/); + }); + test('fixed URL allowlist rejects alternate models, credentials, ports and schemes', async () => { + for (const url of ['http://zenodo.org/api/records/3370489', 'https://localhost/x', 'https://zenodo.org:444/api/records/3370489', 'https://u:p@zenodo.org/api/records/3370489', 'https://zenodo.org/api/records/3370486', 'https://zenodo.org/api/records/3370489#x']) { + await assert.rejects(m.fetchSource(url), /source_rejected/); + } + }); + test('native transport fixes TLS verification, no shared agent, no redirects or credentials', async (t) => { + const response = body('{}', { 'content-type': 'application/json' }); let options; + t.mock.method(https, 'get', (url, opts, cb) => { options = opts; const request = new EventEmitter(); queueMicrotask(() => cb(response)); return request; }); + assert.equal(await m.fetchSource(m.RECORD_URL), response); + assert.equal(options.rejectUnauthorized, true); + assert.equal(options.agent, false); + assert.equal(options.headers.authorization, undefined); + assert.equal(options.headers['accept-encoding'], 'identity'); + assert.ok(options.ca.length > 0); + }); + test('native transport hides low-level connection diagnostics', async (t) => { + t.mock.method(https, 'get', () => { const request = new EventEmitter(); queueMicrotask(() => request.emit('error', Error('secret private proxy'))); return request; }); + await assert.rejects(m.fetchSource(m.RECORD_URL), /^Error: network_failed$/); + }); + test('local pin reader rejects links and reads bounded regular receipt bytes', async (t) => { + const { parent } = await withWorkspace(t); const file = path.join(parent, 'pin.json'); await fs.writeFile(file, '{}'); + assert.equal((await m.readPinFile(file)).toString(), '{}'); + const link = path.join(parent, 'pin-link'); await fs.symlink(file, link); + await assert.rejects(m.readPinFile(link), /pin_file_rejected/); + await fs.writeFile(file, Buffer.alloc(m.MAX_RECORD_BYTES + 1)); + await assert.rejects(m.readPinFile(file), /pin_file_rejected/); + }); + test('TLS-disable environment is rejected before the native request', async (t) => { + const before = process.env.NODE_TLS_REJECT_UNAUTHORIZED; + t.after(() => { if (before === undefined) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; else process.env.NODE_TLS_REJECT_UNAUTHORIZED = before; }); + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + t.mock.method(https, 'get', () => assert.fail('network reached')); + await assert.rejects(m.fetchSource(m.RECORD_URL), /tls_environment_rejected/); + }); + test('native synchronous failure is also redacted', async (t) => { + t.mock.method(https, 'get', () => { throw Error('private detail'); }); + await assert.rejects(m.fetchSource(m.RECORD_URL), /^Error: network_failed$/); + }); + test('native request abort is cancellation, not an acquisition success', async (t) => { + const controller = new AbortController(); + t.mock.method(https, 'get', () => { const request = new EventEmitter(); queueMicrotask(() => { controller.abort(); request.emit('error', Error('abort')); }); return request; }); + await assert.rejects(m.fetchSource(m.RECORD_URL, { signal: controller.signal }), /cancelled/); + }); + test('text-mode chunks are not treated as original binary bytes', async () => { + const response = Object.assign(Readable.from(['text']), { statusCode: 200, headers: {} }); + await assert.rejects(m.readResponse(response, { limit: 20 }), /response_rejected/); + }); + test('source notice must match its independently fixed full SHA256', async (t) => { + const { output } = await withWorkspace(t); const data = fixture(); const original = transport(data); + await assert.rejects(m.acquireUmxhq({ output, bootstrap: true, source: (url) => url === m.NOTICE_URL ? body('wrong notice') : original(url) }), /notice_mismatch/); + assert.equal(existsSync(path.join(output, m.FILENAMES.vocals)), false); + }); + test('publisher size drift fails before fetching any checkpoint', async (t) => { + const { parent, output } = await withWorkspace(t); const data = fixture(); + const pins = await m.acquireUmxhq({ output, bootstrap: true, source: transport(data) }); + data.record.files[0].size += 1; + const calls = []; + await assert.rejects(m.acquireUmxhq({ output: path.join(parent, 'size-drift'), pins, source: transport(data, calls) }), /pinned_size_mismatch/); + assert.deepEqual(calls, [m.RECORD_URL]); + }); + test('directory replacement cannot redirect subsequent bundle writes', async (t) => { + const { parent, output } = await withWorkspace(t); const data = fixture(); const original = transport(data); + const outside = path.join(parent, 'outside'); await fs.mkdir(outside); + const source = async (url) => { + if (url === m.RECORD_URL) { await fs.rename(output, path.join(parent, 'preserved')); await fs.symlink(outside, output, 'junction'); } + return original(url); + }; + await assert.rejects(m.acquireUmxhq({ output, bootstrap: true, source }), /directory_changed/); + assert.deepEqual(await fs.readdir(outside), []); + }); + test('checkpoint pathname replacement cannot publish different descriptor bytes', async (t) => { + const { output } = await withWorkspace(t); const data = fixture(); const original = transport(data); + const source = async (url) => { + if (url.includes('.pth')) { + const partial = path.join(output, `${m.FILENAMES.vocals}.part`); + await fs.rename(partial, `${partial}.preserved`); await fs.writeFile(partial, 'substitute'); + } + return original(url); + }; + await assert.rejects(m.acquireUmxhq({ output, bootstrap: true, source }), /file_changed/); + assert.equal(existsSync(path.join(output, 'acquisition-receipt.json')), false); + }); + test('non-directory parent fails without a network call', async (t) => { + const { parent } = await withWorkspace(t); const file = path.join(parent, 'ordinary-file'); await fs.writeFile(file, 'x'); + await assert.rejects(m.acquireUmxhq({ output: path.join(file, 'child'), bootstrap: true, source: () => assert.fail() }), /destination_unavailable/); + }); + for (const changeAt of [1, 2]) { + test(`pin descriptor size drift at stat ${changeAt} is rejected`, async (t) => { + const { parent } = await withWorkspace(t); const file = path.join(parent, 'pin.json'); await fs.writeFile(file, '{}'); + const probe = await fs.open(file, 'r'); const prototype = Object.getPrototypeOf(probe); await probe.close(); + const original = prototype.stat; let count = 0; + t.mock.method(prototype, 'stat', async function (...args) { const result = await original.apply(this, args); count += 1; if (count === changeAt) result.size += 1; return result; }); + await assert.rejects(m.readPinFile(file), /pin_file_rejected/); + }); + } + test('pin growth during descriptor read is bounded', async (t) => { + const { parent } = await withWorkspace(t); const file = path.join(parent, 'pin.json'); await fs.writeFile(file, '{}'); + const probe = await fs.open(file, 'r'); const prototype = Object.getPrototypeOf(probe); await probe.close(); + t.mock.method(prototype, 'createReadStream', () => Readable.from([Buffer.from('{}extra')])); + await assert.rejects(m.readPinFile(file), /pin_file_rejected/); + }); +} + +test('the existing native Distribution matrix executes the offline acquisition tests', async () => { + const workflow = await fs.readFile(new URL('../../.github/workflows/ci.yml', import.meta.url), 'utf8'); + const job = workflow.split(' distribution-download-platform:')[1].split('\n distribution-owned-platform:')[0]; + for (const platform of ['ubuntu-latest', 'windows-2025', 'macos-15']) assert.ok(job.includes(platform)); + assert.ok(job.includes('node --test scripts/release/download_umxhq.test.mjs scripts/release/download_umxhq_cli.test.mjs')); + assert.ok(job.includes('cargo +stable test')); + assert.ok(!job.includes('download_umxhq.mjs --bootstrap')); +}); diff --git a/scripts/release/download_umxhq_cli.test.mjs b/scripts/release/download_umxhq_cli.test.mjs new file mode 100644 index 000000000..2a80c8a36 --- /dev/null +++ b/scripts/release/download_umxhq_cli.test.mjs @@ -0,0 +1,69 @@ +/** CLI dispatch must be explicit and must not acquire weights on import or help. */ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { existsSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +const target = new URL('./download_umxhq.mjs', import.meta.url); +test('a runnable download command exists', () => assert.ok(existsSync(target))); +if (existsSync(target)) { + const m = await import(target); + const invoke = (args) => spawnSync(process.execPath, [fileURLToPath(target), ...args], { encoding: 'utf8' }); + test('help runs without touching the network', () => { + const result = invoke(['--help']); assert.equal(result.status, 0); assert.match(result.stdout, /--bootstrap/); + }); + for (const args of [[], ['--url', 'https://evil.invalid'], ['--bootstrap'], ['--output', 'out'], ['--bootstrap', '--pin', 'x', '--output', 'out'], ['--bootstrap', '--output', 'out', '--output', 'other'], ['--pin'], ['--help', '--bootstrap'], ['--bootstrap', '--output', '--pin']]) { + test(`invalid CLI invocation fails before acquisition: ${args.join(' ')}`, () => { + const result = invoke(args); assert.equal(result.status, 2); assert.doesNotMatch(result.stderr, /evil.invalid/); + }); + } + test('bootstrap dispatches the fixed model acquisition and reports no release approval', async () => { + let options; let output; + const code = await m.runCli(['--bootstrap', '--output', 'new'], { + acquire: async (value) => { options = value; return { model_id: 'sigsep/umxhq', release_admitted: false }; }, + stdout: (value) => { output = JSON.parse(value); }, stderr: () => assert.fail(), + }); + assert.equal(code, 0); assert.equal(options.bootstrap, true); assert.equal(options.output, 'new'); + assert.equal(output.release_admitted, false); + }); + test('pin mode reads supplied receipt rather than computing pins on arbitrary local weights', async () => { + let options; + assert.equal(await m.runCli(['--pin', 'reviewed.json', '--output', 'new'], { + loadPin: async (file) => { assert.equal(file, 'reviewed.json'); return Buffer.from('{"proof":"independently supplied"}'); }, + acquire: async (value) => { options = value; return { model_id: 'sigsep/umxhq', release_admitted: false }; }, + stdout: () => {}, stderr: () => assert.fail(), + }), 0); + assert.equal(options.pins.proof, 'independently supplied'); + assert.equal(options.bootstrap, false); + }); + test('invalid pin JSON fails before acquisition', async () => { + const code = await m.runCli(['--pin', 'x', '--output', 'new'], { + loadPin: async () => Buffer.from('not JSON'), acquire: () => assert.fail(), stdout: () => assert.fail(), stderr: () => {}, + }); + assert.equal(code, 1); + }); + test('unexpected diagnostics are redacted', async () => { + let diagnostic; + const code = await m.runCli(['--bootstrap', '--output', 'new'], { + acquire: async () => { throw Error('/home/private_user/secret-key'); }, stdout: () => assert.fail(), stderr: (line) => { diagnostic = line; }, + }); + assert.equal(code, 1); assert.equal(diagnostic, 'model_download_failed: acquisition_failed'); + }); + test('cancellation is forwarded and handlers are removed', async () => { + const before = process.listenerCount('SIGINT'); let signal; + const code = await m.runCli(['--bootstrap', '--output', 'new'], { + acquire: async (options) => { signal = options.signal; process.emit('SIGINT'); assert.equal(signal.aborted, true); throw Error('cancelled'); }, + stdout: () => assert.fail(), stderr: () => {}, + }); + assert.equal(code, 130); assert.equal(process.listenerCount('SIGINT'), before); + }); + test('in-process argument errors and help never dispatch acquisition', async () => { + const options = { acquire: () => assert.fail(), stdout: () => {}, stderr: () => {} }; + assert.equal(await m.runCli([], options), 2); + assert.equal(await m.runCli(['--help'], options), 0); + }); + test('module-only import with no argv entry has no side effects', () => { + const result = spawnSync(process.execPath, ['--input-type=module', '-e', `await import(${JSON.stringify(target.href)})`], { encoding: 'utf8' }); + assert.equal(result.status, 0); assert.equal(result.stdout, ''); assert.equal(result.stderr, ''); + }); +} diff --git a/scripts/release/extract_release_artifacts.py b/scripts/release/extract_release_artifacts.py index 7b5355e3f..5c29e8873 100644 --- a/scripts/release/extract_release_artifacts.py +++ b/scripts/release/extract_release_artifacts.py @@ -1,4 +1,10 @@ -"""Safely extract zipped release artifacts downloaded by GitHub Actions.""" +"""Safely extract zipped release artifacts downloaded by GitHub Actions. + +Security Notes: + Extraction accepts only target-qualified BandScope installer, updater, receipt, + checksum, and manifest filenames. Paths, links, duplicate members, per-file and + aggregate byte budgets are fail-closed before publication selection sees them. +""" from __future__ import annotations @@ -11,12 +17,18 @@ from typing import IO RELEASE_MEMBER = re.compile( - r"^bandscope-(?:windows|macos)-(?:amd64|arm64)-[0-9a-f]{12}" - r"\.(?:exe|msi|dmg)(?:\.sha256|\.manifest\.txt)?$" + r"^bandscope-(?:windows|macos)-(?:amd64|arm64)-[0-9a-f]{12}(?:" + r"\.(?:exe|msi)(?:\.sha256|\.manifest\.txt|\.sig)?" + r"|\.dmg(?:\.sha256|\.manifest\.txt)?" + r"|\.app\.tar\.gz(?:\.sig)?" + r"|\.release-receipt\.json" + r")$" ) MAX_RELEASE_ARTIFACT_BYTES = 512 * 1024 * 1024 +MAX_UPDATER_SIGNATURE_BYTES = 64 * 1024 +MAX_RELEASE_RECEIPT_BYTES = 256 * 1024 MAX_TOTAL_RELEASE_ARTIFACT_BYTES = 4 * 1024 * 1024 * 1024 -MAX_RELEASE_ARTIFACT_FILES = 24 +MAX_RELEASE_ARTIFACT_FILES = 32 READ_CHUNK_BYTES = 64 * 1024 @@ -55,8 +67,17 @@ def artifact_zip_paths(source: Path) -> list[Path]: return candidates +def _member_byte_limit(member_name: str) -> int: + """Return the narrowest byte ceiling for one allowlisted release member.""" + if member_name.endswith(".sig"): + return MAX_UPDATER_SIGNATURE_BYTES + if member_name.endswith(".release-receipt.json"): + return MAX_RELEASE_RECEIPT_BYTES + return MAX_RELEASE_ARTIFACT_BYTES + + def validate_member(member: zipfile.ZipInfo) -> None: - """Reject unexpected or unsafe ZIP members.""" + """Reject unexpected, unsafe, or oversized ZIP members.""" member_path = Path(member.filename) unix_mode = member.external_attr >> 16 if ( @@ -67,7 +88,7 @@ def validate_member(member: zipfile.ZipInfo) -> None: or stat.S_ISLNK(unix_mode) ): raise ValueError(f"unexpected release artifact member: {member.filename}") - if member.file_size > MAX_RELEASE_ARTIFACT_BYTES: + if member.file_size > _member_byte_limit(member.filename): raise ValueError(f"release artifact member too large: {member.filename}") diff --git a/scripts/release/fixtures/openunmix-LICENSE.txt b/scripts/release/fixtures/openunmix-LICENSE.txt new file mode 100644 index 000000000..4267b01da --- /dev/null +++ b/scripts/release/fixtures/openunmix-LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Inria (Fabian-Robert Stöter, Antoine Liutkus) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/scripts/release/package_desktop_artifact.py b/scripts/release/package_desktop_artifact.py index 5617ce760..746d4aa18 100644 --- a/scripts/release/package_desktop_artifact.py +++ b/scripts/release/package_desktop_artifact.py @@ -1,14 +1,62 @@ -"""Package desktop build outputs into traceable release artifacts.""" +"""Package desktop build outputs into traceable release artifacts. + +Security Notes: +- version-tag packaging consumes only fixed local Tauri build-output directories + after the repository release-admission preflight succeeds; +- updater bundles and signatures are treated as untrusted build outputs: links, + non-regular/empty signatures, target drift, byte drift, and missing companions + fail closed before a release receipt is published; +- updater signature bytes are copied and digest-bound as evidence only. This + module does not invent signing keys or claim cryptographic signature validity; + Tauri/client verification and packaged acceptance remain separate gates. +""" from __future__ import annotations import hashlib +import json import os import platform import re import shutil +import stat +import subprocess +import sys +import tempfile from collections import Counter +from collections.abc import Callable, Sequence from pathlib import Path +from typing import Any, NamedTuple + +CommandRunner = Callable[..., Any] +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_FULL_GIT_SHA_RE = re.compile(r"^[0-9a-f]{40}$") +_MAX_UPDATER_SIGNATURE_BYTES = 64 * 1024 + + +class PackagedArtifact(NamedTuple): + """Identify one packaged installer and its supporting checksum/manifest evidence.""" + + platform: str + arch: str + target_triple: str + archive_name: str + checksum_name: str + manifest_name: str + + +class UpdaterArtifact(NamedTuple): + """Bind one Tauri updater bundle to its exact detached signature bytes.""" + + platform: str + arch: str + target_triple: str + bundle_name: str + bundle_size_bytes: int + bundle_sha256: str + signature_name: str + signature_size_bytes: int + signature_sha256: str def sha256_file(path: Path) -> str: @@ -20,6 +68,70 @@ def sha256_file(path: Path) -> str: return digest.hexdigest() +def _stable_regular_file_identity(path: Path) -> tuple[int, str]: + """Return size and digest from one stable regular-file descriptor.""" + if path.is_symlink(): + raise RuntimeError("release receipt artifact must not be a symlink") + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + try: + file_descriptor = os.open(path, flags) + except OSError as error: + raise RuntimeError("release receipt artifact could not be opened") from error + + try: + before = os.fstat(file_descriptor) + if not stat.S_ISREG(before.st_mode): + raise RuntimeError("release receipt artifact must be a regular file") + digest = hashlib.sha256() + with os.fdopen(file_descriptor, "rb", closefd=False) as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + after = os.fstat(file_descriptor) + before_identity = (before.st_dev, before.st_ino, before.st_size) + after_identity = (after.st_dev, after.st_ino, after.st_size) + if before_identity != after_identity: + raise RuntimeError("release receipt artifact changed while hashing") + return before.st_size, digest.hexdigest() + finally: + os.close(file_descriptor) + + +def _updater_source_identity( + path: Path, *, label: str, maximum_bytes: int | None = None +) -> tuple[int, str]: + """Return one stable updater-source identity with optional byte ceiling.""" + try: + size_bytes, digest = _stable_regular_file_identity(path) + except RuntimeError as error: + raise RuntimeError(f"{label} must be a regular non-link file") from error + if size_bytes < 1: + raise RuntimeError(f"{label} must not be empty") + if maximum_bytes is not None and size_bytes > maximum_bytes: + raise RuntimeError(f"{label} exceeds its bounded size policy") + return size_bytes, digest + + +def _copy_exact_updater_input( + source: Path, + destination: Path, + *, + label: str, + maximum_bytes: int | None = None, +) -> tuple[int, str]: + """Copy one admitted updater input and prove destination byte identity.""" + source_identity = _updater_source_identity( + source, label=label, maximum_bytes=maximum_bytes + ) + shutil.copy2(source, destination) + try: + destination_identity = _stable_regular_file_identity(destination) + except RuntimeError as error: + raise RuntimeError(f"copied {label} is not stable release evidence") from error + if destination_identity != source_identity: + raise RuntimeError(f"copied {label} does not match source bytes") + return source_identity + + def normalized_platform() -> str: """Return the normalized artifact platform label for the current environment.""" if artifact_platform := os.environ.get("BANDSCOPE_ARTIFACT_OS"): @@ -94,7 +206,11 @@ def find_installer_packages(repo_root: Path) -> list[Path]: installers = [] if bundle_dir.exists(): - for subdirectory, pattern in [("dmg", "*.dmg"), ("nsis", "*.exe"), ("msi", "*.msi")]: + for subdirectory, pattern in [ + ("dmg", "*.dmg"), + ("nsis", "*.exe"), + ("msi", "*.msi"), + ]: installers.extend( installer for installer in sorted((bundle_dir / subdirectory).glob(pattern)) @@ -104,9 +220,418 @@ def find_installer_packages(repo_root: Path) -> list[Path]: return sorted(installers) +def _is_tag_release() -> bool: + """Return whether this package operation belongs to a version-tag release build.""" + return os.environ.get("GITHUB_REF", "").startswith("refs/tags/v") + + +def verify_tag_release_preflight( + repo_root: Path, + *, + runner: CommandRunner = subprocess.run, +) -> None: + """Require version, model, and updater admission before tag artifact writes.""" + if not _is_tag_release(): + return + preflight_path = repo_root / "scripts" / "checks" / "verify_release_identity.py" + try: + result = runner([sys.executable, str(preflight_path)], check=False) + except OSError as verification_error: + raise RuntimeError("Tagged release preflight could not run") from verification_error + if result.returncode != 0: + raise RuntimeError("Tagged release preflight failed") + + +def _platform_trust_command(repo_root: Path, output_dir: Path) -> Sequence[str]: + """Build the fixed verifier command for the selected tagged release target.""" + verifier_path = repo_root / "scripts" / "checks" / "verify_release_platform_trust.py" + target_platform, _ = resolved_artifact_target() + if target_platform == "windows": + return [ + sys.executable, + str(verifier_path), + "windows", + str(output_dir), + "--expected-identity", + os.environ.get("BANDSCOPE_WINDOWS_PUBLISHER_SUBJECT", ""), + ] + if target_platform == "macos": + target_triple = os.environ.get("BANDSCOPE_TARGET_TRIPLE", "") + if not target_triple: + raise RuntimeError("Tagged macOS release packaging requires BANDSCOPE_TARGET_TRIPLE") + bundle_root = ( + repo_root + / "apps" + / "desktop" + / "src-tauri" + / "target" + / target_triple + / "release" + / "bundle" + / "macos" + ) + return [ + sys.executable, + str(verifier_path), + "macos", + str(output_dir), + "--bundle-root", + str(bundle_root), + "--expected-identity", + os.environ.get("BANDSCOPE_APPLE_TEAM_ID", ""), + ] + raise RuntimeError("Tagged release packaging is unsupported on this platform") + + +def verify_tag_platform_trust( + repo_root: Path, + output_dir: Path, + *, + runner: CommandRunner = subprocess.run, +) -> None: + """Block tagged artifact publication unless platform-native trust evidence passes.""" + if not _is_tag_release(): + return + command = _platform_trust_command(repo_root, output_dir) + try: + result = runner(list(command), check=False) + except OSError as verification_error: + raise RuntimeError("Platform release trust verification could not run") from verification_error + if result.returncode != 0: + raise RuntimeError("Platform release trust verification failed") + + +def _release_version(repo_root: Path) -> str: + """Return the single-line authoritative release version.""" + version_lines = (repo_root / "VERSION").read_text(encoding="utf-8").splitlines() + if len(version_lines) != 1 or not version_lines[0].strip(): + raise RuntimeError("release receipt requires one VERSION line") + version = version_lines[0].strip() + if version != version_lines[0]: + raise RuntimeError("release receipt VERSION must not contain surrounding whitespace") + return version + + +def _release_source_commit() -> str: + """Return the exact protected source commit carried by a tagged release receipt.""" + source_commit = os.environ.get("GITHUB_SHA", "").lower() + if not _FULL_GIT_SHA_RE.fullmatch(source_commit): + raise RuntimeError("Tagged release receipt requires exact 40-character GITHUB_SHA") + return source_commit + + +def _windows_updater_artifacts( + output_dir: Path, + source_artifacts: Sequence[tuple[Path, PackagedArtifact]], +) -> list[UpdaterArtifact]: + """Bind each v2 Windows installer to its adjacent Tauri `.sig` file.""" + updater_artifacts: list[UpdaterArtifact] = [] + for source_installer, packaged_artifact in source_artifacts: + if packaged_artifact.platform != "windows": + raise RuntimeError("Windows updater packaging cannot mix platform targets") + source_signature = Path(f"{source_installer}.sig") + signature_identity = _updater_source_identity( + source_signature, + label="updater signature", + maximum_bytes=_MAX_UPDATER_SIGNATURE_BYTES, + ) + packaged_bundle = output_dir / packaged_artifact.archive_name + source_bundle_identity = _updater_source_identity( + source_installer, label="Windows updater bundle" + ) + packaged_bundle_identity = _updater_source_identity( + packaged_bundle, label="packaged Windows updater bundle" + ) + if packaged_bundle_identity != source_bundle_identity: + raise RuntimeError("Windows updater bundle does not match packaged installer bytes") + + signature_name = f"{packaged_artifact.archive_name}.sig" + copied_signature_identity = _copy_exact_updater_input( + source_signature, + output_dir / signature_name, + label="updater signature", + maximum_bytes=_MAX_UPDATER_SIGNATURE_BYTES, + ) + if copied_signature_identity != signature_identity: + raise RuntimeError("copied updater signature identity drifted during packaging") + updater_artifacts.append( + UpdaterArtifact( + platform=packaged_artifact.platform, + arch=packaged_artifact.arch, + target_triple=packaged_artifact.target_triple, + bundle_name=packaged_artifact.archive_name, + bundle_size_bytes=packaged_bundle_identity[0], + bundle_sha256=packaged_bundle_identity[1], + signature_name=signature_name, + signature_size_bytes=signature_identity[0], + signature_sha256=signature_identity[1], + ) + ) + if not updater_artifacts: + raise RuntimeError("Tagged Windows release requires at least one updater bundle") + return updater_artifacts + + +def _macos_updater_artifacts( + repo_root: Path, + output_dir: Path, + source_artifacts: Sequence[tuple[Path, PackagedArtifact]], +) -> list[UpdaterArtifact]: + """Package the single v2 macOS `.app.tar.gz` updater bundle plus signature.""" + if not source_artifacts: + raise RuntimeError("Tagged macOS release requires a packaged installer target") + first = source_artifacts[0][1] + if first.platform != "macos": + raise RuntimeError("macOS updater packaging cannot mix platform targets") + expected_target = (first.platform, first.arch, first.target_triple) + if any( + (artifact.platform, artifact.arch, artifact.target_triple) != expected_target + for _, artifact in source_artifacts + ): + raise RuntimeError("macOS updater packaging cannot mix platform targets") + + target_triple = first.target_triple + if not target_triple or target_triple == "native": + raise RuntimeError("Tagged macOS updater packaging requires an exact target triple") + macos_bundle_root = ( + repo_root + / "apps" + / "desktop" + / "src-tauri" + / "target" + / target_triple + / "release" + / "bundle" + / "macos" + ) + candidates = sorted(macos_bundle_root.glob("*.app.tar.gz")) + if len(candidates) != 1: + raise RuntimeError("Tagged macOS release requires exactly one macOS updater bundle") + source_bundle = candidates[0] + source_signature = Path(f"{source_bundle}.sig") + bundle_identity = _updater_source_identity( + source_bundle, label="macOS updater bundle" + ) + signature_identity = _updater_source_identity( + source_signature, + label="updater signature", + maximum_bytes=_MAX_UPDATER_SIGNATURE_BYTES, + ) + + git_sha = _release_source_commit()[:12] + bundle_name = f"bandscope-macos-{first.arch}-{git_sha}.app.tar.gz" + signature_name = f"{bundle_name}.sig" + copied_bundle_identity = _copy_exact_updater_input( + source_bundle, + output_dir / bundle_name, + label="macOS updater bundle", + ) + copied_signature_identity = _copy_exact_updater_input( + source_signature, + output_dir / signature_name, + label="updater signature", + maximum_bytes=_MAX_UPDATER_SIGNATURE_BYTES, + ) + if copied_bundle_identity != bundle_identity: + raise RuntimeError("copied macOS updater bundle identity drifted during packaging") + if copied_signature_identity != signature_identity: + raise RuntimeError("copied updater signature identity drifted during packaging") + return [ + UpdaterArtifact( + platform=first.platform, + arch=first.arch, + target_triple=first.target_triple, + bundle_name=bundle_name, + bundle_size_bytes=bundle_identity[0], + bundle_sha256=bundle_identity[1], + signature_name=signature_name, + signature_size_bytes=signature_identity[0], + signature_sha256=signature_identity[1], + ) + ] + + +def package_tag_updater_artifacts( + repo_root: Path, + output_dir: Path, + source_artifacts: Sequence[tuple[Path, PackagedArtifact]], +) -> list[UpdaterArtifact]: + """Copy and bind Tauri v2 updater artifacts for the exact tagged target.""" + if not _is_tag_release(): + return [] + target_platform, _ = resolved_artifact_target() + if target_platform == "windows": + return _windows_updater_artifacts(output_dir, source_artifacts) + if target_platform == "macos": + return _macos_updater_artifacts(repo_root, output_dir, source_artifacts) + raise RuntimeError("Tagged updater artifact packaging is unsupported on this platform") + + +def _checksum_digest(checksum_path: Path, archive_name: str) -> str: + """Read the exact single-entry checksum file for one packaged artifact.""" + if checksum_path.is_symlink() or not checksum_path.is_file(): + raise RuntimeError("release receipt checksum must be a regular non-link file") + if checksum_path.stat().st_size > 512: + raise RuntimeError("release receipt checksum file is unexpectedly large") + text = checksum_path.read_text(encoding="utf-8") + match = re.fullmatch(r"([0-9a-f]{64}) ([^\r\n]+)\n", text) + if match is None or match.group(2) != archive_name: + raise RuntimeError("release receipt checksum file is malformed") + digest = match.group(1) + if not _SHA256_RE.fullmatch(digest): + raise RuntimeError("release receipt checksum is not SHA-256") + return digest + + +def _validate_support_file(path: Path, label: str) -> None: + """Require one supporting release file to remain a regular non-link file.""" + if path.is_symlink() or not path.is_file(): + raise RuntimeError(f"release receipt {label} must be a regular non-link file") + + +def _write_receipt_atomically(receipt_path: Path, payload: str) -> None: + """Publish receipt bytes atomically after flushing the staged file.""" + file_descriptor, staged_name = tempfile.mkstemp( + prefix=".release-receipt-", suffix=".tmp", dir=receipt_path.parent + ) + staged_path = Path(staged_name) + try: + with os.fdopen(file_descriptor, "w", encoding="utf-8", newline="\n") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.replace(staged_path, receipt_path) + finally: + if staged_path.exists(): + staged_path.unlink() + + +def _updater_receipt_entries( + output_dir: Path, + updater_artifacts: Sequence[UpdaterArtifact], + target_identity: tuple[str, str, str], +) -> list[dict[str, object]]: + """Re-admit copied updater bytes immediately before receipt publication.""" + entries: list[dict[str, object]] = [] + for updater_artifact in updater_artifacts: + if ( + updater_artifact.platform, + updater_artifact.arch, + updater_artifact.target_triple, + ) != target_identity: + raise RuntimeError("release receipt cannot mix updater platform targets") + bundle_identity = _updater_source_identity( + output_dir / updater_artifact.bundle_name, + label="updater bundle", + ) + if bundle_identity != ( + updater_artifact.bundle_size_bytes, + updater_artifact.bundle_sha256, + ): + raise RuntimeError("updater bundle changed after packaging") + signature_identity = _updater_source_identity( + output_dir / updater_artifact.signature_name, + label="updater signature", + maximum_bytes=_MAX_UPDATER_SIGNATURE_BYTES, + ) + if signature_identity != ( + updater_artifact.signature_size_bytes, + updater_artifact.signature_sha256, + ): + raise RuntimeError("updater signature changed after packaging") + entries.append( + { + "bundle": updater_artifact.bundle_name, + "sizeBytes": updater_artifact.bundle_size_bytes, + "sha256": updater_artifact.bundle_sha256, + "signatureFile": updater_artifact.signature_name, + "signatureSizeBytes": updater_artifact.signature_size_bytes, + "signatureSha256": updater_artifact.signature_sha256, + } + ) + return sorted(entries, key=lambda entry: str(entry["bundle"])) + + +def write_release_receipt( + repo_root: Path, + output_dir: Path, + packaged_artifacts: Sequence[PackagedArtifact], + updater_artifacts: Sequence[UpdaterArtifact] = (), +) -> Path | None: + """Bind trusted tagged installer and updater bytes to one machine-readable receipt.""" + if not _is_tag_release(): + return None + if not packaged_artifacts: + raise RuntimeError("Tagged release receipt requires at least one packaged artifact") + + version = _release_version(repo_root) + tag = os.environ.get("GITHUB_REF", "").removeprefix("refs/tags/") + if tag != f"v{version}": + raise RuntimeError("release receipt tag does not match VERSION") + source_commit = _release_source_commit() + + first = packaged_artifacts[0] + target_identity = (first.platform, first.arch, first.target_triple) + receipt_artifacts: list[dict[str, object]] = [] + for packaged_artifact in packaged_artifacts: + if ( + packaged_artifact.platform, + packaged_artifact.arch, + packaged_artifact.target_triple, + ) != target_identity: + raise RuntimeError("release receipt cannot mix platform targets") + + archive_path = output_dir / packaged_artifact.archive_name + checksum_path = output_dir / packaged_artifact.checksum_name + manifest_path = output_dir / packaged_artifact.manifest_name + _validate_support_file(manifest_path, "manifest") + expected_digest = _checksum_digest(checksum_path, packaged_artifact.archive_name) + size_bytes, actual_digest = _stable_regular_file_identity(archive_path) + if actual_digest != expected_digest: + raise RuntimeError("packaged artifact checksum does not match release receipt bytes") + receipt_artifacts.append( + { + "archive": packaged_artifact.archive_name, + "sizeBytes": size_bytes, + "sha256": actual_digest, + "checksumFile": packaged_artifact.checksum_name, + "manifestFile": packaged_artifact.manifest_name, + } + ) + + receipt: dict[str, object] = { + "schemaVersion": 1, + "version": version, + "tag": tag, + "sourceCommit": source_commit, + "target": { + "platform": first.platform, + "arch": first.arch, + "targetTriple": first.target_triple, + }, + "artifacts": sorted( + receipt_artifacts, key=lambda artifact: str(artifact["archive"]) + ), + } + if updater_artifacts: + receipt["updaterArtifacts"] = _updater_receipt_entries( + output_dir, updater_artifacts, target_identity + ) + receipt_name = ( + f"bandscope-{first.platform}-{first.arch}-{source_commit[:12]}" + ".release-receipt.json" + ) + receipt_path = output_dir / receipt_name + payload = json.dumps(receipt, indent=2, sort_keys=False) + "\n" + _write_receipt_atomically(receipt_path, payload) + return receipt_path + + def main() -> int: - """Find the built installer packages, rename them, and calculate checksums.""" + """Preflight, package installers/updater evidence, then verify tagged trust.""" repo_root = Path(__file__).resolve().parents[2] + verify_tag_release_preflight(repo_root) + output_dir = repo_root / "artifacts" output_dir.mkdir(parents=True, exist_ok=True) @@ -117,6 +642,8 @@ def main() -> int: ) suffix_counts = Counter(path.suffix.lower() for path in installers) + packaged_artifacts: list[PackagedArtifact] = [] + source_artifacts: list[tuple[Path, PackagedArtifact]] = [] for installer_path in installers: identity = artifact_identity(installer_path.name) archive_name = identity["archive_name"] @@ -131,19 +658,22 @@ def main() -> int: shutil.copy2(installer_path, archive_path) checksum_path = output_dir / f"{archive_name}.sha256" - checksum_path.write_text(f"{sha256_file(archive_path)} {archive_name}\n", encoding="utf-8") + checksum_path.write_text( + f"{sha256_file(archive_path)} {archive_name}\n", encoding="utf-8" + ) manifest_path = output_dir / ( f"{archive_name}.manifest.txt" if suffix_counts[installer_path.suffix.lower()] > 1 else identity["manifest_name"] ) + target_triple = os.environ.get("BANDSCOPE_TARGET_TRIPLE", "native") manifest_path.write_text( "\n".join( [ f"platform={identity['platform']}", f"arch={identity['arch']}", - f"target_triple={os.environ.get('BANDSCOPE_TARGET_TRIPLE', 'native')}", + f"target_triple={target_triple}", f"original_file={installer_path.name}", f"archive={archive_name}", f"checksum={checksum_path.name}", @@ -152,9 +682,29 @@ def main() -> int: + "\n", encoding="utf-8", ) + packaged_artifact = PackagedArtifact( + platform=identity["platform"], + arch=identity["arch"], + target_triple=target_triple, + archive_name=archive_name, + checksum_name=checksum_path.name, + manifest_name=manifest_path.name, + ) + packaged_artifacts.append(packaged_artifact) + source_artifacts.append((installer_path, packaged_artifact)) print(f"Packaged {installer_path.name} to artifacts/{archive_name}") + updater_artifacts = package_tag_updater_artifacts( + repo_root, output_dir, source_artifacts + ) + verify_tag_platform_trust(repo_root, output_dir) + write_release_receipt( + repo_root, + output_dir, + packaged_artifacts, + updater_artifacts, + ) return 0 diff --git a/scripts/release/select_release_assets.py b/scripts/release/select_release_assets.py index 114ed2763..dc00027d7 100644 --- a/scripts/release/select_release_assets.py +++ b/scripts/release/select_release_assets.py @@ -1,13 +1,24 @@ -"""Select a strict allowlist of release assets for immutable publication.""" +"""Select and re-admit a strict release graph for immutable publication. + +Security Notes: + The publisher accepts only target-qualified BandScope installer/updater/receipt + names for the exact release commit. Target receipts are bounded, duplicate-key + rejecting, and their installer/updater size+SHA-256 bindings are revalidated + after GitHub artifact upload/download extraction. No receipt field becomes a + filesystem path unless it first matches the already allowlisted target files. +""" from __future__ import annotations import argparse +import hashlib +import json import os import re +import stat import sys from pathlib import Path -from typing import Iterable +from typing import Any, Iterable TARGET_INSTALLER_SUFFIXES = { ("windows", "amd64"): {".exe", ".msi"}, @@ -19,20 +30,37 @@ Path("bandscope-sbom.cdx.json"), Path("supply-chain/supplemental-component-inventory.json"), ] +_FULL_SHA_RE = re.compile(r"^[0-9a-f]{40}$") +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_MAX_RECEIPT_BYTES = 256 * 1024 +_MAX_UPDATER_SIGNATURE_BYTES = 64 * 1024 +_RECEIPT_KEYS = frozenset( + {"schemaVersion", "version", "tag", "sourceCommit", "target", "artifacts", "updaterArtifacts"} +) +_INSTALLER_RECEIPT_KEYS = frozenset( + {"archive", "sizeBytes", "sha256", "checksumFile", "manifestFile"} +) +_UPDATER_RECEIPT_KEYS = frozenset( + {"bundle", "sizeBytes", "sha256", "signatureFile", "signatureSizeBytes", "signatureSha256"} +) +_TARGET_RECEIPT_KEYS = frozenset({"platform", "arch", "targetTriple"}) def _artifact_pattern(git_sha: str) -> re.Pattern[str]: - """Return the strict artifact filename pattern for a release commit.""" + """Return the strict installer/updater/receipt filename pattern for one commit.""" short_sha = re.escape(git_sha[:12]) return re.compile( - rf"^bandscope-(?Pwindows|macos)-(?Pamd64|arm64)-{short_sha}" - r"(?P\.(?:exe|msi|dmg))" - r"(?P\.sha256|\.manifest\.txt)?$" + rf"^bandscope-(?Pwindows|macos)-(?Pamd64|arm64)-{short_sha}(?:" + r"(?P\.(?:exe|msi|dmg))(?P\.sha256|\.manifest\.txt)?" + r"|(?P\.(?:exe|msi)\.sig)" + r"|(?P\.app\.tar\.gz)(?P\.sig)?" + r"|(?P\.release-receipt\.json)" + r")$" ) def _installer_name_for_artifact(filename: str) -> str: - """Return the installer archive filename for a sidecar or archive filename.""" + """Return the installer archive filename for a checksum/manifest or archive name.""" for suffix in [".manifest.txt", ".sha256"]: if filename.endswith(suffix): return filename[: -len(suffix)] @@ -45,16 +73,249 @@ def _ensure_file(path: Path) -> None: raise ValueError(f"missing release asset: {path.as_posix()}") -def select_release_assets(repo_root: Path, git_sha: str | None = None) -> list[str]: - """Return release asset paths after rejecting stray or incomplete artifacts. +def _reject_duplicate_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + """Build a JSON object while rejecting parser-dependent duplicate members.""" + document: dict[str, Any] = {} + for key, value in pairs: + if key in document: + raise ValueError(f"duplicate release receipt member: {key}") + document[key] = value + return document + + +def _stable_file_identity( + path: Path, + *, + label: str, + maximum_bytes: int | None = None, +) -> tuple[int, str]: + """Return exact size/digest for one stable regular non-link publication file.""" + if path.is_symlink(): + raise ValueError(f"{label} must be a regular non-link file") + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags) + except OSError as error: + raise ValueError(f"{label} could not be opened") from error + try: + before = os.fstat(descriptor) + if not stat.S_ISREG(before.st_mode): + raise ValueError(f"{label} must be a regular non-link file") + if maximum_bytes is not None and before.st_size > maximum_bytes: + raise ValueError(f"{label} exceeds its bounded size policy") + digest = hashlib.sha256() + read_bytes = 0 + with os.fdopen(descriptor, "rb", closefd=False) as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + read_bytes += len(chunk) + if maximum_bytes is not None and read_bytes > maximum_bytes: + raise ValueError(f"{label} exceeds its bounded size policy") + digest.update(chunk) + after = os.fstat(descriptor) + if (before.st_dev, before.st_ino, before.st_size) != ( + after.st_dev, + after.st_ino, + after.st_size, + ) or read_bytes != before.st_size: + raise ValueError(f"{label} changed while being read") + return before.st_size, digest.hexdigest() + finally: + os.close(descriptor) + + +def _load_receipt(path: Path) -> dict[str, Any]: + """Load one bounded target receipt with duplicate-member rejection.""" + size_bytes, _ = _stable_file_identity( + path, label="release receipt", maximum_bytes=_MAX_RECEIPT_BYTES + ) + if size_bytes < 1: + raise ValueError("release receipt must not be empty") + try: + raw = path.read_bytes() + if len(raw) != size_bytes: + raise ValueError("release receipt changed after admission") + document = json.loads( + raw.decode("utf-8"), object_pairs_hook=_reject_duplicate_pairs + ) + except (UnicodeError, json.JSONDecodeError) as error: + raise ValueError("release receipt is not valid UTF-8 JSON") from error + if not isinstance(document, dict) or frozenset(document) != _RECEIPT_KEYS: + raise ValueError("release receipt keys do not match the versioned contract") + return document + + +def _exact_nonnegative_int(value: Any, *, field_name: str) -> int: + """Return an exact nonnegative integer without accepting booleans/coercion.""" + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ValueError(f"release receipt {field_name} must be a nonnegative integer") + return value + + +def _exact_sha256(value: Any, *, field_name: str) -> str: + """Return one lowercase full SHA-256 receipt field.""" + if not isinstance(value, str) or _SHA256_RE.fullmatch(value) is None: + raise ValueError(f"release receipt {field_name} must be a full SHA-256") + return value + + +def _checksum_digest(path: Path, archive_name: str) -> str: + """Return the exact digest from one packaged checksum sidecar.""" + _ensure_file(path) + if path.stat().st_size > 512: + raise ValueError(f"release checksum is unexpectedly large: {path.name}") + try: + text = path.read_text(encoding="utf-8") + except UnicodeError as error: + raise ValueError(f"release checksum is not UTF-8: {path.name}") from error + match = re.fullmatch(r"([0-9a-f]{64}) ([^\r\n]+)\n", text) + if match is None or match.group(2) != archive_name: + raise ValueError(f"release checksum is malformed: {path.name}") + return match.group(1) + + +def _validate_receipt_identity( + receipt: dict[str, Any], + *, + target: tuple[str, str], + git_sha: str, +) -> None: + """Validate receipt version/source/target authority before resolving file names.""" + if receipt.get("schemaVersion") != 1: + raise ValueError("release receipt schemaVersion must equal 1") + version = receipt.get("version") + tag = receipt.get("tag") + if not isinstance(version, str) or not version or version != version.strip(): + raise ValueError("release receipt version must be a non-empty trimmed string") + if tag != f"v{version}": + raise ValueError("release receipt tag does not match its version") + source_commit = receipt.get("sourceCommit") + if not isinstance(source_commit, str) or _FULL_SHA_RE.fullmatch(source_commit) is None: + raise ValueError("release receipt sourceCommit must be a full Git SHA") + if source_commit[:12] != git_sha[:12] or (len(git_sha) == 40 and source_commit != git_sha): + raise ValueError("release receipt sourceCommit does not match release publication head") + receipt_target = receipt.get("target") + if not isinstance(receipt_target, dict) or frozenset(receipt_target) != _TARGET_RECEIPT_KEYS: + raise ValueError("release receipt target does not match the versioned contract") + if (receipt_target.get("platform"), receipt_target.get("arch")) != target: + raise ValueError("release receipt target does not match its filename target") + target_triple = receipt_target.get("targetTriple") + if not isinstance(target_triple, str) or not target_triple or target_triple != target_triple.strip(): + raise ValueError("release receipt targetTriple must be a non-empty trimmed string") + + +def _validate_installer_entries( + artifacts_dir: Path, + receipt: dict[str, Any], + expected_installers: set[str], +) -> None: + """Re-admit every receipt-bound installer and checksum after artifact transfer.""" + entries = receipt.get("artifacts") + if not isinstance(entries, list) or not entries: + raise ValueError("release receipt must contain installer artifacts") + seen: set[str] = set() + for entry in entries: + if not isinstance(entry, dict) or frozenset(entry) != _INSTALLER_RECEIPT_KEYS: + raise ValueError("release receipt installer entry keys are invalid") + archive = entry.get("archive") + if not isinstance(archive, str) or archive not in expected_installers or archive in seen: + raise ValueError("release receipt installer set does not match extracted artifacts") + seen.add(archive) + checksum_name = entry.get("checksumFile") + manifest_name = entry.get("manifestFile") + if checksum_name != f"{archive}.sha256" or manifest_name != f"{archive}.manifest.txt": + raise ValueError("release receipt installer sidecars do not match archive") + _ensure_file(artifacts_dir / str(manifest_name)) + expected_digest = _checksum_digest(artifacts_dir / str(checksum_name), archive) + size_bytes, digest = _stable_file_identity( + artifacts_dir / archive, label="release receipt installer" + ) + if size_bytes != _exact_nonnegative_int(entry.get("sizeBytes"), field_name="sizeBytes"): + raise ValueError("release receipt installer size does not match extracted bytes") + receipt_digest = _exact_sha256(entry.get("sha256"), field_name="sha256") + if digest != receipt_digest or digest != expected_digest: + raise ValueError("release receipt installer digest does not match extracted bytes") + if seen != expected_installers: + raise ValueError("release receipt does not cover every extracted installer") + + +def _validate_updater_entries( + artifacts_dir: Path, + receipt: dict[str, Any], + expected_updaters: dict[str, str], +) -> None: + """Re-admit updater bundle/signature bytes bound by the target receipt.""" + entries = receipt.get("updaterArtifacts") + if not isinstance(entries, list) or not entries: + raise ValueError("release receipt must contain updaterArtifacts") + seen: set[str] = set() + for entry in entries: + if not isinstance(entry, dict) or frozenset(entry) != _UPDATER_RECEIPT_KEYS: + raise ValueError("release receipt updater entry keys are invalid") + bundle = entry.get("bundle") + signature = entry.get("signatureFile") + if ( + not isinstance(bundle, str) + or not isinstance(signature, str) + or expected_updaters.get(bundle) != signature + or bundle in seen + ): + raise ValueError("release receipt updater set does not match extracted artifacts") + seen.add(bundle) + bundle_size, bundle_digest = _stable_file_identity( + artifacts_dir / bundle, label="release receipt updater bundle" + ) + if bundle_size != _exact_nonnegative_int(entry.get("sizeBytes"), field_name="sizeBytes"): + raise ValueError("release receipt updater bundle size does not match extracted bytes") + if bundle_digest != _exact_sha256(entry.get("sha256"), field_name="sha256"): + raise ValueError("release receipt updater bundle digest does not match extracted bytes") + signature_size, signature_digest = _stable_file_identity( + artifacts_dir / signature, + label="release receipt updater signature", + maximum_bytes=_MAX_UPDATER_SIGNATURE_BYTES, + ) + if signature_size < 1: + raise ValueError("release receipt updater signature must not be empty") + if signature_size != _exact_nonnegative_int( + entry.get("signatureSizeBytes"), field_name="signatureSizeBytes" + ): + raise ValueError("release receipt updater signature size does not match extracted bytes") + if signature_digest != _exact_sha256( + entry.get("signatureSha256"), field_name="signatureSha256" + ): + raise ValueError("release receipt updater signature digest does not match extracted bytes") + if seen != set(expected_updaters): + raise ValueError("release receipt does not cover every extracted updater bundle") + + +def _validate_target_receipt( + artifacts_dir: Path, + receipt_path: Path, + *, + target: tuple[str, str], + git_sha: str, + installers: set[str], + updater_bundles: dict[str, str], +) -> None: + """Validate one target receipt against the exact extracted publication bytes.""" + receipt = _load_receipt(receipt_path) + _validate_receipt_identity(receipt, target=target, git_sha=git_sha) + _validate_installer_entries(artifacts_dir, receipt, installers) + _validate_updater_entries(artifacts_dir, receipt, updater_bundles) + + +def _normalized_git_sha(git_sha: str | None) -> str: + """Return 12- or 40-hex release identity, preferring the full Actions SHA.""" + value = (git_sha or os.environ.get("GITHUB_SHA") or "").lower() + if re.fullmatch(r"[0-9a-f]{12}|[0-9a-f]{40}", value) is None: + raise ValueError("release asset selection requires a 12- or 40-hex Git SHA") + return value + - The returned paths are relative to ``repo_root`` and safe to pass directly to - ``gh release create``. Any unexpected file in ``artifacts/`` fails closed so - public releases cannot accidentally attach debug, cache, or poisoned files. - """ - effective_sha = (git_sha or os.environ.get("GITHUB_SHA") or "local")[:12] +def select_release_assets(repo_root: Path, git_sha: str | None = None) -> list[str]: + """Return publication assets after target graph and receipt re-admission.""" + effective_sha = _normalized_git_sha(git_sha) artifacts_dir = repo_root / "artifacts" - if not artifacts_dir.is_dir(): + if not artifacts_dir.is_dir() or artifacts_dir.is_symlink(): raise ValueError("missing release artifact directory: artifacts") for metadata_path in RELEASE_METADATA: @@ -65,36 +326,62 @@ def select_release_assets(repo_root: Path, git_sha: str | None = None) -> list[s target: set() for target in TARGET_INSTALLER_SUFFIXES } sidecars_by_installer: dict[str, set[str]] = {} + windows_signatures: dict[tuple[str, str], set[str]] = { + target: set() for target in TARGET_INSTALLER_SUFFIXES if target[0] == "windows" + } + mac_bundles: dict[tuple[str, str], set[str]] = { + target: set() for target in TARGET_INSTALLER_SUFFIXES if target[0] == "macos" + } + mac_signatures: dict[tuple[str, str], set[str]] = { + target: set() for target in TARGET_INSTALLER_SUFFIXES if target[0] == "macos" + } + receipts: dict[tuple[str, str], list[Path]] = { + target: [] for target in TARGET_INSTALLER_SUFFIXES + } selected_artifacts: list[str] = [] for artifact_path in sorted(artifacts_dir.iterdir(), key=lambda path: path.name): if artifact_path.is_symlink() or not artifact_path.is_file(): raise ValueError(f"unexpected release artifact path: {artifact_path.name}") - match = pattern.fullmatch(artifact_path.name) if match is None: raise ValueError(f"unexpected release artifact: {artifact_path.name}") - platform_name = match.group("platform") - arch = match.group("arch") - target = (platform_name, arch) + target = (match.group("platform"), match.group("arch")) installer_suffix = match.group("installer_suffix") - if installer_suffix not in TARGET_INSTALLER_SUFFIXES[target]: - raise ValueError( - f"unexpected installer suffix for {platform_name}-{arch}: {artifact_path.name}" - ) - - installer_name = _installer_name_for_artifact(artifact_path.name) - sidecar = match.group("sidecar") - if sidecar is None: - installers_by_target[target].add(installer_name) + if installer_suffix is not None: + if installer_suffix not in TARGET_INSTALLER_SUFFIXES[target]: + raise ValueError( + f"unexpected installer suffix for {target[0]}-{target[1]}: {artifact_path.name}" + ) + installer_name = _installer_name_for_artifact(artifact_path.name) + sidecar = match.group("sidecar") + if sidecar is None: + installers_by_target[target].add(installer_name) + else: + sidecars_by_installer.setdefault(installer_name, set()).add(sidecar) + elif match.group("windows_signature") is not None: + if target[0] != "windows": + raise ValueError(f"unexpected Windows updater signature: {artifact_path.name}") + windows_signatures[target].add(artifact_path.name) + elif match.group("mac_bundle") is not None: + if target[0] != "macos": + raise ValueError(f"unexpected macOS updater artifact: {artifact_path.name}") + if match.group("mac_signature") is None: + mac_bundles[target].add(artifact_path.name) + else: + mac_signatures[target].add(artifact_path.name) + elif match.group("receipt") is not None: + receipts[target].append(artifact_path) else: - sidecars_by_installer.setdefault(installer_name, set()).add(sidecar) + raise ValueError(f"unexpected release artifact: {artifact_path.name}") selected_artifacts.append(f"artifacts/{artifact_path.name}") - for platform_name, arch in TARGET_INSTALLER_SUFFIXES: - if not installers_by_target[(platform_name, arch)]: - raise ValueError(f"missing installer for {platform_name}-{arch}") + for target in TARGET_INSTALLER_SUFFIXES: + if not installers_by_target[target]: + raise ValueError(f"missing installer for {target[0]}-{target[1]}") + if len(receipts[target]) != 1: + raise ValueError(f"expected one release receipt for {target[0]}-{target[1]}") installer_names = set().union(*installers_by_target.values()) for installer_name in sorted(installer_names): @@ -103,11 +390,36 @@ def select_release_assets(repo_root: Path, git_sha: str | None = None) -> list[s raise ValueError(f"missing checksum for {installer_name}") if ".manifest.txt" not in sidecars: raise ValueError(f"missing manifest for {installer_name}") - for installer_name in sorted(sidecars_by_installer): if installer_name not in installer_names: raise ValueError(f"sidecar without installer: {installer_name}") + expected_updaters: dict[tuple[str, str], dict[str, str]] = {} + for target, installers in installers_by_target.items(): + if target[0] == "windows": + expected = {installer: f"{installer}.sig" for installer in installers} + if set(expected.values()) != windows_signatures[target]: + raise ValueError(f"Windows updater signatures incomplete for {target[0]}-{target[1]}") + expected_updaters[target] = expected + continue + if len(mac_bundles[target]) != 1: + raise ValueError(f"expected one macOS updater bundle for {target[0]}-{target[1]}") + bundle = next(iter(mac_bundles[target])) + expected_signature = f"{bundle}.sig" + if mac_signatures[target] != {expected_signature}: + raise ValueError(f"macOS updater signature incomplete for {target[0]}-{target[1]}") + expected_updaters[target] = {bundle: expected_signature} + + for target in TARGET_INSTALLER_SUFFIXES: + _validate_target_receipt( + artifacts_dir, + receipts[target][0], + target=target, + git_sha=effective_sha, + installers=installers_by_target[target], + updater_bundles=expected_updaters[target], + ) + return [ *sorted(selected_artifacts), *(path.as_posix() for path in RELEASE_METADATA), diff --git a/scripts/release/umxhq_acquisition.mjs b/scripts/release/umxhq_acquisition.mjs new file mode 100644 index 000000000..c626c9443 --- /dev/null +++ b/scripts/release/umxhq_acquisition.mjs @@ -0,0 +1,212 @@ +/** Distribution build tooling only: acquired bytes never imply release admission. */ +import https from 'node:https'; +import { rootCertificates } from 'node:tls'; +import { createHash } from 'node:crypto'; +import { constants } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import path from 'node:path'; + +export const MODEL_ID = 'sigsep/umxhq'; +export const MODEL_RECORD = '10.5281/zenodo.3370489'; +export const RECORD_URL = 'https://zenodo.org/api/records/3370489'; +export const NOTICE_URL = 'https://raw.githubusercontent.com/sigsep/open-unmix-pytorch/814f144e34b2d1ed517eb605ce928dcb838abbed/LICENSE'; +export const NOTICE_SHA256 = '4f7b047ffafb9fbb39a40d605bab961b9b030711addce6e9d23886c2ae3b105e'; +export const MAX_WEIGHT_BYTES = 64 * 1024 * 1024; +export const MAX_RECORD_BYTES = 1024 * 1024; +export const FILENAMES = Object.freeze({ + vocals: 'vocals-b62c91ce.pth', bass: 'bass-8d85a5bd.pth', + drums: 'drums-9619578f.pth', other: 'other-b52fbbf7.pth', +}); +const RECEIPT_KIND = 'candidate_acquisition_receipt_not_release_manifest'; +const weightUrl = (name) => `https://zenodo.org/records/3370489/files/${name}?download=1`; +const SOURCES = new Set([RECORD_URL, NOTICE_URL, ...Object.values(FILENAMES).map(weightUrl)]); +const digest = (data) => createHash('sha256').update(data).digest('hex'); +const cancelled = (signal) => { if (signal?.aborted) throw Error('cancelled'); }; +const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value); +const validSize = (value) => Number.isSafeInteger(value) && value > 0 && value <= MAX_WEIGHT_BYTES; +const decodeJson = (bytes) => JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); + +/** No URL supplied by remote metadata can reach this transport. */ +export async function fetchSource(url, { signal } = {}) { + cancelled(signal); + if (!SOURCES.has(url)) throw Error('source_rejected'); + if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0') throw Error('tls_environment_rejected'); + return new Promise((resolve, reject) => { + try { + const request = https.get(url, { + agent: false, rejectUnauthorized: true, ca: rootCertificates, minVersion: 'TLSv1.2', + maxHeaderSize: 16384, signal, + headers: { 'accept-encoding': 'identity', 'user-agent': 'BandScope-model-acquisition/1' }, + }, resolve); + request.on('error', () => reject(Error(signal?.aborted ? 'cancelled' : 'network_failed'))); + } catch { + reject(Error('network_failed')); + } + }); +} + +/** Bound raw bytes before a sink sees them; never follow or decompress responses. */ +export async function readResponse(response, { limit, expectedSize = null, signal, sink = null }) { + let size = 0; const chunks = []; + try { + cancelled(signal); + const headers = response.headers; + if (response.statusCode !== 200 || !object(headers) + || (headers['content-encoding'] !== undefined && headers['content-encoding'] !== 'identity') + || headers.location !== undefined + || (headers['content-type'] !== undefined && (typeof headers['content-type'] !== 'string' + || !/^(application\/(octet-stream|json)|text\/plain)(;|$)/i.test(headers['content-type'])))) { + throw Error('response_rejected'); + } + const length = headers['content-length']; + if (length !== undefined && (typeof length !== 'string' || !/^(0|[1-9][0-9]*)$/.test(length) + || !Number.isSafeInteger(Number(length)) || Number(length) > limit + || (expectedSize !== null && Number(length) !== expectedSize))) throw Error('response_rejected'); + for await (const chunk of response) { + cancelled(signal); + if (!Buffer.isBuffer(chunk)) throw Error('response_rejected'); + size += chunk.length; + if (size > limit || (expectedSize !== null && size > expectedSize)) throw Error('size_mismatch'); + if (sink) await sink(chunk); else chunks.push(chunk); + } + cancelled(signal); + if ((expectedSize !== null && size !== expectedSize) + || (length !== undefined && size !== Number(length))) throw Error('size_mismatch'); + return sink ? size : Buffer.concat(chunks, size); + } catch (error) { + if (['cancelled', 'response_rejected', 'size_mismatch'].includes(error.message)) throw error; + throw Error('transfer_failed'); + } finally { response.destroy(); } +} + +/** Select only the original four targets; publisher URLs and extra archives are ignored. */ +export function parseRecord(bytes) { + try { + if (!Buffer.isBuffer(bytes) || bytes.length > MAX_RECORD_BYTES) throw Error(); + const record = decodeJson(bytes); + if (record.id !== 3370489 || record.metadata?.doi !== MODEL_RECORD + || record.metadata?.version !== '1.0.1' || record.metadata?.license?.id !== 'mit-license' + || !Array.isArray(record.files)) throw Error(); + return Object.entries(FILENAMES).map(([stem, filename]) => { + const matches = record.files.filter((entry) => entry?.key === filename); + if (matches.length !== 1) throw Error(); + const entry = matches[0]; + if (!validSize(entry.size) || typeof entry.checksum !== 'string' + || !/^md5:[0-9a-f]{32}$/.test(entry.checksum)) throw Error(); + return { stem, filename, size_bytes: entry.size, md5: entry.checksum.slice(4), source_url: weightUrl(filename) }; + }); + } catch { throw Error('record_rejected'); } +} + +/** Snapshot independently reviewed pins before making requests; never admit a release. */ +export function validatePins(pins) { + try { + if (!object(pins) || pins.record_kind !== RECEIPT_KIND || pins.release_admitted !== false + || pins.model_id !== MODEL_ID || pins.model_record !== MODEL_RECORD || pins.model_version !== '1.0.1' + || !object(pins.checkpoints) || Object.keys(pins.checkpoints).length !== 4) throw Error(); + return Object.fromEntries(Object.entries(FILENAMES).map(([stem, filename]) => { + const pin = pins.checkpoints[stem]; + if (!object(pin) || pin.filename !== filename || !validSize(pin.size_bytes) + || typeof pin.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(pin.sha256)) throw Error(); + return [stem, { filename, size_bytes: pin.size_bytes, sha256: pin.sha256 }]; + })); + } catch { throw Error('pins_rejected'); } +} + +/** Use one non-link regular descriptor and a bounded read for local pin input. */ +export async function readPinFile(filename) { + let handle; + try { + const before = await fs.lstat(filename); + if (!before.isFile() || before.isSymbolicLink() || before.size > MAX_RECORD_BYTES || before.nlink !== 1) throw Error(); + handle = await fs.open(filename, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + const opened = await handle.stat(); + if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino || opened.size !== before.size) throw Error(); + const chunks = []; let length = 0; + for await (const chunk of handle.createReadStream({ autoClose: false })) { + length += chunk.length; + if (length > MAX_RECORD_BYTES || length > opened.size) throw Error(); + chunks.push(chunk); + } + const after = await handle.stat(); + if (length !== opened.size || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs) throw Error(); + return Buffer.concat(chunks, length); + } catch { throw Error('pin_file_rejected'); } + finally { if (handle) await handle.close(); } +} + +/** Check our private acquisition directory, not arbitrary content discovered remotely. */ +async function checkDirectory(directory, identity) { + const current = await fs.lstat(directory); + if (!current.isDirectory() || current.isSymbolicLink() + || current.dev !== identity.dev || current.ino !== identity.ino) throw Error('directory_changed'); +} + +/** Publish a completed file without replacing any existing path. */ +async function publishFile(directory, identity, filename, write) { + await checkDirectory(directory, identity); + const partial = path.join(directory, `${filename}.part`); const final = path.join(directory, filename); + const handle = await fs.open(partial, 'wx', 0o600); + try { + await write(handle); + await handle.sync(); + const opened = await handle.stat(); const current = await fs.lstat(partial); + if (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1 + || current.dev !== opened.dev || current.ino !== opened.ino || current.size !== opened.size) throw Error('file_changed'); + await checkDirectory(directory, identity); + await fs.link(partial, final); + } finally { await handle.close(); } + await fs.unlink(partial); +} + +/** Explicit build/qualification acquisition, never called by ordinary audio analysis. */ +export async function acquireUmxhq({ output, bootstrap = false, pins = null, signal, source = fetchSource }) { + if ((bootstrap !== true && pins === null) || (bootstrap === true && pins !== null)) throw Error('mode_required'); + const expected = pins === null ? null : validatePins(pins); + cancelled(signal); + const requested = path.resolve(output); + const directory = path.join(await fs.realpath(path.dirname(requested)), path.basename(requested)); + try { await fs.mkdir(directory, { mode: 0o700 }); } + catch (error) { throw Error(error.code === 'EEXIST' ? 'destination_exists' : 'destination_unavailable'); } + const identity = await fs.lstat(directory); + const writeBytes = (name, bytes) => publishFile(directory, identity, name, (handle) => handle.writeFile(bytes)); + // Failed or interrupted acquisitions intentionally remain inspectable but never loadable. + await fs.writeFile(path.join(directory, 'INCOMPLETE'), 'Not a model release. Do not load.\n', { flag: 'wx', mode: 0o600 }); + const rawRecord = await readResponse(await source(RECORD_URL, { signal }), { limit: MAX_RECORD_BYTES, signal }); + const descriptors = parseRecord(rawRecord); + if (expected && descriptors.some((d) => d.size_bytes !== expected[d.stem].size_bytes)) throw Error('pinned_size_mismatch'); + await writeBytes('zenodo-record.json', rawRecord); + const notice = await readResponse(await source(NOTICE_URL, { signal }), { limit: 16 * 1024, signal }); + if (digest(notice) !== NOTICE_SHA256) throw Error('notice_mismatch'); + await writeBytes('LICENSE.openunmix', notice); + const checkpoints = {}; + for (const descriptor of descriptors) { + cancelled(signal); + const sha256 = createHash('sha256'); const md5 = createHash('md5'); let count = 0; + await publishFile(directory, identity, descriptor.filename, async (handle) => { + count = await readResponse(await source(descriptor.source_url, { signal }), { + limit: MAX_WEIGHT_BYTES, expectedSize: descriptor.size_bytes, signal, + sink: async (chunk) => { await handle.writeFile(chunk); sha256.update(chunk); md5.update(chunk); }, + }); + const fullDigest = sha256.digest('hex'); + if (md5.digest('hex') !== descriptor.md5) throw Error('checksum_mismatch'); + if (expected && fullDigest !== expected[descriptor.stem].sha256) throw Error('sha256_mismatch'); + checkpoints[descriptor.stem] = { filename: descriptor.filename, size_bytes: count, sha256: fullDigest, + publisher_md5: descriptor.md5, source_url: descriptor.source_url }; + }); + } + const receipt = { + schema_version: 1, record_kind: RECEIPT_KIND, model_id: MODEL_ID, model_record: MODEL_RECORD, + model_version: '1.0.1', release_admitted: false, + verification_mode: expected ? 'sha256_pinned_reacquisition' : 'publisher_checksum_bootstrap', + acquired_at: new Date().toISOString(), node_version: process.version, + rights_evidence: { source_url: RECORD_URL, filename: 'zenodo-record.json', sha256: digest(rawRecord), + weights_license: 'MIT', separate_oem_contract_required: false }, + notice: { source_url: NOTICE_URL, filename: 'LICENSE.openunmix', sha256: NOTICE_SHA256 }, checkpoints, + }; + cancelled(signal); + await writeBytes('acquisition-receipt.json', `${JSON.stringify(receipt, null, 2)}\n`); + await checkDirectory(directory, identity); + await fs.unlink(path.join(directory, 'INCOMPLETE')); + return receipt; +} diff --git a/scripts/release/verify_hosted_release_assets.py b/scripts/release/verify_hosted_release_assets.py new file mode 100644 index 000000000..3a7d03aaa --- /dev/null +++ b/scripts/release/verify_hosted_release_assets.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Verify downloaded hosted release assets against the admitted local upload set. + +Security Notes: + GitHub release downloads are untrusted publication evidence. This verifier + accepts only an explicit bounded list of repository-relative local assets, + maps each to one unique hosted basename, rejects links/directories/extra + downloads, and compares size plus streaming SHA-256 from stable regular-file + descriptors. It does not authenticate GitHub itself or replace platform/ + updater signature verification; it proves only that the bytes downloaded + from the release asset namespace equal the bytes admitted for upload. +""" + +from __future__ import annotations + +import argparse +import hashlib +import os +import stat +import sys +from pathlib import Path, PurePosixPath + +_MAX_ASSET_LIST_BYTES = 256 * 1024 +_MAX_ASSET_COUNT = 256 + + +def _stable_identity(path: Path, *, label: str) -> tuple[int, str]: + """Return stable byte size and SHA-256 for one regular non-link file.""" + if path.is_symlink(): + raise ValueError(f"{label} must be a regular non-link file") + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags) + except OSError as error: + raise ValueError(f"{label} could not be opened") from error + try: + before = os.fstat(descriptor) + if not stat.S_ISREG(before.st_mode): + raise ValueError(f"{label} must be a regular non-link file") + digest = hashlib.sha256() + size = 0 + while True: + chunk = os.read(descriptor, 1024 * 1024) + if not chunk: + break + size += len(chunk) + digest.update(chunk) + after = os.fstat(descriptor) + if (before.st_dev, before.st_ino, before.st_size) != ( + after.st_dev, + after.st_ino, + after.st_size, + ) or size != before.st_size: + raise ValueError(f"{label} changed while being hashed") + return size, digest.hexdigest() + finally: + os.close(descriptor) + + +def _read_asset_list(path: Path) -> list[str]: + """Read a bounded unique list of safe repository-relative upload paths.""" + size, _ = _stable_identity(path, label="release asset list") + if size < 1 or size > _MAX_ASSET_LIST_BYTES: + raise ValueError("release asset list size is outside policy") + try: + raw = path.read_bytes() + text = raw.decode("utf-8") + except (OSError, UnicodeError) as error: + raise ValueError("release asset list must be readable UTF-8") from error + if len(raw) != size: + raise ValueError("release asset list changed while being read") + + members: list[str] = [] + seen_paths: set[str] = set() + seen_basenames: set[str] = set() + for line in text.splitlines(): + member = line.strip() + if not member: + continue + posix = PurePosixPath(member) + if ( + posix.is_absolute() + or member != posix.as_posix() + or any(part in {"", ".", ".."} for part in posix.parts) + ): + raise ValueError("release asset list contains an unsafe path") + basename = posix.name + if member in seen_paths or basename in seen_basenames: + raise ValueError("release asset list contains duplicate publication authority") + seen_paths.add(member) + seen_basenames.add(basename) + members.append(member) + if len(members) > _MAX_ASSET_COUNT: + raise ValueError("release asset list exceeds bounded member count") + if not members: + raise ValueError("release asset list must not be empty") + return members + + +def _hosted_asset_names(hosted_root: Path) -> set[str]: + """Return the exact flat set downloaded from the release asset namespace.""" + if hosted_root.is_symlink() or not hosted_root.is_dir(): + raise ValueError("hosted release root must be a regular directory") + names: set[str] = set() + for path in hosted_root.iterdir(): + if path.is_symlink() or not path.is_file(): + raise ValueError(f"unexpected hosted release path: {path.name}") + if path.name in names: + raise ValueError("hosted release asset set contains duplicate names") + names.add(path.name) + if len(names) > _MAX_ASSET_COUNT: + raise ValueError("hosted release asset set exceeds bounded member count") + return names + + +def verify_hosted_assets( + local_root: Path, + hosted_root: Path, + asset_list: Path, +) -> None: + """Require exact name, size, and digest parity for every uploaded release asset.""" + members = _read_asset_list(asset_list) + expected_names = {PurePosixPath(member).name for member in members} + hosted_names = _hosted_asset_names(hosted_root) + if hosted_names != expected_names: + missing = sorted(expected_names - hosted_names) + unexpected = sorted(hosted_names - expected_names) + raise ValueError( + f"hosted release asset set mismatch; missing={missing}, unexpected={unexpected}" + ) + + for member in members: + basename = PurePosixPath(member).name + local_size, local_digest = _stable_identity( + local_root / Path(*PurePosixPath(member).parts), + label=f"local release asset {member}", + ) + hosted_size, hosted_digest = _stable_identity( + hosted_root / basename, + label=f"hosted release asset {basename}", + ) + if hosted_size != local_size: + raise ValueError(f"hosted release asset size mismatch: {basename}") + if hosted_digest != local_digest: + raise ValueError(f"hosted release asset digest mismatch: {basename}") + + +def main() -> int: + """CLI entry point for draft/final hosted release byte re-verification.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--local-root", type=Path, default=Path.cwd()) + parser.add_argument("--hosted-root", type=Path, required=True) + parser.add_argument("--asset-list", type=Path, required=True) + args = parser.parse_args() + try: + verify_hosted_assets(args.local_root, args.hosted_root, args.asset_list) + except (OSError, ValueError) as error: + print(f"Hosted release verification failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/services/analysis-engine/tests/test_distribution_ci_coverage.py b/services/analysis-engine/tests/test_distribution_ci_coverage.py new file mode 100644 index 000000000..1ae74ae3d --- /dev/null +++ b/services/analysis-engine/tests/test_distribution_ci_coverage.py @@ -0,0 +1,27 @@ +"""Hosted-CI contracts for standalone Distribution Rust owners.""" + +from __future__ import annotations + +from pathlib import Path + + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_CI_WORKFLOW = _REPO_ROOT / ".github" / "workflows" / "ci.yml" + + +def test_hosted_ci_executes_every_standalone_distribution_owner() -> None: + """Current-head CI must execute tests for each Distribution-owned Rust crate.""" + workflow = _CI_WORKFLOW.read_text(encoding="utf-8") + manifests = ( + "apps/desktop/distribution-download/Cargo.toml", + "apps/desktop/distribution-state/Cargo.toml", + "apps/desktop/distribution-runtime/Cargo.toml", + "apps/desktop/distribution-transport/Cargo.toml", + ) + + for manifest in manifests: + command = f"cargo +stable test --manifest-path {manifest} --locked --all-targets" + assert command in workflow, f"hosted CI does not execute {manifest}" + + assert "distribution-owned-platform" in workflow + assert "- distribution-owned-platform" in workflow diff --git a/services/analysis-engine/tests/test_distribution_http_dependency_admission.py b/services/analysis-engine/tests/test_distribution_http_dependency_admission.py new file mode 100644 index 000000000..a901b3381 --- /dev/null +++ b/services/analysis-engine/tests/test_distribution_http_dependency_admission.py @@ -0,0 +1,248 @@ +"""Regression tests for Distribution HTTP dependency admission.""" + +from __future__ import annotations + +from pathlib import Path + +from conftest import load_module + +POLICY = load_module( + "scripts/checks/verify_distribution_http_dependencies.py", + "verify_distribution_http_dependencies", +) + + +def _write_fixture( + root: Path, + *, + reqwest: str | None, + rustls_version: str | None, +) -> None: + """Write the smallest standalone Distribution transport dependency graph.""" + crate = root / "apps/desktop/distribution-transport" + crate.mkdir(parents=True) + dependency = reqwest or "" + (crate / "Cargo.toml").write_text( + '[package]\nname = "fixture"\nversion = "0.0.0"\n\n' + "[dependencies]\n" + f"{dependency}", + encoding="utf-8", + ) + packages = [] + if reqwest is not None: + packages.append( + '[[package]]\nname = "reqwest"\nversion = "0.13.5"\n' + 'source = "registry+https://github.com/rust-lang/crates.io-index"\n' + 'checksum = "fixture"\n' + ) + if rustls_version is not None: + packages.append( + f'[[package]]\nname = "rustls"\nversion = "{rustls_version}"\n' + 'source = "registry+https://github.com/rust-lang/crates.io-index"\n' + 'checksum = "fixture"\n' + ) + (crate / "Cargo.lock").write_text( + "version = 4\n\n" + "\n".join(packages), + encoding="utf-8", + ) + + +def test_direct_reqwest_rejects_rustls_advisory_range(tmp_path: Path) -> None: + """Reject the affected rustls 0.23.13 through 0.23.44 range.""" + _write_fixture( + tmp_path, + reqwest=( + 'reqwest = { version = "0.13.5", default-features = false, ' + 'features = ["rustls"] }\n' + ), + rustls_version="0.23.44", + ) + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any("RUSTSEC-2026-0285" in violation for violation in violations) + assert any("rustls >=0.23.45" in violation for violation in violations) + + +def test_direct_reqwest_accepts_patched_rustls(tmp_path: Path) -> None: + """Accept the first patched rustls 0.23 release.""" + _write_fixture( + tmp_path, + reqwest=( + 'reqwest = { version = "0.13.5", default-features = false, ' + 'features = ["rustls"] }\n' + ), + rustls_version="0.23.45", + ) + + assert POLICY.verify_distribution_http_dependency_admission(tmp_path) == [] + + +def test_direct_reqwest_accepts_later_unaffected_rustls_line(tmp_path: Path) -> None: + """Do not freeze the gate to the 0.23 minor line.""" + _write_fixture( + tmp_path, + reqwest=( + 'reqwest = { version = "0.13.5", default-features = false, ' + 'features = ["rustls"] }\n' + ), + rustls_version="0.24.0", + ) + + assert POLICY.verify_distribution_http_dependency_admission(tmp_path) == [] + + +def test_unrelated_transitive_rustls_does_not_activate_distribution_gate( + tmp_path: Path, +) -> None: + """Scope the gate to the Distribution transport crate's direct HTTP client.""" + _write_fixture(tmp_path, reqwest=None, rustls_version="0.23.44") + + assert POLICY.verify_distribution_http_dependency_admission(tmp_path) == [] + + +def test_direct_reqwest_requires_explicit_tls_feature_ownership(tmp_path: Path) -> None: + """Reject reqwest's implicit default TLS/backend feature selection.""" + _write_fixture( + tmp_path, + reqwest='reqwest = { version = "0.13.5" }\n', + rustls_version="0.23.45", + ) + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any("default features must be disabled" in violation for violation in violations) + assert any("explicitly enable the rustls feature" in violation for violation in violations) + + +def test_direct_reqwest_rejects_vendored_native_tls_without_alpn(tmp_path: Path) -> None: + """Reject the final reqwest native-TLS feature spelling, not just its siblings.""" + _write_fixture( + tmp_path, + reqwest=( + 'reqwest = { version = "0.13.5", default-features = false, ' + 'features = ["rustls", "native-tls-vendored-no-alpn"] }\n' + ), + rustls_version="0.23.45", + ) + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any( + "native-tls-vendored-no-alpn" in violation for violation in violations + ) + + +def test_direct_reqwest_rejects_unapproved_transport_features(tmp_path: Path) -> None: + """Keep updater transport semantics explicit instead of activating optional behavior.""" + unapproved_features = ( + "gzip", + "brotli", + "zstd", + "deflate", + "system-proxy", + "socks", + "hickory-dns", + "http2", + "http3", + ) + for feature in unapproved_features: + fixture = tmp_path / feature + _write_fixture( + fixture, + reqwest=( + 'reqwest = { version = "0.13.5", default-features = false, ' + f'features = ["rustls", "{feature}"] }}\n' + ), + rustls_version="0.23.45", + ) + + violations = POLICY.verify_distribution_http_dependency_admission(fixture) + + assert any(feature in violation for violation in violations), feature + + +def test_target_specific_reqwest_cannot_bypass_direct_dependency_admission( + tmp_path: Path, +) -> None: + """Treat target-scoped runtime reqwest declarations as direct owner dependencies.""" + _write_fixture(tmp_path, reqwest=None, rustls_version="0.23.45") + manifest = tmp_path / "apps/desktop/distribution-transport/Cargo.toml" + manifest.write_text( + manifest.read_text(encoding="utf-8") + + '\n[target.\'cfg(windows)\'.dependencies]\n' + + 'reqwest = { version = "0.13.5", default-features = false, ' + + 'features = ["rustls", "gzip"] }\n', + encoding="utf-8", + ) + lock = tmp_path / "apps/desktop/distribution-transport/Cargo.lock" + lock.write_text( + lock.read_text(encoding="utf-8") + + '\n[[package]]\nname = "reqwest"\nversion = "0.13.5"\n' + + 'source = "registry+https://github.com/rust-lang/crates.io-index"\n' + + 'checksum = "fixture"\n', + encoding="utf-8", + ) + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any("gzip" in violation for violation in violations) + assert any("cfg(windows)" in violation for violation in violations) + + +def test_renamed_reqwest_cannot_bypass_direct_dependency_admission( + tmp_path: Path, +) -> None: + """Treat Cargo package aliases as direct reqwest ownership.""" + _write_fixture(tmp_path, reqwest=None, rustls_version="0.23.45") + manifest = tmp_path / "apps/desktop/distribution-transport/Cargo.toml" + manifest.write_text( + manifest.read_text(encoding="utf-8") + + 'distribution_http = { package = "reqwest", version = "0.13.5", ' + + 'default-features = false, features = ["rustls", "gzip"] }\n', + encoding="utf-8", + ) + lock = tmp_path / "apps/desktop/distribution-transport/Cargo.lock" + lock.write_text( + lock.read_text(encoding="utf-8") + + '\n[[package]]\nname = "reqwest"\nversion = "0.13.5"\n' + + 'source = "registry+https://github.com/rust-lang/crates.io-index"\n' + + 'checksum = "fixture"\n', + encoding="utf-8", + ) + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any("gzip" in violation for violation in violations) + assert any("distribution_http" in violation for violation in violations) + + +def test_workspace_inherited_reqwest_cannot_bypass_direct_dependency_admission( + tmp_path: Path, +) -> None: + """Reject reqwest hidden behind workspace dependency inheritance.""" + _write_fixture(tmp_path, reqwest=None, rustls_version="0.23.45") + manifest = tmp_path / "apps/desktop/distribution-transport/Cargo.toml" + manifest.write_text( + '[package]\nname = "fixture"\nversion = "0.0.0"\n\n' + '[dependencies]\n' + 'distribution_http = { workspace = true, features = ["gzip"] }\n\n' + '[workspace]\n\n' + '[workspace.dependencies]\n' + 'distribution_http = { package = "reqwest", version = "0.13.5", ' + 'default-features = false, features = ["rustls"] }\n', + encoding="utf-8", + ) + lock = tmp_path / "apps/desktop/distribution-transport/Cargo.lock" + lock.write_text( + lock.read_text(encoding="utf-8") + + '\n[[package]]\nname = "reqwest"\nversion = "0.13.5"\n' + + 'source = "registry+https://github.com/rust-lang/crates.io-index"\n' + + 'checksum = "fixture"\n', + encoding="utf-8", + ) + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any("workspace" in violation for violation in violations) + assert any("distribution_http" in violation for violation in violations) diff --git a/services/analysis-engine/tests/test_distribution_http_dependency_feature_forwarding.py b/services/analysis-engine/tests/test_distribution_http_dependency_feature_forwarding.py new file mode 100644 index 000000000..2851e7816 --- /dev/null +++ b/services/analysis-engine/tests/test_distribution_http_dependency_feature_forwarding.py @@ -0,0 +1,96 @@ +"""Regression tests for Distribution reqwest feature-forwarding admission.""" + +from __future__ import annotations + +from pathlib import Path + +from conftest import load_module + +POLICY = load_module( + "scripts/checks/verify_distribution_http_dependencies.py", + "verify_distribution_http_dependencies_feature_forwarding", +) + +CRATES_IO_SOURCE = "registry+https://github.com/rust-lang/crates.io-index" + + +def _write_fixture(root: Path, *, dependency_name: str, package_field: str, feature: str) -> None: + """Write an otherwise-admitted reqwest graph with one forwarded dependency feature.""" + crate = root / "apps/desktop/distribution-transport" + crate.mkdir(parents=True) + (crate / "Cargo.toml").write_text( + '[package]\nname = "fixture"\nversion = "0.0.0"\n\n' + '[dependencies]\n' + f'{dependency_name} = {{ {package_field}version = "0.13.5", ' + 'default-features = false, features = ["rustls"] }}\n\n' + '[features]\n' + f'default = ["{dependency_name}/{feature}"]\n', + encoding="utf-8", + ) + (crate / "Cargo.lock").write_text( + "version = 4\n\n" + '[[package]]\nname = "reqwest"\nversion = "0.13.5"\n' + f'source = "{CRATES_IO_SOURCE}"\n\n' + '[[package]]\nname = "rustls"\nversion = "0.23.45"\n' + f'source = "{CRATES_IO_SOURCE}"\n', + encoding="utf-8", + ) + + +def test_root_feature_cannot_add_unapproved_reqwest_feature(tmp_path: Path) -> None: + """Reject Cargo feature forwarding that re-enables gzip outside the dependency table.""" + _write_fixture( + tmp_path, + dependency_name="reqwest", + package_field="", + feature="gzip", + ) + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any( + "reqwest/gzip" in violation and "feature" in violation + for violation in violations + ) + + +def test_renamed_reqwest_feature_forwarding_is_also_rejected(tmp_path: Path) -> None: + """Apply the same invariant when Cargo renames the reqwest dependency key.""" + _write_fixture( + tmp_path, + dependency_name="distribution_http", + package_field='package = "reqwest", ', + feature="brotli", + ) + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any( + "distribution_http/brotli" in violation and "feature" in violation + for violation in violations + ) + + +def test_weak_dependency_feature_forwarding_is_rejected(tmp_path: Path) -> None: + """Reject Cargo's dependency?/feature form as another feature-activation path.""" + _write_fixture( + tmp_path, + dependency_name="distribution_http", + package_field='package = "reqwest", ', + feature="zstd", + ) + manifest = tmp_path / "apps/desktop/distribution-transport/Cargo.toml" + manifest.write_text( + manifest.read_text(encoding="utf-8").replace( + "distribution_http/zstd", + "distribution_http?/zstd", + ), + encoding="utf-8", + ) + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any( + "distribution_http?/zstd" in violation and "feature" in violation + for violation in violations + ) diff --git a/services/analysis-engine/tests/test_distribution_http_dependency_prerelease_admission.py b/services/analysis-engine/tests/test_distribution_http_dependency_prerelease_admission.py new file mode 100644 index 000000000..0f9ede64f --- /dev/null +++ b/services/analysis-engine/tests/test_distribution_http_dependency_prerelease_admission.py @@ -0,0 +1,71 @@ +"""Pre-release boundary contracts for Distribution's security-owned HTTP graph.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from conftest import load_module + +POLICY = load_module( + "scripts/checks/verify_distribution_http_dependencies.py", + "verify_distribution_http_dependencies_prerelease_admission", +) + + +def _write_fixture( + root: Path, + *, + locked_reqwest_version: str = "0.13.5", + locked_rustls_version: str = "0.23.45", +) -> None: + """Write one crates.io-backed Distribution HTTP graph for version-bound tests.""" + crate = root / "apps/desktop/distribution-transport" + crate.mkdir(parents=True) + (crate / "Cargo.toml").write_text( + '[package]\nname = "fixture"\nversion = "0.0.0"\n\n' + "[dependencies]\n" + 'reqwest = { version = "0.13.5", default-features = false, ' + 'features = ["rustls"] }\n', + encoding="utf-8", + ) + (crate / "Cargo.lock").write_text( + "version = 4\n\n" + f'[[package]]\nname = "reqwest"\nversion = "{locked_reqwest_version}"\n' + 'source = "registry+https://github.com/rust-lang/crates.io-index"\n' + 'checksum = "fixture"\n\n' + f'[[package]]\nname = "rustls"\nversion = "{locked_rustls_version}"\n' + 'source = "registry+https://github.com/rust-lang/crates.io-index"\n' + 'checksum = "fixture"\n', + encoding="utf-8", + ) + + +def test_reqwest_prerelease_lock_is_not_treated_as_reviewed_stable_line(tmp_path: Path) -> None: + """A pre-release package must not inherit the owner review for stable 0.13.5+.""" + _write_fixture(tmp_path, locked_reqwest_version="0.13.5-alpha.1") + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any("reviewed stable reqwest range" in violation for violation in violations) + + +@pytest.mark.parametrize("rustls_version", ["0.23.45-alpha.1", "0.23.45-rc.1"]) +def test_rustls_prerelease_before_patched_stable_is_still_advisory_affected( + tmp_path: Path, + rustls_version: str, +) -> None: + """RustSec's >=0.23.45 patch boundary excludes 0.23.45 pre-releases.""" + _write_fixture(tmp_path, locked_rustls_version=rustls_version) + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any(POLICY.RUSTLS_ENCRYPTION_LEVEL_ADVISORY in violation for violation in violations) + + +def test_rustls_stable_patched_boundary_remains_admitted(tmp_path: Path) -> None: + """Keep the exact first stable patched release admitted.""" + _write_fixture(tmp_path, locked_rustls_version="0.23.45") + + assert POLICY.verify_distribution_http_dependency_admission(tmp_path) == [] diff --git a/services/analysis-engine/tests/test_distribution_http_dependency_source_admission.py b/services/analysis-engine/tests/test_distribution_http_dependency_source_admission.py new file mode 100644 index 000000000..fb57c6291 --- /dev/null +++ b/services/analysis-engine/tests/test_distribution_http_dependency_source_admission.py @@ -0,0 +1,166 @@ +"""Regression tests for Distribution HTTP dependency source admission.""" + +from __future__ import annotations + +from pathlib import Path + +from conftest import load_module + +POLICY = load_module( + "scripts/checks/verify_distribution_http_dependencies.py", + "verify_distribution_http_dependencies_source", +) + +CRATES_IO_SOURCE = "registry+https://github.com/rust-lang/crates.io-index" + + +def _write_source_fixture( + root: Path, + *, + dependency_fields: str, + reqwest_source: str | None, +) -> None: + """Write one standalone reqwest graph with an explicit dependency source.""" + crate = root / "apps/desktop/distribution-transport" + crate.mkdir(parents=True) + (crate / "Cargo.toml").write_text( + '[package]\nname = "fixture"\nversion = "0.0.0"\n\n' + '[dependencies]\n' + 'reqwest = { default-features = false, features = ["rustls"], ' + f"{dependency_fields} }}\n", + encoding="utf-8", + ) + source_line = f'source = "{reqwest_source}"\n' if reqwest_source else "" + (crate / "Cargo.lock").write_text( + "version = 4\n\n" + '[[package]]\nname = "reqwest"\nversion = "0.13.5"\n' + f"{source_line}" + '\n[[package]]\nname = "rustls"\nversion = "0.23.45"\n' + f'source = "{CRATES_IO_SOURCE}"\n', + encoding="utf-8", + ) + + +def test_reqwest_noncanonical_sources_are_rejected(tmp_path: Path) -> None: + """Reject git, path, and alternate-registry sources for the production client.""" + cases = ( + ( + "git", + 'git = "https://example.invalid/reqwest", rev = "deadbeef"', + "git+https://example.invalid/reqwest?rev=deadbeef#deadbeef", + ), + ("path", 'path = "../reqwest-fork"', None), + ( + "registry", + 'version = "0.13.5", registry = "private"', + "registry+https://example.invalid/index", + ), + ) + for name, dependency_fields, reqwest_source in cases: + fixture = tmp_path / name + _write_source_fixture( + fixture, + dependency_fields=dependency_fields, + reqwest_source=reqwest_source, + ) + + violations = POLICY.verify_distribution_http_dependency_admission(fixture) + + assert any("source" in violation for violation in violations), name + + +def test_rustls_noncanonical_lock_source_is_rejected(tmp_path: Path) -> None: + """Reject a patched or forked rustls source hidden behind a safe-looking version.""" + _write_source_fixture( + tmp_path, + dependency_fields='version = "0.13.5"', + reqwest_source=CRATES_IO_SOURCE, + ) + lock = tmp_path / "apps/desktop/distribution-transport/Cargo.lock" + lock.write_text( + lock.read_text(encoding="utf-8").replace( + 'name = "rustls"\nversion = "0.23.45"\n' + f'source = "{CRATES_IO_SOURCE}"', + 'name = "rustls"\nversion = "0.23.45"\n' + 'source = "git+https://example.invalid/rustls#deadbeef"', + ), + encoding="utf-8", + ) + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any("rustls source" in violation for violation in violations) + + +def test_transitive_noncanonical_lock_sources_are_rejected(tmp_path: Path) -> None: + """Reject transitive git/path substitution behind canonical reqwest and rustls.""" + cases = ( + ( + "git-provider", + "aws-lc-rs", + 'source = "git+https://example.invalid/aws-lc-rs#deadbeef"\n', + ), + ("path-webpki", "rustls-webpki", ""), + ) + for fixture_name, package_name, source_line in cases: + fixture = tmp_path / fixture_name + _write_source_fixture( + fixture, + dependency_fields='version = "0.13.5"', + reqwest_source=CRATES_IO_SOURCE, + ) + lock = fixture / "apps/desktop/distribution-transport/Cargo.lock" + lock.write_text( + lock.read_text(encoding="utf-8") + + f'\n[[package]]\nname = "{package_name}"\nversion = "1.0.0"\n' + + source_line, + encoding="utf-8", + ) + + violations = POLICY.verify_distribution_http_dependency_admission(fixture) + + assert any( + package_name in violation and "source" in violation + for violation in violations + ), fixture_name + + +def test_canonical_crates_io_transitive_package_is_admitted(tmp_path: Path) -> None: + """Keep ordinary crates.io transitive packages admissible under full-graph checking.""" + _write_source_fixture( + tmp_path, + dependency_fields='version = "0.13.5"', + reqwest_source=CRATES_IO_SOURCE, + ) + lock = tmp_path / "apps/desktop/distribution-transport/Cargo.lock" + lock.write_text( + lock.read_text(encoding="utf-8") + + '\n[[package]]\nname = "rustls-webpki"\nversion = "0.103.8"\n' + + f'source = "{CRATES_IO_SOURCE}"\n', + encoding="utf-8", + ) + + assert POLICY.verify_distribution_http_dependency_admission(tmp_path) == [] + + +def test_local_distribution_package_cannot_gain_remote_source(tmp_path: Path) -> None: + """Keep the explicit local-package allow-list path-owned rather than name-trusted.""" + _write_source_fixture( + tmp_path, + dependency_fields='version = "0.13.5"', + reqwest_source=CRATES_IO_SOURCE, + ) + lock = tmp_path / "apps/desktop/distribution-transport/Cargo.lock" + lock.write_text( + lock.read_text(encoding="utf-8") + + '\n[[package]]\nname = "bandscope-distribution-core"\nversion = "0.1.0"\n' + + f'source = "{CRATES_IO_SOURCE}"\n', + encoding="utf-8", + ) + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any( + "bandscope-distribution-core" in violation and "path-owned" in violation + for violation in violations + ) diff --git a/services/analysis-engine/tests/test_distribution_http_dependency_version_requirement.py b/services/analysis-engine/tests/test_distribution_http_dependency_version_requirement.py new file mode 100644 index 000000000..bb0b08969 --- /dev/null +++ b/services/analysis-engine/tests/test_distribution_http_dependency_version_requirement.py @@ -0,0 +1,83 @@ +"""Version-requirement contracts for Distribution's production HTTP client.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from conftest import load_module + +POLICY = load_module( + "scripts/checks/verify_distribution_http_dependencies.py", + "verify_distribution_http_dependencies_version_requirement", +) + + +def _write_fixture( + root: Path, + version_requirement: str, + *, + locked_reqwest_version: str = "0.13.5", +) -> None: + """Write a safe lock graph paired with one reqwest version requirement.""" + crate = root / "apps/desktop/distribution-transport" + crate.mkdir(parents=True) + (crate / "Cargo.toml").write_text( + '[package]\nname = "fixture"\nversion = "0.0.0"\n\n' + "[dependencies]\n" + f'reqwest = {{ version = "{version_requirement}", default-features = false, ' + 'features = ["rustls"] }}\n', + encoding="utf-8", + ) + (crate / "Cargo.lock").write_text( + "version = 4\n\n" + f'[[package]]\nname = "reqwest"\nversion = "{locked_reqwest_version}"\n' + 'source = "registry+https://github.com/rust-lang/crates.io-index"\n' + 'checksum = "fixture"\n\n' + '[[package]]\nname = "rustls"\nversion = "0.23.45"\n' + 'source = "registry+https://github.com/rust-lang/crates.io-index"\n' + 'checksum = "fixture"\n', + encoding="utf-8", + ) + + +@pytest.mark.parametrize("version_requirement", ["*", ">=0.13.5"]) +def test_direct_reqwest_rejects_unbounded_version_requirement( + tmp_path: Path, + version_requirement: str, +) -> None: + """Reject requirements that can drift across an unbounded future release line.""" + _write_fixture(tmp_path, version_requirement) + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any("bounded three-component" in violation for violation in violations) + + +def test_direct_reqwest_accepts_bounded_three_component_requirement(tmp_path: Path) -> None: + """Keep the normal Cargo caret requirement form available with a committed lock.""" + _write_fixture(tmp_path, "0.13.5") + + assert POLICY.verify_distribution_http_dependency_admission(tmp_path) == [] + + +@pytest.mark.parametrize( + ("version_requirement", "locked_reqwest_version"), + [("0.13.4", "0.13.4"), ("0.14.0", "0.14.0")], +) +def test_direct_reqwest_rejects_unreviewed_release_lines( + tmp_path: Path, + version_requirement: str, + locked_reqwest_version: str, +) -> None: + """Require a new owner decision before downgrading or crossing reqwest's 0.13 line.""" + _write_fixture( + tmp_path, + version_requirement, + locked_reqwest_version=locked_reqwest_version, + ) + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any("reviewed reqwest range" in violation for violation in violations) diff --git a/services/analysis-engine/tests/test_distribution_update_core.py b/services/analysis-engine/tests/test_distribution_update_core.py new file mode 100644 index 000000000..7e0be96fa --- /dev/null +++ b/services/analysis-engine/tests/test_distribution_update_core.py @@ -0,0 +1,69 @@ +"""Native contract gates for BandScope Distribution/update Rust boundaries.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path +from types import MappingProxyType + +import yaml + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_MANIFESTS = ( + _REPO_ROOT / "apps" / "desktop" / "distribution-core" / "Cargo.toml", + _REPO_ROOT / "apps" / "desktop" / "distribution-state" / "Cargo.toml", + _REPO_ROOT / "apps" / "desktop" / "distribution-runtime" / "Cargo.toml", + _REPO_ROOT / "apps" / "desktop" / "distribution-download" / "Cargo.toml", + _REPO_ROOT / "apps" / "desktop" / "distribution-transport" / "Cargo.toml", +) +_CI_WORKFLOW = _REPO_ROOT / ".github" / "workflows" / "ci.yml" +_EXPECTED_STAGING_PLATFORMS = frozenset({"ubuntu-latest", "windows-2025", "macos-15"}) + + +def test_distribution_update_native_suites_are_green() -> None: + """Run locked decision, state, metadata, download, and transport Rust contracts.""" + for manifest in _MANIFESTS: + completed = subprocess.run( + [ + "cargo", + "test", + "--manifest-path", + str(manifest), + "--locked", + "--all-targets", + ], + cwd=_REPO_ROOT, + text=True, + capture_output=True, + check=False, + timeout=60, + ) + + assert completed.returncode == 0, ( + f"{manifest.relative_to(_REPO_ROOT)} failed:\n" + + completed.stdout + + completed.stderr + ) + + +def test_staging_lease_contract_runs_on_all_shipped_desktop_os_families() -> None: + """Require platform CI for filesystem-lock semantics before the main CI gate can pass.""" + workflow = yaml.safe_load(_CI_WORKFLOW.read_text(encoding="utf-8")) + jobs = MappingProxyType(workflow["jobs"]) + platform_job = jobs["distribution-download-platform"] + + assert platform_job["needs"] == "lock-validation" + assert set(platform_job["strategy"]["matrix"]["os"]) == _EXPECTED_STAGING_PLATFORMS + assert platform_job["runs-on"] == "${{ matrix.os }}" + + commands = "\n".join( + str(step.get("run", "")) for step in platform_job["steps"] if isinstance(step, dict) + ) + assert ( + "cargo +stable test --manifest-path " + "apps/desktop/distribution-download/Cargo.toml --locked --all-targets" + ) in commands + + verify_needs = jobs["verify"]["needs"] + assert "lock-validation" in verify_needs + assert "distribution-download-platform" in verify_needs diff --git a/services/analysis-engine/tests/test_hosted_release_asset_reverification.py b/services/analysis-engine/tests/test_hosted_release_asset_reverification.py new file mode 100644 index 000000000..d56aebc26 --- /dev/null +++ b/services/analysis-engine/tests/test_hosted_release_asset_reverification.py @@ -0,0 +1,126 @@ +"""Tests for immutable release upload/download byte re-verification.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_VERIFIER = _REPO_ROOT / "scripts" / "release" / "verify_hosted_release_assets.py" +_WORKFLOW = _REPO_ROOT / ".github" / "workflows" / "build-baseline.yml" + + +def _run_verifier( + local_root: Path, + hosted_root: Path, + asset_list: Path, +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + str(_VERIFIER), + "--local-root", + str(local_root), + "--hosted-root", + str(hosted_root), + "--asset-list", + str(asset_list), + ], + text=True, + capture_output=True, + check=False, + ) + + +def _fixture(tmp_path: Path) -> tuple[Path, Path, Path]: + local_root = tmp_path / "local" + hosted_root = tmp_path / "hosted" + local_root.mkdir() + hosted_root.mkdir() + names = [ + "latest.json", + "bandscope-windows-amd64.exe", + "bandscope-windows-amd64.exe.sig", + ] + for name in names: + payload = f"payload:{name}\n".encode() + (local_root / name).write_bytes(payload) + (hosted_root / name).write_bytes(payload) + asset_list = tmp_path / "release-assets.txt" + asset_list.write_text("\n".join(names) + "\n", encoding="utf-8") + return local_root, hosted_root, asset_list + + +def test_hosted_verifier_accepts_exact_uploaded_asset_bytes(tmp_path: Path) -> None: + """Every hosted release asset must match admitted local bytes.""" + local_root, hosted_root, asset_list = _fixture(tmp_path) + + completed = _run_verifier(local_root, hosted_root, asset_list) + + assert completed.returncode == 0, completed.stderr + + +def test_hosted_verifier_rejects_signature_drift(tmp_path: Path) -> None: + """A remotely different signature invalidates publication evidence.""" + local_root, hosted_root, asset_list = _fixture(tmp_path) + (hosted_root / "bandscope-windows-amd64.exe.sig").write_text( + "different-signature\n", encoding="utf-8" + ) + + completed = _run_verifier(local_root, hosted_root, asset_list) + + assert completed.returncode != 0 + assert "digest" in completed.stderr.lower() + + +def test_hosted_verifier_rejects_missing_or_unexpected_assets( + tmp_path: Path, +) -> None: + """Publication cannot drop admitted assets or add unreviewed assets.""" + local_root, hosted_root, asset_list = _fixture(tmp_path) + (hosted_root / "latest.json").unlink() + (hosted_root / "unexpected.bin").write_bytes(b"unexpected") + + completed = _run_verifier(local_root, hosted_root, asset_list) + + assert completed.returncode != 0 + assert "asset set" in completed.stderr.lower() + + +def test_hosted_verifier_rejects_duplicate_or_nested_asset_list_members( + tmp_path: Path, +) -> None: + """Expected publication names must be unique safe basenames.""" + local_root, hosted_root, asset_list = _fixture(tmp_path) + asset_list.write_text( + "latest.json\nlatest.json\n../escape.bin\n", + encoding="utf-8", + ) + + completed = _run_verifier(local_root, hosted_root, asset_list) + + assert completed.returncode != 0 + assert "asset list" in completed.stderr.lower() + + +def test_release_workflow_reverifies_draft_and_published_assets() -> None: + """Publication compares downloaded draft/final bytes to local authority.""" + workflow = _WORKFLOW.read_text(encoding="utf-8") + verifier = "python3 scripts/release/verify_hosted_release_assets.py" + assert workflow.count("gh release download") >= 2 + assert workflow.count(verifier) >= 2 + assert workflow.index("gh release create") < workflow.index(verifier) + assert workflow.index(verifier) < workflow.index("gh release edit") + assert workflow.rindex("gh release edit") < workflow.rindex(verifier) + + +def test_published_release_requires_github_immutable_attestation() -> None: + """Published bytes must also match GitHub's signed immutable-release attestation.""" + workflow = _WORKFLOW.read_text(encoding="utf-8") + release_verify = 'gh release verify "$RELEASE_TAG"' + asset_verify = 'gh release verify-asset "$RELEASE_TAG" "$asset"' + assert release_verify in workflow + assert asset_verify in workflow + assert workflow.index("gh release edit") < workflow.index(release_verify) + assert workflow.index(release_verify) < workflow.index(asset_verify) diff --git a/services/analysis-engine/tests/test_release_asset_selection.py b/services/analysis-engine/tests/test_release_asset_selection.py index 5227dd9f3..9096cbd3b 100644 --- a/services/analysis-engine/tests/test_release_asset_selection.py +++ b/services/analysis-engine/tests/test_release_asset_selection.py @@ -2,6 +2,8 @@ from __future__ import annotations +import hashlib +import json import sys from pathlib import Path @@ -16,43 +18,106 @@ def _write_release_metadata(repo_root: Path) -> None: inventory.write_text("{}", encoding="utf-8") +def _full_sha(sha: str) -> str: + """Expand a short fixture SHA into one deterministic full receipt commit.""" + return sha if len(sha) == 40 else sha + ("0" * (40 - len(sha))) + + +def _target_triple(platform: str, arch: str) -> str: + if platform == "windows": + return "x86_64-pc-windows-msvc" if arch == "amd64" else "aarch64-pc-windows-msvc" + return "x86_64-apple-darwin" if arch == "amd64" else "aarch64-apple-darwin" + + +def _digest(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest() + + def _write_installer(repo_root: Path, platform: str, arch: str, sha: str, suffix: str) -> str: + """Write one complete target installer/updater/receipt publication graph.""" artifacts = repo_root / "artifacts" artifacts.mkdir(parents=True, exist_ok=True) - archive_name = f"bandscope-{platform}-{arch}-{sha}{suffix}" - (artifacts / archive_name).write_text(f"{platform}-{arch}", encoding="utf-8") - (artifacts / f"{archive_name}.sha256").write_text(f"0 {archive_name}\n", encoding="utf-8") - (artifacts / f"{archive_name}.manifest.txt").write_text( - f"platform={platform}\narch={arch}\narchive={archive_name}\n", + archive_name = f"bandscope-{platform}-{arch}-{sha[:12]}{suffix}" + installer_payload = f"{platform}-{arch}".encode() + (artifacts / archive_name).write_bytes(installer_payload) + checksum_name = f"{archive_name}.sha256" + (artifacts / checksum_name).write_text( + f"{_digest(installer_payload)} {archive_name}\n", encoding="utf-8" + ) + manifest_name = f"{archive_name}.manifest.txt" + target_triple = _target_triple(platform, arch) + (artifacts / manifest_name).write_text( + f"platform={platform}\narch={arch}\ntarget_triple={target_triple}\narchive={archive_name}\n", encoding="utf-8", ) + + if platform == "windows": + updater_name = archive_name + updater_payload = installer_payload + else: + updater_name = f"bandscope-macos-{arch}-{sha[:12]}.app.tar.gz" + updater_payload = f"updater-{platform}-{arch}".encode() + (artifacts / updater_name).write_bytes(updater_payload) + signature_name = f"{updater_name}.sig" + signature_payload = f"signature-{platform}-{arch}".encode() + (artifacts / signature_name).write_bytes(signature_payload) + + receipt_name = f"bandscope-{platform}-{arch}-{sha[:12]}.release-receipt.json" + receipt = { + "schemaVersion": 1, + "version": "1.2.3", + "tag": "v1.2.3", + "sourceCommit": _full_sha(sha), + "target": { + "platform": platform, + "arch": arch, + "targetTriple": target_triple, + }, + "artifacts": [ + { + "archive": archive_name, + "sizeBytes": len(installer_payload), + "sha256": _digest(installer_payload), + "checksumFile": checksum_name, + "manifestFile": manifest_name, + } + ], + "updaterArtifacts": [ + { + "bundle": updater_name, + "sizeBytes": len(updater_payload), + "sha256": _digest(updater_payload), + "signatureFile": signature_name, + "signatureSizeBytes": len(signature_payload), + "signatureSha256": _digest(signature_payload), + } + ], + } + (artifacts / receipt_name).write_text( + json.dumps(receipt) + "\n", encoding="utf-8" + ) return archive_name def test_select_release_assets_returns_only_validated_release_files(tmp_path: Path) -> None: - """Select installers, sidecars, SBOM, and inventory after validation.""" + """Select installers, updater evidence, target receipts, SBOM, and inventory.""" selector = load_module( "scripts/release/select_release_assets.py", "select_release_assets_valid" ) sha = "abc123def456" _write_release_metadata(tmp_path) - archives = [ - _write_installer(tmp_path, "windows", "amd64", sha, ".exe"), - _write_installer(tmp_path, "windows", "arm64", sha, ".msi"), - _write_installer(tmp_path, "macos", "amd64", sha, ".dmg"), - _write_installer(tmp_path, "macos", "arm64", sha, ".dmg"), - ] + for platform, arch, suffix in [ + ("windows", "amd64", ".exe"), + ("windows", "arm64", ".msi"), + ("macos", "amd64", ".dmg"), + ("macos", "arm64", ".dmg"), + ]: + _write_installer(tmp_path, platform, arch, sha, suffix) assets = selector.select_release_assets(tmp_path, git_sha=sha) expected_artifacts = sorted( - artifact - for archive in archives - for artifact in [ - f"artifacts/{archive}", - f"artifacts/{archive}.manifest.txt", - f"artifacts/{archive}.sha256", - ] + f"artifacts/{path.name}" for path in (tmp_path / "artifacts").iterdir() ) assert assets == [ *expected_artifacts, @@ -118,7 +183,9 @@ def test_select_release_assets_rejects_symlink_artifact(tmp_path: Path) -> None: symlink_target = tmp_path / "payload.exe" symlink_target.write_text("payload", encoding="utf-8") make_symlink_or_skip(artifacts / linked_archive, symlink_target) - (artifacts / f"{linked_archive}.sha256").write_text(f"0 {linked_archive}\n", encoding="utf-8") + (artifacts / f"{linked_archive}.sha256").write_text( + f"{'0' * 64} {linked_archive}\n", encoding="utf-8" + ) (artifacts / f"{linked_archive}.manifest.txt").write_text( f"platform=windows\narch=amd64\narchive={linked_archive}\n", encoding="utf-8", @@ -175,7 +242,9 @@ def test_select_release_assets_rejects_unsanctioned_archive_suffix(tmp_path: Pat debug_archive = f"bandscope-windows-amd64-{sha}-debug.exe" artifacts = tmp_path / "artifacts" (artifacts / debug_archive).write_text("debug", encoding="utf-8") - (artifacts / f"{debug_archive}.sha256").write_text(f"0 {debug_archive}\n", encoding="utf-8") + (artifacts / f"{debug_archive}.sha256").write_text( + f"{'0' * 64} {debug_archive}\n", encoding="utf-8" + ) (artifacts / f"{debug_archive}.manifest.txt").write_text( f"platform=windows\narch=amd64\narchive={debug_archive}\n", encoding="utf-8", diff --git a/services/analysis-engine/tests/test_release_identity_file_admission.py b/services/analysis-engine/tests/test_release_identity_file_admission.py new file mode 100644 index 000000000..c85d7132f --- /dev/null +++ b/services/analysis-engine/tests/test_release_identity_file_admission.py @@ -0,0 +1,133 @@ +"""Regression tests for release-identity file admission boundaries.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from conftest import load_module, make_symlink_or_skip + + +def _write_minimal_identity_tree(repo_root: Path, version: str = "1.2.3") -> None: + """Write the smallest canonical release-identity projections used by the guard.""" + (repo_root / "VERSION").write_text(f"{version}\n", encoding="utf-8") + (repo_root / "package.json").write_text( + f'{{"version":"{version}"}}\n', encoding="utf-8" + ) + tauri_config = repo_root / "apps" / "desktop" / "src-tauri" / "tauri.conf.json" + tauri_config.parent.mkdir(parents=True) + tauri_config.write_text(f'{{"version":"{version}"}}\n', encoding="utf-8") + + +def test_release_identity_rejects_duplicate_json_version_projection(tmp_path: Path) -> None: + """A parser-dependent duplicate version must not enter release identity.""" + verifier = load_module( + "scripts/checks/verify_release_identity.py", + "verify_release_identity_duplicate_projection", + ) + _write_minimal_identity_tree(tmp_path) + (tmp_path / "package.json").write_text( + '{"version":"9.9.9","version":"1.2.3"}\n', encoding="utf-8" + ) + + with pytest.raises(ValueError, match="duplicate JSON member"): + verifier.verify_release_identity(tmp_path) + + +@pytest.mark.parametrize("constant", ["NaN", "Infinity", "-Infinity"]) +def test_release_identity_rejects_nonstandard_json_constants( + tmp_path: Path, constant: str +) -> None: + """Release projections must remain strict JSON across consumer implementations.""" + verifier = load_module( + "scripts/checks/verify_release_identity.py", + f"verify_release_identity_nonstandard_constant_{constant.replace('-', 'neg_')}", + ) + _write_minimal_identity_tree(tmp_path) + (tmp_path / "package.json").write_text( + f'{{"version":"1.2.3","nonstandard":{constant}}}\n', encoding="utf-8" + ) + + with pytest.raises(ValueError, match="could not read release metadata"): + verifier.verify_release_identity(tmp_path) + + +def test_release_identity_rejects_same_size_mutation_during_descriptor_read( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A same-length rewrite during admission must not yield a mixed file snapshot.""" + verifier = load_module( + "scripts/checks/verify_release_identity.py", + "verify_release_identity_same_size_mutation", + ) + _write_minimal_identity_tree(tmp_path) + package_path = tmp_path / "package.json" + original = b'{"version":"1.2.3"}\n' + replacement = b'{"version":"9.9.9"}\n' + assert len(original) == len(replacement) + + real_read = verifier.os.read + mutated = False + + def mutate_after_first_package_read(descriptor: int, size: int) -> bytes: + nonlocal mutated + chunk = real_read(descriptor, size) + if not mutated and chunk == original: + package_path.write_bytes(replacement) + mutated = True + return chunk + + monkeypatch.setattr(verifier.os, "read", mutate_after_first_package_read) + + with pytest.raises(ValueError, match="changed while being read"): + verifier.verify_release_identity(tmp_path) + assert mutated + + +def test_release_identity_rejects_path_replacement_during_descriptor_read( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A stable old descriptor must not authorize a path replaced during admission.""" + verifier = load_module( + "scripts/checks/verify_release_identity.py", + "verify_release_identity_path_replacement", + ) + _write_minimal_identity_tree(tmp_path) + package_path = tmp_path / "package.json" + original = b'{"version":"1.2.3"}\n' + replacement_path = tmp_path / "replacement-package.json" + replacement_path.write_bytes(b'{"version":"9.9.9"}\n') + + real_read = verifier.os.read + replaced = False + + def replace_after_first_package_read(descriptor: int, size: int) -> bytes: + nonlocal replaced + chunk = real_read(descriptor, size) + if not replaced and chunk == original: + replacement_path.replace(package_path) + replaced = True + return chunk + + monkeypatch.setattr(verifier.os, "read", replace_after_first_package_read) + + with pytest.raises(ValueError, match="changed while being read"): + verifier.verify_release_identity(tmp_path) + assert replaced + + +def test_release_identity_rejects_symlinked_version_authority(tmp_path: Path) -> None: + """VERSION must be the repository file itself rather than a followed link.""" + verifier = load_module( + "scripts/checks/verify_release_identity.py", + "verify_release_identity_symlinked_version", + ) + _write_minimal_identity_tree(tmp_path) + version_path = tmp_path / "VERSION" + version_path.unlink() + target = tmp_path / "version-target.txt" + target.write_text("1.2.3\n", encoding="utf-8") + make_symlink_or_skip(version_path, target) + + with pytest.raises(ValueError, match="VERSION must be a regular non-link file"): + verifier.verify_release_identity(tmp_path) diff --git a/services/analysis-engine/tests/test_release_model_evidence_binding.py b/services/analysis-engine/tests/test_release_model_evidence_binding.py new file mode 100644 index 000000000..b9710fae3 --- /dev/null +++ b/services/analysis-engine/tests/test_release_model_evidence_binding.py @@ -0,0 +1,69 @@ +"""Release evidence binding contracts for commercially admitted model artifacts.""" + +from __future__ import annotations + +import hashlib +import importlib.util +import json +from pathlib import Path +from types import ModuleType + +import pytest + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +_GUARD_PATH = _REPOSITORY_ROOT / "scripts" / "checks" / "verify_release_model_policy.py" + + +def _load_guard() -> ModuleType: + """Load the Distribution-owned model release guard from its executable path.""" + module_spec = importlib.util.spec_from_file_location( + "verify_release_model_policy_evidence_red", _GUARD_PATH + ) + assert module_spec is not None and module_spec.loader is not None + module = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(module) + return module + + +def _write_admitted_policy(repository_root: Path, model_payload: bytes) -> None: + """Write an admitted artifact whose evidence digests have no backing evidence files.""" + model_path = repository_root / "release" / "models" / "separator.safetensors" + model_path.parent.mkdir(parents=True, exist_ok=True) + model_path.write_bytes(model_payload) + + policy_path = repository_root / "release" / "model-artifact-policy.json" + policy_path.write_text( + json.dumps( + { + "schemaVersion": 1, + "releaseStatus": "admitted", + "blockedArtifact": { + "modelId": "demucs/htdemucs", + "checkpoint": "955717e8-8726e21a.th", + "reason": "commercial-rights-not-established", + "primaryEvidence": "https://github.com/facebookresearch/demucs/issues/327#issuecomment-1134828611", + }, + "admittedArtifact": { + "modelId": "cwl/rehearsal-separator-v1", + "modelVersion": "1.0.0", + "path": "release/models/separator.safetensors", + "sizeBytes": len(model_payload), + "sha256": hashlib.sha256(model_payload).hexdigest(), + "serialization": "safetensors", + "rightsEvidenceSha256": hashlib.sha256(b"rights").hexdigest(), + "provenanceEvidenceSha256": hashlib.sha256(b"provenance").hexdigest(), + "loaderPolicySha256": hashlib.sha256(b"loader-policy").hexdigest(), + }, + } + ), + encoding="utf-8", + ) + + +def test_admitted_model_evidence_hashes_require_backing_files(tmp_path: Path) -> None: + """Do not treat self-asserted evidence digests as evidence without immutable bytes.""" + guard = _load_guard() + _write_admitted_policy(tmp_path, b"commercial-model") + + with pytest.raises(ValueError, match="rights evidence is missing"): + guard.verify_model_policy(tmp_path, require_admitted=True) diff --git a/services/analysis-engine/tests/test_release_model_inventory_binding.py b/services/analysis-engine/tests/test_release_model_inventory_binding.py new file mode 100644 index 000000000..6926e8b84 --- /dev/null +++ b/services/analysis-engine/tests/test_release_model_inventory_binding.py @@ -0,0 +1,142 @@ +"""Distribution contracts binding admitted model bytes to the shipped component inventory.""" + +from __future__ import annotations + +import hashlib +import importlib.util +import json +from pathlib import Path +from types import ModuleType + +import pytest + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +_GUARD_PATH = _REPOSITORY_ROOT / "scripts" / "checks" / "verify_release_model_policy.py" +_EVIDENCE = { + "rightsEvidenceSha256": ("model-rights.txt", b"commercial rights evidence\n"), + "provenanceEvidenceSha256": ( + "model-provenance.json", + b'{"training":"cwl-owned","version":1}\n', + ), + "loaderPolicySha256": ( + "model-loader-policy.json", + b'{"serialization":"safetensors","network":false}\n', + ), +} + + +def _load_guard() -> ModuleType: + """Load the Distribution-owned model admission guard.""" + module_spec = importlib.util.spec_from_file_location( + "verify_release_model_policy_inventory", _GUARD_PATH + ) + assert module_spec is not None and module_spec.loader is not None + module = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(module) + return module + + +def _write_admitted_release(repository_root: Path) -> tuple[dict[str, object], bytes]: + """Write exact model/evidence bytes and an admitted release policy.""" + model_payload = b"commercially-admitted-model" + model_path = "release/models/cwl-rehearsal-separator-v1.safetensors" + artifact_path = repository_root / model_path + artifact_path.parent.mkdir(parents=True, exist_ok=True) + artifact_path.write_bytes(model_payload) + + evidence_dir = repository_root / "release" / "evidence" + evidence_dir.mkdir(parents=True, exist_ok=True) + evidence_digests: dict[str, str] = {} + for field, (filename, payload) in _EVIDENCE.items(): + (evidence_dir / filename).write_bytes(payload) + evidence_digests[field] = hashlib.sha256(payload).hexdigest() + + admitted: dict[str, object] = { + "modelId": "cwl/rehearsal-separator-v1", + "modelVersion": "1.0.0", + "path": model_path, + "sizeBytes": len(model_payload), + "sha256": hashlib.sha256(model_payload).hexdigest(), + "serialization": "safetensors", + **evidence_digests, + } + policy_path = repository_root / "release" / "model-artifact-policy.json" + policy_path.write_text( + json.dumps( + { + "schemaVersion": 1, + "releaseStatus": "admitted", + "blockedArtifact": { + "modelId": "demucs/htdemucs", + "checkpoint": "955717e8-8726e21a.th", + "reason": "commercial-rights-not-established", + "primaryEvidence": "https://github.com/facebookresearch/demucs/issues/327#issuecomment-1134828611", + }, + "admittedArtifact": admitted, + } + ), + encoding="utf-8", + ) + return admitted, model_payload + + +def _write_inventory(repository_root: Path, admitted: dict[str, object]) -> Path: + """Write the minimum repository inventory entry for one admitted model.""" + inventory_path = repository_root / "supply-chain" / "supplemental-component-inventory.json" + inventory_path.parent.mkdir(parents=True, exist_ok=True) + inventory_path.write_text( + json.dumps( + { + "version": 1, + "generatedBy": "test fixture", + "bundledBinaries": [], + "modelArtifacts": [ + { + "name": admitted["modelId"], + "version": admitted["modelVersion"], + "sourceUrl": "local-repo://release/models/cwl-rehearsal-separator-v1.safetensors", + "license": "Proprietary", + "checksum": f"sha256:{admitted['sha256']}", + "storagePath": admitted["path"], + "releaseUsage": "Packaged offline rehearsal source-separation model.", + "verification": "Distribution release admission full SHA-256.", + } + ], + "notes": [], + } + ), + encoding="utf-8", + ) + return inventory_path + + +def test_admitted_model_requires_backing_supply_chain_inventory(tmp_path: Path) -> None: + """Reject commercially admitted model bytes that are absent from shipped inventory.""" + guard = _load_guard() + _write_admitted_release(tmp_path) + + with pytest.raises(ValueError, match="supplemental model inventory is missing"): + guard.verify_model_policy(tmp_path, require_admitted=True) + + +def test_admitted_model_matches_exact_inventory_identity(tmp_path: Path) -> None: + """Accept inventory only when model ID/version/path/full digest match release policy.""" + guard = _load_guard() + admitted, _ = _write_admitted_release(tmp_path) + _write_inventory(tmp_path, admitted) + + policy = guard.verify_model_policy(tmp_path, require_admitted=True) + assert policy["admittedArtifact"]["modelId"] == admitted["modelId"] + + +def test_admitted_model_rejects_inventory_digest_substitution(tmp_path: Path) -> None: + """Reject an inventory entry that names the model but binds different artifact bytes.""" + guard = _load_guard() + admitted, _ = _write_admitted_release(tmp_path) + inventory_path = _write_inventory(tmp_path, admitted) + inventory = json.loads(inventory_path.read_text(encoding="utf-8")) + inventory["modelArtifacts"][0]["checksum"] = f"sha256:{'0' * 64}" + inventory_path.write_text(json.dumps(inventory), encoding="utf-8") + + with pytest.raises(ValueError, match="supplemental model inventory does not match admitted artifact"): + guard.verify_model_policy(tmp_path, require_admitted=True) diff --git a/services/analysis-engine/tests/test_release_model_policy.py b/services/analysis-engine/tests/test_release_model_policy.py new file mode 100644 index 000000000..98255e086 --- /dev/null +++ b/services/analysis-engine/tests/test_release_model_policy.py @@ -0,0 +1,307 @@ +"""Distribution contracts for commercially admissible release model artifacts.""" + +from __future__ import annotations + +import hashlib +import importlib.util +import json +from pathlib import Path +from types import ModuleType + +import pytest + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +_GUARD_PATH = _REPOSITORY_ROOT / "scripts" / "checks" / "verify_release_model_policy.py" +_IDENTITY_GUARD_PATH = _REPOSITORY_ROOT / "scripts" / "checks" / "verify_release_identity.py" +_PACKAGER_PATH = _REPOSITORY_ROOT / "scripts" / "release" / "package_desktop_artifact.py" +_EVIDENCE_BYTES = { + "rightsEvidenceSha256": ( + "release/evidence/model-rights.txt", + b"commercial rights grant for rehearsal separator\n", + ), + "provenanceEvidenceSha256": ( + "release/evidence/model-provenance.json", + b'{"source":"cwl-owned-training-pipeline","version":1}\n', + ), + "loaderPolicySha256": ( + "release/evidence/model-loader-policy.json", + b'{"serialization":"safetensors","network":false}\n', + ), +} + + +def _load_module(module_name: str, module_path: Path) -> ModuleType: + """Load one repository-owned executable guard for focused contract tests.""" + assert module_path.is_file(), f"release preflight guard is missing: {module_path.name}" + module_spec = importlib.util.spec_from_file_location(module_name, module_path) + assert module_spec is not None and module_spec.loader is not None + module = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(module) + return module + + +def _load_guard() -> ModuleType: + """Load the Distribution-owned model release guard from its executable path.""" + return _load_module("verify_release_model_policy", _GUARD_PATH) + + +def _write_release_metadata(repository_root: Path, release_version: str) -> None: + """Write the version projections consumed by the composed release preflight.""" + (repository_root / "apps" / "desktop" / "src-tauri").mkdir(parents=True) + (repository_root / "VERSION").write_text(f"{release_version}\n", encoding="utf-8") + (repository_root / "package.json").write_text( + json.dumps({"name": "bandscope", "version": release_version}), + encoding="utf-8", + ) + (repository_root / "apps" / "desktop" / "src-tauri" / "tauri.conf.json").write_text( + json.dumps({"productName": "BandScope", "version": release_version}), + encoding="utf-8", + ) + + +def _write_policy( + repository_root: Path, + *, + release_status: str, + admitted_artifact: dict[str, object] | None, +) -> Path: + """Write a minimal release model policy for one isolated verifier scenario.""" + policy_path = repository_root / "release" / "model-artifact-policy.json" + policy_path.parent.mkdir(parents=True, exist_ok=True) + policy_path.write_text( + json.dumps( + { + "schemaVersion": 1, + "releaseStatus": release_status, + "blockedArtifact": { + "modelId": "demucs/htdemucs", + "checkpoint": "955717e8-8726e21a.th", + "reason": "commercial-rights-not-established", + "primaryEvidence": "https://github.com/facebookresearch/demucs/issues/327#issuecomment-1134828611", + }, + "admittedArtifact": admitted_artifact, + } + ), + encoding="utf-8", + ) + return policy_path + + +def _write_evidence_files(repository_root: Path) -> dict[str, str]: + """Create exact release evidence bytes and return their full SHA-256 bindings.""" + digests: dict[str, str] = {} + for digest_field, (relative_path, payload) in _EVIDENCE_BYTES.items(): + evidence_path = repository_root / relative_path + evidence_path.parent.mkdir(parents=True, exist_ok=True) + evidence_path.write_bytes(payload) + digests[digest_field] = hashlib.sha256(payload).hexdigest() + return digests + + +def _admitted_artifact( + repository_root: Path, artifact_path: str, payload: bytes +) -> dict[str, object]: + """Build immutable metadata, evidence, and inventory for an admitted test artifact.""" + evidence_digests = _write_evidence_files(repository_root) + admitted: dict[str, object] = { + "modelId": "cwl/rehearsal-separator-v1", + "modelVersion": "1.0.0", + "path": artifact_path, + "sizeBytes": len(payload), + "sha256": hashlib.sha256(payload).hexdigest(), + "serialization": "safetensors", + **evidence_digests, + } + inventory_path = repository_root / "supply-chain" / "supplemental-component-inventory.json" + inventory_path.parent.mkdir(parents=True, exist_ok=True) + inventory_path.write_text( + json.dumps( + { + "version": 1, + "generatedBy": "test fixture", + "bundledBinaries": [], + "modelArtifacts": [ + { + "name": admitted["modelId"], + "version": admitted["modelVersion"], + "sourceUrl": f"local-repo://{artifact_path}", + "license": "Proprietary", + "checksum": f"sha256:{admitted['sha256']}", + "storagePath": artifact_path, + "releaseUsage": "Packaged offline rehearsal source-separation model.", + "verification": "Distribution release admission full SHA-256.", + } + ], + "notes": [], + } + ), + encoding="utf-8", + ) + return admitted + + +def test_release_preflight_composes_model_policy_without_duplicate_workflow() -> None: + """Keep one preflight guard while making artifact packaging enforce it independently.""" + identity_guard_text = _IDENTITY_GUARD_PATH.read_text(encoding="utf-8") + quickcheck_text = ( + _REPOSITORY_ROOT / "scripts" / "harness" / "quickcheck.sh" + ).read_text(encoding="utf-8") + packager_text = _PACKAGER_PATH.read_text(encoding="utf-8") + assert "verify_model_policy" in identity_guard_text + assert "python3 scripts/checks/verify_release_identity.py" in quickcheck_text + assert "python3 scripts/checks/verify_release_model_policy.py" not in quickcheck_text + assert "verify_tag_release_preflight(repo_root)" in packager_text + + +def test_tag_packaging_requires_commercially_admitted_model( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Reject a version-tag preflight when the release model remains legally blocked.""" + _write_release_metadata(tmp_path, "1.2.3") + _write_policy(tmp_path, release_status="blocked", admitted_artifact=None) + identity_guard = _load_module("verify_release_identity", _IDENTITY_GUARD_PATH) + monkeypatch.setattr(identity_guard, "_REPOSITORY_ROOT", tmp_path) + monkeypatch.setenv("GITHUB_REF_TYPE", "tag") + monkeypatch.setenv("GITHUB_REF_NAME", "v1.2.3") + assert identity_guard.main() == 1 + + monkeypatch.delenv("GITHUB_REF_TYPE") + monkeypatch.delenv("GITHUB_REF_NAME") + assert identity_guard.main() == 0 + + +def test_repository_policy_is_valid_but_blocks_commercial_tag_release() -> None: + """Keep the known upstream-weight rights blocker executable in release policy.""" + guard = _load_guard() + policy = guard.verify_model_policy(_REPOSITORY_ROOT, require_admitted=False) + assert policy["releaseStatus"] == "blocked" + assert policy["admittedArtifact"] is None + + with pytest.raises(ValueError, match="commercial model artifact is not admitted"): + guard.verify_model_policy(_REPOSITORY_ROOT, require_admitted=True) + + +def test_admitted_artifact_requires_exact_size_and_full_sha256(tmp_path: Path) -> None: + """Admit only the exact regular model bytes named by immutable release metadata.""" + guard = _load_guard() + payload = b"rights-cleared-model-bytes" + artifact_path = "release/models/rehearsal-separator-v1.safetensors" + artifact_file = tmp_path / artifact_path + artifact_file.parent.mkdir(parents=True) + artifact_file.write_bytes(payload) + _write_policy( + tmp_path, + release_status="admitted", + admitted_artifact=_admitted_artifact(tmp_path, artifact_path, payload), + ) + + policy = guard.verify_model_policy(tmp_path, require_admitted=True) + assert policy["admittedArtifact"]["sha256"] == hashlib.sha256(payload).hexdigest() + + artifact_file.write_bytes(payload + b"-changed") + with pytest.raises(ValueError, match="model artifact size does not match policy"): + guard.verify_model_policy(tmp_path, require_admitted=True) + + +def test_admitted_artifact_rejects_same_size_digest_mismatch(tmp_path: Path) -> None: + """Reject same-size model substitution rather than treating byte count as identity.""" + guard = _load_guard() + payload = b"model-A" + artifact_path = "release/models/rehearsal-separator-v1.safetensors" + artifact_file = tmp_path / artifact_path + artifact_file.parent.mkdir(parents=True) + artifact_file.write_bytes(payload) + _write_policy( + tmp_path, + release_status="admitted", + admitted_artifact=_admitted_artifact(tmp_path, artifact_path, payload), + ) + artifact_file.write_bytes(b"model-B") + + with pytest.raises(ValueError, match="model artifact SHA-256 does not match policy"): + guard.verify_model_policy(tmp_path, require_admitted=True) + + +def test_model_policy_rejects_duplicate_json_members(tmp_path: Path) -> None: + """Reject ambiguous policy JSON instead of accepting a last-value-wins authority.""" + policy_path = tmp_path / "release" / "model-artifact-policy.json" + policy_path.parent.mkdir(parents=True) + policy_path.write_text( + '{"schemaVersion":1,"schemaVersion":1,"releaseStatus":"blocked",' + '"blockedArtifact":{},"admittedArtifact":null}', + encoding="utf-8", + ) + guard = _load_guard() + + with pytest.raises(ValueError, match="duplicate JSON member"): + guard.verify_model_policy(tmp_path, require_admitted=False) + + +def test_model_policy_rejects_path_escape_and_symlink(tmp_path: Path) -> None: + """Keep release model admission inside the repository and off link indirection.""" + guard = _load_guard() + payload = b"model" + _write_policy( + tmp_path, + release_status="admitted", + admitted_artifact=_admitted_artifact(tmp_path, "../outside.safetensors", payload), + ) + with pytest.raises(ValueError, match="model artifact path must be repository-relative"): + guard.verify_model_policy(tmp_path, require_admitted=True) + + real_file = tmp_path / "real-model.safetensors" + real_file.write_bytes(payload) + link_path = tmp_path / "release" / "models" / "model.safetensors" + link_path.parent.mkdir(parents=True, exist_ok=True) + try: + link_path.symlink_to(real_file) + except OSError: + pytest.skip("symlinks are unavailable on this test platform") + _write_policy( + tmp_path, + release_status="admitted", + admitted_artifact=_admitted_artifact( + tmp_path, "release/models/model.safetensors", payload + ), + ) + with pytest.raises(ValueError, match="model artifact must be a regular non-link file"): + guard.verify_model_policy(tmp_path, require_admitted=True) + + +def test_model_policy_rejects_unknown_keys_and_malformed_evidence(tmp_path: Path) -> None: + """Keep release authority schema exact and evidence digests unambiguous.""" + guard = _load_guard() + payload = b"model" + artifact_path = "release/models/model.safetensors" + artifact_file = tmp_path / artifact_path + artifact_file.parent.mkdir(parents=True) + artifact_file.write_bytes(payload) + admitted = _admitted_artifact(tmp_path, artifact_path, payload) + admitted["unexpected"] = True + _write_policy(tmp_path, release_status="admitted", admitted_artifact=admitted) + with pytest.raises(ValueError, match="unexpected admittedArtifact fields"): + guard.verify_model_policy(tmp_path, require_admitted=True) + + admitted = _admitted_artifact(tmp_path, artifact_path, payload) + admitted["rightsEvidenceSha256"] = "not-a-digest" + _write_policy(tmp_path, release_status="admitted", admitted_artifact=admitted) + with pytest.raises(ValueError, match="rightsEvidenceSha256 must be a full SHA-256"): + guard.verify_model_policy(tmp_path, require_admitted=True) + + +def test_admitted_artifact_rejects_tampered_evidence_bytes(tmp_path: Path) -> None: + """Reject evidence files that no longer match the digests carried by release policy.""" + guard = _load_guard() + payload = b"model" + artifact_path = "release/models/model.safetensors" + artifact_file = tmp_path / artifact_path + artifact_file.parent.mkdir(parents=True) + artifact_file.write_bytes(payload) + admitted = _admitted_artifact(tmp_path, artifact_path, payload) + _write_policy(tmp_path, release_status="admitted", admitted_artifact=admitted) + (tmp_path / "release" / "evidence" / "model-rights.txt").write_bytes( + b"tampered rights evidence\n" + ) + + with pytest.raises(ValueError, match="rights evidence SHA-256 does not match policy"): + guard.verify_model_policy(tmp_path, require_admitted=True) diff --git a/services/analysis-engine/tests/test_release_platform_trust.py b/services/analysis-engine/tests/test_release_platform_trust.py new file mode 100644 index 000000000..f3e0ca207 --- /dev/null +++ b/services/analysis-engine/tests/test_release_platform_trust.py @@ -0,0 +1,309 @@ +"""Platform signature and notarization gates for BandScope release artifacts.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import ModuleType, SimpleNamespace + +import pytest + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +_GUARD_PATH = _REPOSITORY_ROOT / "scripts" / "checks" / "verify_release_platform_trust.py" +_PACKAGER_PATH = _REPOSITORY_ROOT / "scripts" / "release" / "package_desktop_artifact.py" +_BUILD_BASELINE_PATH = _REPOSITORY_ROOT / ".github" / "workflows" / "build-baseline.yml" + + +def _load_module(path: Path, module_name: str) -> ModuleType: + """Load one repository-owned executable module without adding a package boundary.""" + assert path.is_file(), f"release boundary module is missing: {path.name}" + module_spec = importlib.util.spec_from_file_location(module_name, path) + assert module_spec is not None and module_spec.loader is not None + module = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(module) + return module + + +def _load_guard() -> ModuleType: + """Load the executable release-trust guard from its repository path.""" + return _load_module(_GUARD_PATH, "verify_release_platform_trust") + + +def _load_packager() -> ModuleType: + """Load the release packager that owns the tag-publication trust call.""" + return _load_module(_PACKAGER_PATH, "package_desktop_artifact_trust") + + +def _command_result( + returncode: int = 0, stdout: str = "", stderr: str = "" +) -> SimpleNamespace: + """Build the subprocess result shape consumed by the trust verifier.""" + return SimpleNamespace(returncode=returncode, stdout=stdout, stderr=stderr) + + +def _workflow_job_block(workflow_text: str, job_name: str) -> str: + """Return one top-level GitHub Actions job without adding a YAML dependency.""" + job_marker = f" {job_name}:" + workflow_lines = workflow_text.splitlines() + job_start_index = workflow_lines.index(job_marker) + job_end_index = len(workflow_lines) + for line_index in range(job_start_index + 1, len(workflow_lines)): + workflow_line = workflow_lines[line_index] + if ( + workflow_line.startswith(" ") + and not workflow_line.startswith(" ") + and workflow_line.endswith(":") + ): + job_end_index = line_index + break + return "\n".join(workflow_lines[job_start_index:job_end_index]) + + +def test_windows_release_trust_requires_valid_exact_publisher(tmp_path: Path) -> None: + """Accept only valid Authenticode signatures from the configured publisher.""" + guard = _load_guard() + artifact_path = tmp_path / "bandscope.exe" + artifact_path.write_bytes(b"signed-installer-placeholder") + commands: list[list[str]] = [] + + def valid_runner(command: list[str], **_: object) -> SimpleNamespace: + commands.append(command) + return _command_result( + stdout='{"Status":"Valid","Subject":"CN=ContextualWisdomLab"}' + ) + + verified = guard.verify_windows_artifacts( + tmp_path, "CN=ContextualWisdomLab", runner=valid_runner + ) + + assert verified == [artifact_path] + assert commands[0][0] == "pwsh" + assert str(artifact_path) == commands[0][-1] + + def unsigned_runner(command: list[str], **_: object) -> SimpleNamespace: + del command + return _command_result(stdout='{"Status":"NotSigned","Subject":null}') + + with pytest.raises(ValueError, match="valid Authenticode signature"): + guard.verify_windows_artifacts( + tmp_path, "CN=ContextualWisdomLab", runner=unsigned_runner + ) + + def wrong_publisher_runner(command: list[str], **_: object) -> SimpleNamespace: + del command + return _command_result( + stdout='{"Status":"Valid","Subject":"CN=Other Publisher"}' + ) + + with pytest.raises(ValueError, match="approved Windows publisher"): + guard.verify_windows_artifacts( + tmp_path, "CN=ContextualWisdomLab", runner=wrong_publisher_runner + ) + + +def test_windows_release_trust_fails_closed_without_identity_or_artifacts( + tmp_path: Path, +) -> None: + """Refuse a tag release when publisher authority or installers are absent.""" + guard = _load_guard() + + with pytest.raises(ValueError, match="Windows publisher subject"): + guard.verify_windows_artifacts(tmp_path, "") + + with pytest.raises(ValueError, match="Windows release installer"): + guard.verify_windows_artifacts(tmp_path, "CN=ContextualWisdomLab") + + +def test_macos_release_trust_requires_team_signature_and_stapled_ticket( + tmp_path: Path, +) -> None: + """Require Developer ID team identity plus offline notarization evidence.""" + guard = _load_guard() + artifact_root = tmp_path / "artifacts" + bundle_root = tmp_path / "bundle" / "macos" + artifact_root.mkdir() + app_path = bundle_root / "BandScope.app" + app_path.mkdir(parents=True) + dmg_path = artifact_root / "bandscope.dmg" + dmg_path.write_bytes(b"notarized-dmg-placeholder") + commands: list[list[str]] = [] + + def valid_runner(command: list[str], **_: object) -> SimpleNamespace: + commands.append(command) + if command[:3] == ["codesign", "--display", "--verbose=4"]: + return _command_result(stderr="TeamIdentifier=ABCDE12345\n") + return _command_result() + + verified_apps, verified_dmgs = guard.verify_macos_artifacts( + artifact_root, bundle_root, "ABCDE12345", runner=valid_runner + ) + + assert verified_apps == [app_path] + assert verified_dmgs == [dmg_path] + assert ["codesign", "--verify", "--deep", "--strict", str(app_path)] in commands + assert ["xcrun", "stapler", "validate", str(dmg_path)] in commands + assert [ + "spctl", + "--assess", + "--type", + "open", + "--context", + "context:primary-signature", + "--verbose=2", + str(dmg_path), + ] in commands + + +def test_macos_release_trust_fails_closed_on_wrong_team_or_notarization( + tmp_path: Path, +) -> None: + """Reject an unexpected signing team and a DMG without valid notarization evidence.""" + guard = _load_guard() + artifact_root = tmp_path / "artifacts" + bundle_root = tmp_path / "bundle" / "macos" + artifact_root.mkdir() + app_path = bundle_root / "BandScope.app" + app_path.mkdir(parents=True) + dmg_path = artifact_root / "bandscope.dmg" + dmg_path.write_bytes(b"dmg-placeholder") + + def wrong_team_runner(command: list[str], **_: object) -> SimpleNamespace: + if command[:3] == ["codesign", "--display", "--verbose=4"]: + return _command_result(stderr="TeamIdentifier=ZZZZZ99999\n") + return _command_result() + + with pytest.raises(ValueError, match="approved Apple Team ID"): + guard.verify_macos_artifacts( + artifact_root, bundle_root, "ABCDE12345", runner=wrong_team_runner + ) + + def unstapled_runner(command: list[str], **_: object) -> SimpleNamespace: + if command[:3] == ["codesign", "--display", "--verbose=4"]: + return _command_result(stderr="TeamIdentifier=ABCDE12345\n") + if command[:3] == ["xcrun", "stapler", "validate"]: + return _command_result(returncode=1, stderr="ticket missing") + return _command_result() + + with pytest.raises(ValueError, match="notarization ticket"): + guard.verify_macos_artifacts( + artifact_root, bundle_root, "ABCDE12345", runner=unstapled_runner + ) + + +def test_macos_release_trust_fails_closed_without_identity_or_outputs( + tmp_path: Path, +) -> None: + """Refuse a macOS release when configured team authority or outputs are absent.""" + guard = _load_guard() + artifact_root = tmp_path / "artifacts" + bundle_root = tmp_path / "bundle" + artifact_root.mkdir() + bundle_root.mkdir() + + with pytest.raises(ValueError, match="Apple Team ID"): + guard.verify_macos_artifacts(artifact_root, bundle_root, "") + + with pytest.raises(ValueError, match="macOS application bundle"): + guard.verify_macos_artifacts(artifact_root, bundle_root, "ABCDE12345") + + +def test_tag_packager_invokes_windows_trust_guard( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Bind Windows tag packaging to the native verifier before artifact upload.""" + packager = _load_packager() + monkeypatch.setenv("GITHUB_REF", "refs/tags/v1.2.3") + monkeypatch.setenv("BANDSCOPE_ARTIFACT_OS", "windows") + monkeypatch.setenv( + "BANDSCOPE_WINDOWS_PUBLISHER_SUBJECT", "CN=ContextualWisdomLab" + ) + commands: list[list[str]] = [] + + def runner(command: list[str], **_: object) -> SimpleNamespace: + commands.append(command) + return _command_result() + + packager.verify_tag_platform_trust(tmp_path, tmp_path / "artifacts", runner=runner) + + assert len(commands) == 1 + assert commands[0][2:5] == [ + "windows", + str(tmp_path / "artifacts"), + "--expected-identity", + ] + assert commands[0][-1] == "CN=ContextualWisdomLab" + + +def test_tag_packager_invokes_macos_trust_guard( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Bind macOS tag packaging to signature, team, and notarization verification.""" + packager = _load_packager() + monkeypatch.setenv("GITHUB_REF", "refs/tags/v1.2.3") + monkeypatch.setenv("BANDSCOPE_ARTIFACT_OS", "macos") + monkeypatch.setenv("BANDSCOPE_TARGET_TRIPLE", "aarch64-apple-darwin") + monkeypatch.setenv("BANDSCOPE_APPLE_TEAM_ID", "ABCDE12345") + commands: list[list[str]] = [] + + def runner(command: list[str], **_: object) -> SimpleNamespace: + commands.append(command) + return _command_result() + + packager.verify_tag_platform_trust(tmp_path, tmp_path / "artifacts", runner=runner) + + expected_bundle_root = ( + tmp_path + / "apps" + / "desktop" + / "src-tauri" + / "target" + / "aarch64-apple-darwin" + / "release" + / "bundle" + / "macos" + ) + assert len(commands) == 1 + assert "macos" in commands[0] + assert str(expected_bundle_root) in commands[0] + assert commands[0][-1] == "ABCDE12345" + + +def test_tag_packager_is_fail_closed_and_non_tag_packaging_stays_build_only( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Reject failed release trust while leaving ordinary validation builds unsigned.""" + packager = _load_packager() + commands: list[list[str]] = [] + + def failing_runner(command: list[str], **_: object) -> SimpleNamespace: + commands.append(command) + return _command_result(returncode=1) + + monkeypatch.setenv("GITHUB_REF", "refs/heads/develop") + packager.verify_tag_platform_trust( + tmp_path, tmp_path / "artifacts", runner=failing_runner + ) + assert commands == [] + + monkeypatch.setenv("GITHUB_REF", "refs/tags/v1.2.3") + monkeypatch.setenv("BANDSCOPE_ARTIFACT_OS", "windows") + with pytest.raises(RuntimeError, match="Platform release trust verification failed"): + packager.verify_tag_platform_trust( + tmp_path, tmp_path / "artifacts", runner=failing_runner + ) + + +def test_tag_builds_package_before_artifact_upload() -> None: + """Keep immutable publication downstream of the packager-owned trust gate.""" + workflow_text = _BUILD_BASELINE_PATH.read_text(encoding="utf-8") + + for job_name in ( + "build-windows-native", + "build-windows-arm64", + "build-macos-native", + "build-macos-arm64", + ): + job_block = _workflow_job_block(workflow_text, job_name) + packaging_index = job_block.index("scripts/release/package_desktop_artifact.py") + upload_index = job_block.index("uses: actions/upload-artifact@") + assert packaging_index < upload_index diff --git a/services/analysis-engine/tests/test_release_receipt.py b/services/analysis-engine/tests/test_release_receipt.py new file mode 100644 index 000000000..9a185c036 --- /dev/null +++ b/services/analysis-engine/tests/test_release_receipt.py @@ -0,0 +1,323 @@ +"""Distribution contracts for the exact packaged-release receipt.""" + +from __future__ import annotations + +import hashlib +import importlib.util +import json +import os +import stat +from pathlib import Path +from types import ModuleType, SimpleNamespace + +import pytest + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +_PACKAGER_PATH = _REPOSITORY_ROOT / "scripts" / "release" / "package_desktop_artifact.py" + + +def _load_packager() -> ModuleType: + """Load the repository-owned packager for focused release-receipt tests.""" + module_spec = importlib.util.spec_from_file_location( + "package_desktop_artifact_release_receipt", _PACKAGER_PATH + ) + assert module_spec is not None and module_spec.loader is not None + module = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(module) + return module + + +def _write_packaged_artifact( + output_dir: Path, + *, + archive_name: str = "bandscope-windows-amd64-deadbeef0000.exe", + payload: bytes = b"signed-installer-bytes", +) -> object: + """Create one checksum-bound packaged artifact using the production value object.""" + packager = _load_packager() + output_dir.mkdir(parents=True, exist_ok=True) + archive_path = output_dir / archive_name + archive_path.write_bytes(payload) + checksum_name = f"{archive_name}.sha256" + (output_dir / checksum_name).write_text( + f"{hashlib.sha256(payload).hexdigest()} {archive_name}\n", + encoding="utf-8", + ) + manifest_name = f"{archive_name}.manifest.txt" + (output_dir / manifest_name).write_text( + "platform=windows\narch=amd64\ntarget_triple=x86_64-pc-windows-msvc\n", + encoding="utf-8", + ) + return packager.PackagedArtifact( + platform="windows", + arch="amd64", + target_triple="x86_64-pc-windows-msvc", + archive_name=archive_name, + checksum_name=checksum_name, + manifest_name=manifest_name, + ) + + +def _set_tag_identity(monkeypatch: pytest.MonkeyPatch, version: str = "1.2.3") -> None: + """Set exact GitHub tag/commit identity used by tagged receipt scenarios.""" + monkeypatch.setenv("GITHUB_REF", f"refs/tags/v{version}") + monkeypatch.setenv("GITHUB_SHA", "a" * 40) + + +def test_tag_release_receipt_binds_version_commit_and_exact_artifact_bytes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Record one deterministic receipt only for the exact trusted tag artifact bytes.""" + packager = _load_packager() + (tmp_path / "VERSION").write_text("1.2.3\n", encoding="utf-8") + output_dir = tmp_path / "artifacts" + packaged_artifact = _write_packaged_artifact(output_dir) + _set_tag_identity(monkeypatch) + + receipt_path = packager.write_release_receipt( + tmp_path, output_dir, [packaged_artifact] + ) + + assert receipt_path == ( + output_dir / "bandscope-windows-amd64-aaaaaaaaaaaa.release-receipt.json" + ) + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + assert receipt == { + "schemaVersion": 1, + "version": "1.2.3", + "tag": "v1.2.3", + "sourceCommit": "a" * 40, + "target": { + "platform": "windows", + "arch": "amd64", + "targetTriple": "x86_64-pc-windows-msvc", + }, + "artifacts": [ + { + "archive": packaged_artifact.archive_name, + "sizeBytes": len(b"signed-installer-bytes"), + "sha256": hashlib.sha256(b"signed-installer-bytes").hexdigest(), + "checksumFile": packaged_artifact.checksum_name, + "manifestFile": packaged_artifact.manifest_name, + } + ], + } + assert receipt_path.read_text(encoding="utf-8").endswith("\n") + + +def test_release_receipt_rejects_artifact_drift_after_checksum( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Fail closed if packaged installer bytes drift after their checksum was written.""" + packager = _load_packager() + (tmp_path / "VERSION").write_text("1.2.3\n", encoding="utf-8") + output_dir = tmp_path / "artifacts" + packaged_artifact = _write_packaged_artifact(output_dir, payload=b"model-A") + (output_dir / packaged_artifact.archive_name).write_bytes(b"model-B") + _set_tag_identity(monkeypatch) + + with pytest.raises(RuntimeError, match="packaged artifact checksum does not match"): + packager.write_release_receipt(tmp_path, output_dir, [packaged_artifact]) + + +def test_release_receipt_requires_exact_tag_and_full_source_commit( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Reject receipts whose release tag or source commit is not the exact release identity.""" + packager = _load_packager() + (tmp_path / "VERSION").write_text("1.2.3\n", encoding="utf-8") + output_dir = tmp_path / "artifacts" + packaged_artifact = _write_packaged_artifact(output_dir) + monkeypatch.setenv("GITHUB_REF", "refs/tags/v1.2.4") + monkeypatch.setenv("GITHUB_SHA", "c" * 40) + with pytest.raises(RuntimeError, match="release receipt tag does not match VERSION"): + packager.write_release_receipt(tmp_path, output_dir, [packaged_artifact]) + + monkeypatch.setenv("GITHUB_REF", "refs/tags/v1.2.3") + monkeypatch.setenv("GITHUB_SHA", "short-sha") + with pytest.raises(RuntimeError, match="exact 40-character GITHUB_SHA"): + packager.write_release_receipt(tmp_path, output_dir, [packaged_artifact]) + + +def test_release_receipt_rejects_ambiguous_version_authority( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Keep receipt generation bound to the same unambiguous VERSION authority.""" + packager = _load_packager() + output_dir = tmp_path / "artifacts" + packaged_artifact = _write_packaged_artifact(output_dir) + _set_tag_identity(monkeypatch) + + (tmp_path / "VERSION").write_text("1.2.3\n2.0.0\n", encoding="utf-8") + with pytest.raises(RuntimeError, match="requires one VERSION line"): + packager.write_release_receipt(tmp_path, output_dir, [packaged_artifact]) + + (tmp_path / "VERSION").write_text(" 1.2.3\n", encoding="utf-8") + with pytest.raises(RuntimeError, match="must not contain surrounding whitespace"): + packager.write_release_receipt(tmp_path, output_dir, [packaged_artifact]) + + +def test_release_receipt_rejects_empty_or_mixed_target_inventory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Require one non-empty platform/architecture target per receipt.""" + packager = _load_packager() + (tmp_path / "VERSION").write_text("1.2.3\n", encoding="utf-8") + output_dir = tmp_path / "artifacts" + _set_tag_identity(monkeypatch) + + with pytest.raises(RuntimeError, match="at least one packaged artifact"): + packager.write_release_receipt(tmp_path, output_dir, []) + + first = _write_packaged_artifact(output_dir) + second = _write_packaged_artifact( + output_dir, + archive_name="bandscope-macos-amd64-deadbeef0000.dmg", + payload=b"signed-macos-installer", + )._replace(platform="macos", target_triple="x86_64-apple-darwin") + with pytest.raises(RuntimeError, match="cannot mix platform targets"): + packager.write_release_receipt(tmp_path, output_dir, [first, second]) + + +def test_release_receipt_rejects_missing_or_malformed_support_files( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Require exact checksum syntax and a regular per-artifact manifest.""" + packager = _load_packager() + (tmp_path / "VERSION").write_text("1.2.3\n", encoding="utf-8") + output_dir = tmp_path / "artifacts" + packaged_artifact = _write_packaged_artifact(output_dir) + _set_tag_identity(monkeypatch) + + manifest_path = output_dir / packaged_artifact.manifest_name + manifest_path.unlink() + with pytest.raises(RuntimeError, match="manifest must be a regular non-link file"): + packager.write_release_receipt(tmp_path, output_dir, [packaged_artifact]) + + manifest_path.write_text("restored\n", encoding="utf-8") + checksum_path = output_dir / packaged_artifact.checksum_name + checksum_path.write_text("not-a-checksum\n", encoding="utf-8") + with pytest.raises(RuntimeError, match="checksum file is malformed"): + packager.write_release_receipt(tmp_path, output_dir, [packaged_artifact]) + + checksum_path.write_text("x" * 513, encoding="utf-8") + with pytest.raises(RuntimeError, match="checksum file is unexpectedly large"): + packager.write_release_receipt(tmp_path, output_dir, [packaged_artifact]) + + +def test_release_receipt_rejects_linked_or_missing_checksum( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Do not let supporting checksum authority resolve through missing/link indirection.""" + packager = _load_packager() + (tmp_path / "VERSION").write_text("1.2.3\n", encoding="utf-8") + output_dir = tmp_path / "artifacts" + packaged_artifact = _write_packaged_artifact(output_dir) + _set_tag_identity(monkeypatch) + checksum_path = output_dir / packaged_artifact.checksum_name + checksum_path.unlink() + + with pytest.raises(RuntimeError, match="checksum must be a regular non-link file"): + packager.write_release_receipt(tmp_path, output_dir, [packaged_artifact]) + + target = output_dir / "other-checksum.txt" + target.write_text("placeholder\n", encoding="utf-8") + try: + checksum_path.symlink_to(target) + except OSError: + pytest.skip("symlinks are unavailable on this test platform") + with pytest.raises(RuntimeError, match="checksum must be a regular non-link file"): + packager.write_release_receipt(tmp_path, output_dir, [packaged_artifact]) + + +def test_release_receipt_rejects_symlinked_archive( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Do not derive immutable release authority through archive symlink indirection.""" + packager = _load_packager() + (tmp_path / "VERSION").write_text("1.2.3\n", encoding="utf-8") + output_dir = tmp_path / "artifacts" + packaged_artifact = _write_packaged_artifact(output_dir) + _set_tag_identity(monkeypatch) + archive_path = output_dir / packaged_artifact.archive_name + payload = archive_path.read_bytes() + archive_path.unlink() + target = output_dir / "other.exe" + target.write_bytes(payload) + try: + archive_path.symlink_to(target) + except OSError: + pytest.skip("symlinks are unavailable on this test platform") + + with pytest.raises(RuntimeError, match="artifact must not be a symlink"): + packager.write_release_receipt(tmp_path, output_dir, [packaged_artifact]) + + +def test_stable_file_identity_fails_on_non_regular_or_drifting_descriptor( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Fail closed when the opened descriptor is not regular or changes while hashing.""" + packager = _load_packager() + archive_path = tmp_path / "archive.bin" + archive_path.write_bytes(b"payload") + real_fstat = os.fstat + + monkeypatch.setattr( + packager.os, + "fstat", + lambda _descriptor: SimpleNamespace( + st_mode=stat.S_IFDIR, + st_dev=1, + st_ino=1, + st_size=0, + ), + ) + with pytest.raises(RuntimeError, match="artifact must be a regular file"): + packager._stable_regular_file_identity(archive_path) + + calls = 0 + + def drifting_fstat(descriptor: int) -> object: + nonlocal calls + calls += 1 + result = real_fstat(descriptor) + if calls == 1: + return result + return SimpleNamespace( + st_mode=result.st_mode, + st_dev=result.st_dev, + st_ino=result.st_ino, + st_size=result.st_size + 1, + ) + + monkeypatch.setattr(packager.os, "fstat", drifting_fstat) + with pytest.raises(RuntimeError, match="artifact changed while hashing"): + packager._stable_regular_file_identity(archive_path) + + +def test_non_tag_packaging_does_not_publish_release_receipt( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Keep unsigned PR/develop packages from masquerading as immutable release receipts.""" + packager = _load_packager() + (tmp_path / "VERSION").write_text("1.2.3\n", encoding="utf-8") + output_dir = tmp_path / "artifacts" + packaged_artifact = _write_packaged_artifact(output_dir) + monkeypatch.setenv("GITHUB_REF", "refs/heads/develop") + monkeypatch.setenv("GITHUB_SHA", "d" * 40) + + assert ( + packager.write_release_receipt(tmp_path, output_dir, [packaged_artifact]) + is None + ) + assert list(output_dir.glob("*.release-receipt.json")) == [] + + +def test_tag_packager_writes_receipt_only_after_platform_trust() -> None: + """Never publish release receipt authority before native signing/notarization checks pass.""" + packager_text = _PACKAGER_PATH.read_text(encoding="utf-8") + trust_call = "verify_tag_platform_trust(repo_root, output_dir)" + receipt_call = "write_release_receipt(" + assert trust_call in packager_text + assert receipt_call in packager_text + assert packager_text.index(trust_call) < packager_text.index(receipt_call) diff --git a/services/analysis-engine/tests/test_release_updater_artifact_binding.py b/services/analysis-engine/tests/test_release_updater_artifact_binding.py new file mode 100644 index 000000000..43f795c07 --- /dev/null +++ b/services/analysis-engine/tests/test_release_updater_artifact_binding.py @@ -0,0 +1,287 @@ +"""Distribution contracts for exact Tauri updater artifact binding.""" + +from __future__ import annotations + +import hashlib +import importlib.util +import json +from pathlib import Path +from types import ModuleType + +import pytest + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +_PACKAGER_PATH = _REPOSITORY_ROOT / "scripts" / "release" / "package_desktop_artifact.py" + + +def _load_packager() -> ModuleType: + """Load the repository-owned desktop packager for focused updater tests.""" + module_spec = importlib.util.spec_from_file_location( + "package_desktop_artifact_updater_binding", _PACKAGER_PATH + ) + assert module_spec is not None and module_spec.loader is not None + module = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(module) + return module + + +def _set_tag_target( + monkeypatch: pytest.MonkeyPatch, + *, + platform_name: str, + arch: str, + target_triple: str, +) -> None: + """Set exact tag/target identity used by updater packaging scenarios.""" + monkeypatch.setenv("GITHUB_REF", "refs/tags/v1.2.3") + monkeypatch.setenv("GITHUB_SHA", "a" * 40) + monkeypatch.setenv("BANDSCOPE_ARTIFACT_OS", platform_name) + monkeypatch.setenv("BANDSCOPE_ARTIFACT_ARCH", arch) + monkeypatch.setenv("BANDSCOPE_TARGET_TRIPLE", target_triple) + + +def _write_standard_packaged_artifact( + packager: ModuleType, + output_dir: Path, + *, + platform_name: str, + arch: str, + target_triple: str, + archive_name: str, + payload: bytes, +) -> object: + """Write one checksum-bound standard artifact used by the receipt contract.""" + output_dir.mkdir(parents=True, exist_ok=True) + archive_path = output_dir / archive_name + archive_path.write_bytes(payload) + checksum_name = f"{archive_name}.sha256" + (output_dir / checksum_name).write_text( + f"{hashlib.sha256(payload).hexdigest()} {archive_name}\n", + encoding="utf-8", + ) + manifest_name = f"{archive_name}.manifest.txt" + (output_dir / manifest_name).write_text( + f"platform={platform_name}\narch={arch}\ntarget_triple={target_triple}\n", + encoding="utf-8", + ) + return packager.PackagedArtifact( + platform=platform_name, + arch=arch, + target_triple=target_triple, + archive_name=archive_name, + checksum_name=checksum_name, + manifest_name=manifest_name, + ) + + +def test_windows_tag_requires_adjacent_tauri_signature_and_binds_exact_bytes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Windows release evidence must include the exact Tauri signature for each installer.""" + packager = _load_packager() + target_triple = "x86_64-pc-windows-msvc" + _set_tag_target( + monkeypatch, + platform_name="windows", + arch="amd64", + target_triple=target_triple, + ) + bundle_root = ( + tmp_path + / "apps" + / "desktop" + / "src-tauri" + / "target" + / target_triple + / "release" + / "bundle" + / "nsis" + ) + bundle_root.mkdir(parents=True) + source_installer = bundle_root / "BandScope-setup.exe" + source_installer.write_bytes(b"signed-windows-installer") + output_dir = tmp_path / "artifacts" + packaged_artifact = _write_standard_packaged_artifact( + packager, + output_dir, + platform_name="windows", + arch="amd64", + target_triple=target_triple, + archive_name="bandscope-windows-amd64-aaaaaaaaaaaa.exe", + payload=b"signed-windows-installer", + ) + + with pytest.raises(RuntimeError, match="updater signature"): + packager.package_tag_updater_artifacts( + tmp_path, + output_dir, + [(source_installer, packaged_artifact)], + ) + + signature_bytes = b"untrusted comment: signature\ntrusted-signature-payload\n" + Path(f"{source_installer}.sig").write_bytes(signature_bytes) + updater_artifacts = packager.package_tag_updater_artifacts( + tmp_path, + output_dir, + [(source_installer, packaged_artifact)], + ) + + assert len(updater_artifacts) == 1 + updater = updater_artifacts[0] + assert updater.bundle_name == packaged_artifact.archive_name + assert updater.signature_name == f"{packaged_artifact.archive_name}.sig" + assert (output_dir / updater.signature_name).read_bytes() == signature_bytes + assert updater.signature_sha256 == hashlib.sha256(signature_bytes).hexdigest() + + +def test_macos_tag_requires_app_tarball_and_signature_before_release_receipt( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """macOS updater evidence must carry the Tauri .app.tar.gz bundle and its signature.""" + packager = _load_packager() + target_triple = "aarch64-apple-darwin" + _set_tag_target( + monkeypatch, + platform_name="macos", + arch="arm64", + target_triple=target_triple, + ) + bundle_root = ( + tmp_path + / "apps" + / "desktop" + / "src-tauri" + / "target" + / target_triple + / "release" + / "bundle" + ) + dmg_root = bundle_root / "dmg" + macos_root = bundle_root / "macos" + dmg_root.mkdir(parents=True) + macos_root.mkdir(parents=True) + source_dmg = dmg_root / "BandScope.dmg" + source_dmg.write_bytes(b"notarized-dmg") + output_dir = tmp_path / "artifacts" + packaged_artifact = _write_standard_packaged_artifact( + packager, + output_dir, + platform_name="macos", + arch="arm64", + target_triple=target_triple, + archive_name="bandscope-macos-arm64-aaaaaaaaaaaa.dmg", + payload=b"notarized-dmg", + ) + + with pytest.raises(RuntimeError, match="macOS updater bundle"): + packager.package_tag_updater_artifacts( + tmp_path, + output_dir, + [(source_dmg, packaged_artifact)], + ) + + updater_bundle = macos_root / "BandScope.app.tar.gz" + updater_bundle.write_bytes(b"signed-app-tarball") + with pytest.raises(RuntimeError, match="updater signature"): + packager.package_tag_updater_artifacts( + tmp_path, + output_dir, + [(source_dmg, packaged_artifact)], + ) + + signature_bytes = b"untrusted comment: signature\nmacos-signature\n" + Path(f"{updater_bundle}.sig").write_bytes(signature_bytes) + updater_artifacts = packager.package_tag_updater_artifacts( + tmp_path, + output_dir, + [(source_dmg, packaged_artifact)], + ) + + updater = updater_artifacts[0] + assert updater.bundle_name == "bandscope-macos-arm64-aaaaaaaaaaaa.app.tar.gz" + assert updater.signature_name == f"{updater.bundle_name}.sig" + assert (output_dir / updater.bundle_name).read_bytes() == b"signed-app-tarball" + assert (output_dir / updater.signature_name).read_bytes() == signature_bytes + + +def test_release_receipt_binds_updater_bundle_and_signature_against_post_copy_drift( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Receipt generation must fail if copied updater evidence drifts before publication.""" + packager = _load_packager() + target_triple = "x86_64-pc-windows-msvc" + _set_tag_target( + monkeypatch, + platform_name="windows", + arch="amd64", + target_triple=target_triple, + ) + (tmp_path / "VERSION").write_text("1.2.3\n", encoding="utf-8") + source_root = ( + tmp_path + / "apps" + / "desktop" + / "src-tauri" + / "target" + / target_triple + / "release" + / "bundle" + / "nsis" + ) + source_root.mkdir(parents=True) + source_installer = source_root / "BandScope-setup.exe" + source_installer.write_bytes(b"signed-installer") + Path(f"{source_installer}.sig").write_bytes(b"signature-v1") + output_dir = tmp_path / "artifacts" + packaged_artifact = _write_standard_packaged_artifact( + packager, + output_dir, + platform_name="windows", + arch="amd64", + target_triple=target_triple, + archive_name="bandscope-windows-amd64-aaaaaaaaaaaa.exe", + payload=b"signed-installer", + ) + updater_artifacts = packager.package_tag_updater_artifacts( + tmp_path, + output_dir, + [(source_installer, packaged_artifact)], + ) + updater = updater_artifacts[0] + + receipt_path = packager.write_release_receipt( + tmp_path, + output_dir, + [packaged_artifact], + updater_artifacts, + ) + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + assert receipt["updaterArtifacts"] == [ + { + "bundle": updater.bundle_name, + "sizeBytes": updater.bundle_size_bytes, + "sha256": updater.bundle_sha256, + "signatureFile": updater.signature_name, + "signatureSizeBytes": updater.signature_size_bytes, + "signatureSha256": updater.signature_sha256, + } + ] + + (output_dir / updater.signature_name).write_bytes(b"signature-v2") + with pytest.raises(RuntimeError, match="updater signature changed"): + packager.write_release_receipt( + tmp_path, + output_dir, + [packaged_artifact], + updater_artifacts, + ) + + +def test_non_tag_build_does_not_require_or_publish_updater_artifacts( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Unsigned branch validation remains independent from commercial updater authority.""" + packager = _load_packager() + monkeypatch.setenv("GITHUB_REF", "refs/heads/develop") + + assert packager.package_tag_updater_artifacts(tmp_path, tmp_path / "artifacts", []) == [] diff --git a/services/analysis-engine/tests/test_release_updater_policy.py b/services/analysis-engine/tests/test_release_updater_policy.py new file mode 100644 index 000000000..5d8b08273 --- /dev/null +++ b/services/analysis-engine/tests/test_release_updater_policy.py @@ -0,0 +1,246 @@ +"""Distribution contracts for the BandScope updater release policy.""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +from types import ModuleType + +import pytest + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +_GUARD_PATH = _REPOSITORY_ROOT / "scripts" / "checks" / "verify_release_updater_policy.py" +_RELEASE_IDENTITY_PATH = _REPOSITORY_ROOT / "scripts" / "checks" / "verify_release_identity.py" + + +def _load_guard() -> ModuleType: + """Load the updater policy guard from its executable repository path.""" + assert _GUARD_PATH.is_file(), "release preflight must own an updater policy guard" + module_spec = importlib.util.spec_from_file_location( + "verify_release_updater_policy", _GUARD_PATH + ) + assert module_spec is not None and module_spec.loader is not None + module = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(module) + return module + + +def _write_fixture( + repository_root: Path, + *, + state: str, + public_key: str | None, + endpoints: list[str], + create_updater_artifacts: bool = False, + updater_config: dict[str, object] | None = None, +) -> None: + """Write the minimum updater authority plus valid admitted runtime wiring.""" + (repository_root / "release").mkdir(parents=True, exist_ok=True) + tauri_root = repository_root / "apps" / "desktop" / "src-tauri" + source_root = tauri_root / "src" + source_root.mkdir(parents=True, exist_ok=True) + (repository_root / "release" / "updater-policy.json").write_text( + json.dumps( + { + "schemaVersion": 1, + "state": state, + "channel": "stable", + "minimumSupportedVersion": "0.1.3", + "publicKey": public_key, + "endpoints": endpoints, + "reason": ( + "External updater signing authority is not provisioned." + if state == "blocked" + else None + ), + } + ), + encoding="utf-8", + ) + tauri_document: dict[str, object] = { + "bundle": {"active": True, "createUpdaterArtifacts": create_updater_artifacts} + } + if updater_config is not None: + tauri_document["plugins"] = {"updater": updater_config} + (tauri_root / "tauri.conf.json").write_text( + json.dumps(tauri_document), encoding="utf-8" + ) + + if state == "admitted": + (tauri_root / "Cargo.toml").write_text( + "[package]\nname = \"bandscope-desktop\"\nversion = \"0.1.0\"\n" + "edition = \"2021\"\n\n[dependencies]\n" + "tauri = \"2.11.1\"\n" + "tauri-plugin-updater = \"2.9.0\"\n", + encoding="utf-8", + ) + (tauri_root / "Cargo.lock").write_text( + "version = 4\n\n" + "[[package]]\nname = \"bandscope-desktop\"\nversion = \"0.1.0\"\n\n" + "[[package]]\nname = \"tauri-plugin-updater\"\nversion = \"2.9.0\"\n" + "source = \"registry+https://github.com/rust-lang/crates.io-index\"\n" + "checksum = \"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"\n", + encoding="utf-8", + ) + (source_root / "main.rs").write_text( + "fn main() {\n" + " tauri::Builder::default()\n" + " .plugin(tauri_plugin_updater::Builder::new().build())\n" + " .run(tauri::generate_context!())\n" + " .expect(\"error while running tauri application\");\n" + "}\n", + encoding="utf-8", + ) + + +def test_checked_in_updater_policy_is_explicitly_blocked_until_authority_exists() -> None: + """Keep the repository honest while updater signing/publication authority is absent.""" + guard = _load_guard() + + policy = guard.verify_updater_policy(_REPOSITORY_ROOT, require_admitted=False) + + assert policy["state"] == "blocked" + assert policy["publicKey"] is None + assert policy["endpoints"] == [] + with pytest.raises(ValueError, match="commercial updater policy is blocked"): + guard.verify_updater_policy(_REPOSITORY_ROOT, require_admitted=True) + + +def test_release_identity_preflight_composes_updater_policy_guard() -> None: + """Require tag preflight to execute the updater guard rather than a detached audit.""" + preflight_text = _RELEASE_IDENTITY_PATH.read_text(encoding="utf-8") + + assert "verify_release_updater_policy.py" in preflight_text + assert "verify_updater_policy(" in preflight_text + assert "require_admitted=release_tag is not None" in preflight_text + + +def test_admitted_policy_requires_exact_tauri_public_key_and_https_endpoints( + tmp_path: Path, +) -> None: + """Bind admitted updater authority to the exact Tauri public key and HTTPS endpoints.""" + guard = _load_guard() + public_key = "trusted-minisign-public-key" + endpoints = ["https://releases.example.invalid/bandscope/latest.json"] + _write_fixture( + tmp_path, + state="admitted", + public_key=public_key, + endpoints=endpoints, + create_updater_artifacts=True, + updater_config={"pubkey": public_key, "endpoints": endpoints}, + ) + + policy = guard.verify_updater_policy(tmp_path, require_admitted=True) + + assert policy["state"] == "admitted" + + tauri_path = tmp_path / "apps" / "desktop" / "src-tauri" / "tauri.conf.json" + tauri_document = json.loads(tauri_path.read_text(encoding="utf-8")) + tauri_document["plugins"]["updater"]["pubkey"] = "wrong-key" + tauri_path.write_text(json.dumps(tauri_document), encoding="utf-8") + with pytest.raises(ValueError, match="public key does not match"): + guard.verify_updater_policy(tmp_path, require_admitted=True) + + +def test_admitted_policy_rejects_insecure_or_unapproved_endpoint(tmp_path: Path) -> None: + """Do not admit HTTP transport or endpoint drift outside the release authority.""" + guard = _load_guard() + public_key = "trusted-minisign-public-key" + endpoints = ["http://releases.example.invalid/bandscope/latest.json"] + _write_fixture( + tmp_path, + state="admitted", + public_key=public_key, + endpoints=endpoints, + create_updater_artifacts=True, + updater_config={"pubkey": public_key, "endpoints": endpoints}, + ) + with pytest.raises(ValueError, match="HTTPS"): + guard.verify_updater_policy(tmp_path, require_admitted=True) + + endpoints = ["https://releases.example.invalid/bandscope/latest.json"] + _write_fixture( + tmp_path, + state="admitted", + public_key=public_key, + endpoints=endpoints, + create_updater_artifacts=True, + updater_config={ + "pubkey": public_key, + "endpoints": ["https://mirror.example.invalid/latest.json"], + }, + ) + with pytest.raises(ValueError, match="endpoints do not match"): + guard.verify_updater_policy(tmp_path, require_admitted=True) + + +def test_admitted_policy_requires_updater_artifacts_and_safe_transport( + tmp_path: Path, +) -> None: + """Require signed updater artifacts and reject Tauri insecure-transport escape hatches.""" + guard = _load_guard() + public_key = "trusted-minisign-public-key" + endpoints = ["https://releases.example.invalid/bandscope/latest.json"] + _write_fixture( + tmp_path, + state="admitted", + public_key=public_key, + endpoints=endpoints, + create_updater_artifacts=False, + updater_config={"pubkey": public_key, "endpoints": endpoints}, + ) + with pytest.raises(ValueError, match="createUpdaterArtifacts"): + guard.verify_updater_policy(tmp_path, require_admitted=True) + + _write_fixture( + tmp_path, + state="admitted", + public_key=public_key, + endpoints=endpoints, + create_updater_artifacts=True, + updater_config={ + "pubkey": public_key, + "endpoints": endpoints, + "dangerousInsecureTransportProtocol": True, + }, + ) + with pytest.raises(ValueError, match="insecure transport"): + guard.verify_updater_policy(tmp_path, require_admitted=True) + + +def test_blocked_policy_cannot_hide_partially_enabled_updater(tmp_path: Path) -> None: + """Reject a blocked authority record when runtime updater capability is already enabled.""" + guard = _load_guard() + _write_fixture( + tmp_path, + state="blocked", + public_key=None, + endpoints=[], + create_updater_artifacts=True, + ) + + with pytest.raises(ValueError, match="blocked updater policy cannot enable"): + guard.verify_updater_policy(tmp_path, require_admitted=False) + + +def test_policy_rejects_duplicate_json_members(tmp_path: Path) -> None: + """Fail closed when duplicate policy members could create parser-dependent authority.""" + guard = _load_guard() + _write_fixture( + tmp_path, + state="blocked", + public_key=None, + endpoints=[], + ) + policy_path = tmp_path / "release" / "updater-policy.json" + policy_path.write_text( + '{"schemaVersion":1,"state":"blocked","state":"admitted",' + '"channel":"stable","minimumSupportedVersion":"0.1.3",' + '"publicKey":null,"endpoints":[],"reason":"blocked"}', + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="duplicate JSON member"): + guard.verify_updater_policy(tmp_path, require_admitted=False) diff --git a/services/analysis-engine/tests/test_release_updater_publication_graph.py b/services/analysis-engine/tests/test_release_updater_publication_graph.py new file mode 100644 index 000000000..4749b1d48 --- /dev/null +++ b/services/analysis-engine/tests/test_release_updater_publication_graph.py @@ -0,0 +1,178 @@ +"""Distribution contracts for publishing updater evidence without target collisions.""" + +from __future__ import annotations + +import hashlib +import json +import zipfile +from pathlib import Path + +from conftest import load_module + +_FULL_SHA = "abcdef0123456789abcdef0123456789abcdef01" +_SHORT_SHA = _FULL_SHA[:12] + + +def _digest(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest() + + +def _write_release_metadata(repo_root: Path) -> None: + (repo_root / "bandscope-sbom.cdx.json").write_text("{}", encoding="utf-8") + inventory = repo_root / "supply-chain" / "supplemental-component-inventory.json" + inventory.parent.mkdir(parents=True) + inventory.write_text("{}", encoding="utf-8") + + +def _target_triple(platform_name: str, arch: str) -> str: + if platform_name == "windows": + return "x86_64-pc-windows-msvc" if arch == "amd64" else "aarch64-pc-windows-msvc" + return "x86_64-apple-darwin" if arch == "amd64" else "aarch64-apple-darwin" + + +def _write_target_release_graph(repo_root: Path, platform_name: str, arch: str) -> list[str]: + """Write one target's installer, updater evidence, and exact receipt.""" + artifacts = repo_root / "artifacts" + artifacts.mkdir(parents=True, exist_ok=True) + target_triple = _target_triple(platform_name, arch) + installer_suffix = ".exe" if platform_name == "windows" else ".dmg" + installer_name = f"bandscope-{platform_name}-{arch}-{_SHORT_SHA}{installer_suffix}" + installer_payload = f"installer:{platform_name}:{arch}".encode() + (artifacts / installer_name).write_bytes(installer_payload) + checksum_name = f"{installer_name}.sha256" + (artifacts / checksum_name).write_text( + f"{_digest(installer_payload)} {installer_name}\n", encoding="utf-8" + ) + manifest_name = f"{installer_name}.manifest.txt" + (artifacts / manifest_name).write_text( + f"platform={platform_name}\narch={arch}\ntarget_triple={target_triple}\n", + encoding="utf-8", + ) + + if platform_name == "windows": + updater_name = installer_name + updater_payload = installer_payload + else: + updater_name = f"bandscope-macos-{arch}-{_SHORT_SHA}.app.tar.gz" + updater_payload = f"updater:{platform_name}:{arch}".encode() + (artifacts / updater_name).write_bytes(updater_payload) + signature_name = f"{updater_name}.sig" + signature_payload = f"signature:{platform_name}:{arch}".encode() + (artifacts / signature_name).write_bytes(signature_payload) + + receipt_name = f"bandscope-{platform_name}-{arch}-{_SHORT_SHA}.release-receipt.json" + receipt = { + "schemaVersion": 1, + "version": "1.2.3", + "tag": "v1.2.3", + "sourceCommit": _FULL_SHA, + "target": { + "platform": platform_name, + "arch": arch, + "targetTriple": target_triple, + }, + "artifacts": [ + { + "archive": installer_name, + "sizeBytes": len(installer_payload), + "sha256": _digest(installer_payload), + "checksumFile": checksum_name, + "manifestFile": manifest_name, + } + ], + "updaterArtifacts": [ + { + "bundle": updater_name, + "sizeBytes": len(updater_payload), + "sha256": _digest(updater_payload), + "signatureFile": signature_name, + "signatureSizeBytes": len(signature_payload), + "signatureSha256": _digest(signature_payload), + } + ], + } + (artifacts / receipt_name).write_text( + json.dumps(receipt, sort_keys=True) + "\n", encoding="utf-8" + ) + names = [installer_name, checksum_name, manifest_name, signature_name, receipt_name] + if platform_name == "macos": + names.append(updater_name) + return names + + +def test_release_extractor_accepts_target_receipt_and_tauri_updater_members( + tmp_path: Path, +) -> None: + """Downloaded tag artifacts must preserve updater payload/signature/receipt members.""" + extractor = load_module( + "scripts/release/extract_release_artifacts.py", + "extract_release_updater_publication_graph", + ) + archive_path = tmp_path / "release.zip" + members = { + f"bandscope-windows-amd64-{_SHORT_SHA}.exe": b"exe", + f"bandscope-windows-amd64-{_SHORT_SHA}.exe.sig": b"sig", + f"bandscope-macos-arm64-{_SHORT_SHA}.app.tar.gz": b"tar", + f"bandscope-macos-arm64-{_SHORT_SHA}.app.tar.gz.sig": b"sig", + f"bandscope-macos-arm64-{_SHORT_SHA}.release-receipt.json": b"{}\n", + } + with zipfile.ZipFile(archive_path, "w") as archive: + for name, payload in members.items(): + archive.writestr(name, payload) + + extracted = extractor.extract_release_artifacts(archive_path, tmp_path / "out") + + assert {path.name for path in extracted} == set(members) + + +def test_release_selector_requires_complete_updater_graph_and_target_receipts( + tmp_path: Path, +) -> None: + """Immutable publication must select and re-admit all four target updater receipts.""" + selector = load_module( + "scripts/release/select_release_assets.py", + "select_release_assets_updater_publication_graph", + ) + _write_release_metadata(tmp_path) + artifact_names: list[str] = [] + for platform_name, arch in [ + ("windows", "amd64"), + ("windows", "arm64"), + ("macos", "amd64"), + ("macos", "arm64"), + ]: + artifact_names.extend(_write_target_release_graph(tmp_path, platform_name, arch)) + + selected = selector.select_release_assets(tmp_path, git_sha=_FULL_SHA) + + assert selected == [ + *(f"artifacts/{name}" for name in sorted(artifact_names)), + "bandscope-sbom.cdx.json", + "supply-chain/supplemental-component-inventory.json", + ] + + +def test_release_selector_rejects_receipt_bound_updater_signature_drift(tmp_path: Path) -> None: + """Publisher re-admission must reject updater bytes changed after target packaging.""" + selector = load_module( + "scripts/release/select_release_assets.py", + "select_release_assets_updater_signature_drift", + ) + _write_release_metadata(tmp_path) + for platform_name, arch in [ + ("windows", "amd64"), + ("windows", "arm64"), + ("macos", "amd64"), + ("macos", "arm64"), + ]: + _write_target_release_graph(tmp_path, platform_name, arch) + + signature = tmp_path / "artifacts" / f"bandscope-windows-amd64-{_SHORT_SHA}.exe.sig" + signature.write_bytes(b"tampered-signature") + + try: + selector.select_release_assets(tmp_path, git_sha=_FULL_SHA) + except ValueError as error: + assert "receipt" in str(error) or "signature" in str(error) + else: + raise AssertionError("tampered updater signature must fail publisher re-admission") diff --git a/services/analysis-engine/tests/test_release_updater_runtime_wiring.py b/services/analysis-engine/tests/test_release_updater_runtime_wiring.py new file mode 100644 index 000000000..246198698 --- /dev/null +++ b/services/analysis-engine/tests/test_release_updater_runtime_wiring.py @@ -0,0 +1,131 @@ +"""Distribution contracts for admitted Tauri updater runtime wiring.""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +from types import ModuleType + +import pytest + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +_GUARD_PATH = _REPOSITORY_ROOT / "scripts" / "checks" / "verify_release_updater_policy.py" + + +def _load_guard() -> ModuleType: + """Load the updater policy guard from its executable repository path.""" + module_spec = importlib.util.spec_from_file_location( + "verify_release_updater_policy_runtime_wiring", _GUARD_PATH + ) + assert module_spec is not None and module_spec.loader is not None + module = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(module) + return module + + +def _write_admitted_fixture( + repository_root: Path, + *, + dependency: bool, + initializer: bool, +) -> None: + """Write one admitted updater fixture with optional compiled runtime wiring.""" + (repository_root / "release").mkdir(parents=True, exist_ok=True) + tauri_root = repository_root / "apps" / "desktop" / "src-tauri" + source_root = tauri_root / "src" + source_root.mkdir(parents=True, exist_ok=True) + + public_key = "trusted-minisign-public-key" + endpoints = ["https://releases.example.invalid/bandscope/latest.json"] + (repository_root / "release" / "updater-policy.json").write_text( + json.dumps( + { + "schemaVersion": 1, + "state": "admitted", + "channel": "stable", + "minimumSupportedVersion": "0.1.3", + "publicKey": public_key, + "endpoints": endpoints, + "reason": None, + } + ), + encoding="utf-8", + ) + (tauri_root / "tauri.conf.json").write_text( + json.dumps( + { + "bundle": {"createUpdaterArtifacts": True}, + "plugins": { + "updater": { + "pubkey": public_key, + "endpoints": endpoints, + } + }, + } + ), + encoding="utf-8", + ) + + dependency_line = 'tauri-plugin-updater = "2.9.0"\n' if dependency else "" + (tauri_root / "Cargo.toml").write_text( + "[package]\nname = \"bandscope-desktop\"\nversion = \"0.1.0\"\n" + "edition = \"2021\"\n\n[dependencies]\n" + f"tauri = \"2.11.1\"\n{dependency_line}", + encoding="utf-8", + ) + lock_packages = [ + "[[package]]\nname = \"bandscope-desktop\"\nversion = \"0.1.0\"\n", + ] + if dependency: + lock_packages.append( + "[[package]]\nname = \"tauri-plugin-updater\"\nversion = \"2.9.0\"\n" + "source = \"registry+https://github.com/rust-lang/crates.io-index\"\n" + "checksum = \"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"\n" + ) + (tauri_root / "Cargo.lock").write_text( + "version = 4\n\n" + "\n".join(lock_packages), encoding="utf-8" + ) + + initializer_line = ( + " .plugin(tauri_plugin_updater::Builder::new().build())\n" + if initializer + else "" + ) + (source_root / "main.rs").write_text( + "fn main() {\n" + " tauri::Builder::default()\n" + f"{initializer_line}" + " .run(tauri::generate_context!())\n" + " .expect(\"error while running tauri application\");\n" + "}\n", + encoding="utf-8", + ) + + +def test_admitted_updater_rejects_missing_compiled_plugin_dependency(tmp_path: Path) -> None: + """Config-only admission must not pass when updater code is absent from the binary graph.""" + guard = _load_guard() + _write_admitted_fixture(tmp_path, dependency=False, initializer=False) + + with pytest.raises(ValueError, match="tauri-plugin-updater"): + guard.verify_updater_policy(tmp_path, require_admitted=True) + + +def test_admitted_updater_rejects_dependency_without_runtime_initializer(tmp_path: Path) -> None: + """A locked updater crate is insufficient unless the desktop runtime installs the plugin.""" + guard = _load_guard() + _write_admitted_fixture(tmp_path, dependency=True, initializer=False) + + with pytest.raises(ValueError, match="runtime initializer"): + guard.verify_updater_policy(tmp_path, require_admitted=True) + + +def test_admitted_updater_accepts_locked_dependency_and_runtime_initializer(tmp_path: Path) -> None: + """Admit the source wiring contract only when config, lock graph, and runtime agree.""" + guard = _load_guard() + _write_admitted_fixture(tmp_path, dependency=True, initializer=True) + + policy = guard.verify_updater_policy(tmp_path, require_admitted=True) + + assert policy["state"] == "admitted" diff --git a/services/analysis-engine/tests/test_release_version_identity.py b/services/analysis-engine/tests/test_release_version_identity.py new file mode 100644 index 000000000..240e55420 --- /dev/null +++ b/services/analysis-engine/tests/test_release_version_identity.py @@ -0,0 +1,170 @@ +"""Release identity contracts for the packaged BandScope desktop application.""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +from types import ModuleType + +import pytest + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +_GUARD_PATH = _REPOSITORY_ROOT / "scripts" / "checks" / "verify_release_identity.py" +_PACKAGER_PATH = _REPOSITORY_ROOT / "scripts" / "release" / "package_desktop_artifact.py" + + +def _load_guard() -> ModuleType: + """Load the repository-owned release identity guard from its executable path.""" + assert _GUARD_PATH.is_file(), "release preflight must own a version identity guard" + guard_module_spec = importlib.util.spec_from_file_location( + "verify_release_identity", _GUARD_PATH + ) + assert guard_module_spec is not None and guard_module_spec.loader is not None + guard_module = importlib.util.module_from_spec(guard_module_spec) + guard_module_spec.loader.exec_module(guard_module) + return guard_module + + +def _write_release_metadata(repository_root: Path, release_version: str) -> None: + """Write the minimum release metadata consumed by the identity guard.""" + (repository_root / "apps" / "desktop" / "src-tauri").mkdir(parents=True) + (repository_root / "VERSION").write_text( + f"{release_version}\n", encoding="utf-8" + ) + (repository_root / "package.json").write_text( + json.dumps({"name": "bandscope", "version": release_version}), + encoding="utf-8", + ) + ( + repository_root / "apps" / "desktop" / "src-tauri" / "tauri.conf.json" + ).write_text( + json.dumps( + { + "productName": "BandScope", + "version": release_version, + "identifier": "com.bandscope.desktop", + } + ), + encoding="utf-8", + ) + + +def test_release_preflight_executes_version_identity_guard() -> None: + """Keep repository and release preflight fail-closed when versions drift.""" + quickcheck_text = ( + _REPOSITORY_ROOT / "scripts" / "harness" / "quickcheck.sh" + ).read_text(encoding="utf-8") + release_workflow_text = ( + _REPOSITORY_ROOT / ".github" / "workflows" / "release.yml" + ).read_text(encoding="utf-8") + + assert "python3 scripts/checks/verify_release_identity.py" in quickcheck_text + assert "./scripts/harness/quickcheck.sh" in release_workflow_text + + +def test_tag_packager_runs_release_preflight_before_artifact_writes() -> None: + """Prevent the build workflow from publishing around a failed preflight workflow.""" + packager_text = _PACKAGER_PATH.read_text(encoding="utf-8") + preflight_call = "verify_tag_release_preflight(repo_root)" + artifact_directory_creation = "output_dir.mkdir(parents=True, exist_ok=True)" + + assert preflight_call in packager_text + assert artifact_directory_creation in packager_text + assert packager_text.index(preflight_call) < packager_text.index( + artifact_directory_creation + ) + + +def test_repository_release_version_matches_authoritative_version_file() -> None: + """Verify checked-in projections without creating another version authority.""" + release_guard = _load_guard() + version_text = (_REPOSITORY_ROOT / "VERSION").read_text(encoding="utf-8") + assert version_text.endswith("\n") + expected_version = version_text.removesuffix("\n") + assert "\n" not in expected_version + assert ( + release_guard.verify_release_identity(_REPOSITORY_ROOT) == expected_version + ) + + +def test_release_identity_guard_rejects_metadata_drift(tmp_path: Path) -> None: + """Reject a package projection that diverges from the authoritative version.""" + release_guard = _load_guard() + _write_release_metadata(tmp_path, "1.2.3") + package_document = json.loads( + (tmp_path / "package.json").read_text(encoding="utf-8") + ) + package_document["version"] = "1.2.4" + (tmp_path / "package.json").write_text( + json.dumps(package_document), encoding="utf-8" + ) + + with pytest.raises(ValueError, match="package.json version does not match VERSION"): + release_guard.verify_release_identity(tmp_path) + + +def test_release_identity_guard_rejects_wrong_tag(tmp_path: Path) -> None: + """Reject a version tag that does not identify the exact VERSION release.""" + release_guard = _load_guard() + _write_release_metadata(tmp_path, "1.2.3") + + with pytest.raises(ValueError, match="release tag does not match VERSION"): + release_guard.verify_release_identity(tmp_path, release_tag="v1.2.2") + + +@pytest.mark.parametrize( + "invalid_version", + [ + "1.2.3-rc.1", + "1.2.3+build.7", + "01.2.3", + "1.02.3", + "1.2.03", + "1.2", + "v1.2.3", + "18446744073709551616.0.0", + "0.18446744073709551616.0", + "0.0.18446744073709551616", + ], +) +def test_release_identity_guard_rejects_noncanonical_stable_version( + tmp_path: Path, invalid_version: str +) -> None: + """Keep release publication aligned with the runtime's stable-version grammar.""" + release_guard = _load_guard() + _write_release_metadata(tmp_path, invalid_version) + + with pytest.raises( + ValueError, match="VERSION must be canonical stable MAJOR.MINOR.PATCH" + ): + release_guard.verify_release_identity(tmp_path) + + +def test_release_identity_guard_rejects_multiline_version_authority(tmp_path: Path) -> None: + """Reject an ambiguous VERSION file even if projections repeat the same text.""" + release_guard = _load_guard() + _write_release_metadata(tmp_path, "1.2.3") + ambiguous_version = "1.2.3\n2.0.0" + (tmp_path / "VERSION").write_text( + f"{ambiguous_version}\n", encoding="utf-8" + ) + (tmp_path / "package.json").write_text( + json.dumps({"name": "bandscope", "version": ambiguous_version}), + encoding="utf-8", + ) + (tmp_path / "apps" / "desktop" / "src-tauri" / "tauri.conf.json").write_text( + json.dumps( + { + "productName": "BandScope", + "version": ambiguous_version, + "identifier": "com.bandscope.desktop", + } + ), + encoding="utf-8", + ) + + with pytest.raises( + ValueError, match="VERSION must contain exactly one non-empty version line" + ): + release_guard.verify_release_identity(tmp_path) diff --git a/services/analysis-engine/tests/test_updater_manifest_publication.py b/services/analysis-engine/tests/test_updater_manifest_publication.py new file mode 100644 index 000000000..6126e697a --- /dev/null +++ b/services/analysis-engine/tests/test_updater_manifest_publication.py @@ -0,0 +1,334 @@ +"""Tests for exact updater-manifest generation and publication wiring.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import subprocess +import sys +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_BUILDER = _REPO_ROOT / "scripts" / "release" / "build_updater_manifest.py" +_WORKFLOW = _REPO_ROOT / ".github" / "workflows" / "build-baseline.yml" +_TARGETS = ( + ("windows", "amd64", "x86_64-pc-windows-msvc", ".exe"), + ("windows", "arm64", "aarch64-pc-windows-msvc", ".exe"), + ("macos", "amd64", "x86_64-apple-darwin", ".dmg"), + ("macos", "arm64", "aarch64-apple-darwin", ".dmg"), +) + + +def _digest(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest() + + +def _write_release_graph( + repo_root: Path, *, source_commit: str +) -> tuple[dict[str, str], dict[str, dict[str, object]]]: + """Write four receipt-bound updater targets and release metadata.""" + (repo_root / "VERSION").write_text("1.2.3\n", encoding="utf-8") + (repo_root / "bandscope-sbom.cdx.json").write_text("{}", encoding="utf-8") + inventory = repo_root / "supply-chain" / "supplemental-component-inventory.json" + inventory.parent.mkdir(parents=True) + inventory.write_text("{}", encoding="utf-8") + release = repo_root / "release" + release.mkdir() + (release / "updater-policy.json").write_text( + json.dumps( + { + "schemaVersion": 1, + "state": "admitted", + "channel": "stable", + "minimumSupportedVersion": "1.0.0", + "publicKey": "fixture-public-key", + "endpoints": ["https://updates.example.test/latest.json"], + "reason": None, + } + ), + encoding="utf-8", + ) + artifacts = repo_root / "artifacts" + artifacts.mkdir() + signatures: dict[str, str] = {} + updater_identities: dict[str, dict[str, object]] = {} + + for platform, arch, target_triple, suffix in _TARGETS: + archive_name = f"bandscope-{platform}-{arch}-{source_commit[:12]}{suffix}" + archive_payload = f"installer-{platform}-{arch}".encode() + (artifacts / archive_name).write_bytes(archive_payload) + checksum_name = f"{archive_name}.sha256" + (artifacts / checksum_name).write_text( + f"{_digest(archive_payload)} {archive_name}\n", encoding="utf-8" + ) + manifest_name = f"{archive_name}.manifest.txt" + (artifacts / manifest_name).write_text( + ( + f"platform={platform}\narch={arch}\n" + f"target_triple={target_triple}\narchive={archive_name}\n" + ), + encoding="utf-8", + ) + + if platform == "windows": + updater_name = archive_name + updater_payload = archive_payload + else: + updater_name = ( + f"bandscope-macos-{arch}-{source_commit[:12]}.app.tar.gz" + ) + updater_payload = f"updater-{platform}-{arch}".encode() + (artifacts / updater_name).write_bytes(updater_payload) + signature_name = f"{updater_name}.sig" + signature_text = base64.b64encode( + f"signature-{platform}-{arch}".encode() + ).decode("ascii") + signature_payload = signature_text.encode() + (artifacts / signature_name).write_bytes(signature_payload) + signatures[f"{platform}-{arch}"] = signature_text + updater_identities[f"{platform}-{arch}"] = { + "sizeBytes": len(updater_payload), + "sha256": _digest(updater_payload), + } + + receipt = { + "schemaVersion": 1, + "version": "1.2.3", + "tag": "v1.2.3", + "sourceCommit": source_commit, + "target": { + "platform": platform, + "arch": arch, + "targetTriple": target_triple, + }, + "artifacts": [ + { + "archive": archive_name, + "sizeBytes": len(archive_payload), + "sha256": _digest(archive_payload), + "checksumFile": checksum_name, + "manifestFile": manifest_name, + } + ], + "updaterArtifacts": [ + { + "bundle": updater_name, + "sizeBytes": len(updater_payload), + "sha256": _digest(updater_payload), + "signatureFile": signature_name, + "signatureSizeBytes": len(signature_payload), + "signatureSha256": _digest(signature_payload), + } + ], + } + receipt_name = ( + f"bandscope-{platform}-{arch}-{source_commit[:12]}.release-receipt.json" + ) + (artifacts / receipt_name).write_text( + json.dumps(receipt), encoding="utf-8" + ) + return signatures, updater_identities + + +def _run_builder( + repo_root: Path, *, source_commit: str, check: bool = False +) -> subprocess.CompletedProcess[str]: + command = [ + sys.executable, + str(_BUILDER), + "--repo-root", + str(repo_root), + "--git-sha", + source_commit, + "--repository", + "ContextualWisdomLab/bandscope", + "--server-url", + "https://github.com", + "--output", + str(repo_root / "latest.json"), + ] + if check: + command.append("--check") + return subprocess.run(command, text=True, capture_output=True, check=False) + + +def test_manifest_binds_exact_receipts_and_signature_contents(tmp_path: Path) -> None: + """Generate Tauri static JSON from exact receipt-bound updater bytes.""" + source_commit = "a" * 40 + signatures, updater_identities = _write_release_graph( + tmp_path, source_commit=source_commit + ) + + completed = _run_builder(tmp_path, source_commit=source_commit) + + assert completed.returncode == 0, completed.stderr + manifest = json.loads((tmp_path / "latest.json").read_text(encoding="utf-8")) + assert manifest["version"] == "1.2.3" + assert set(manifest["platforms"]) == { + "windows-x86_64", + "windows-aarch64", + "darwin-x86_64", + "darwin-aarch64", + } + assert ( + manifest["platforms"]["windows-x86_64"]["signature"] + == signatures["windows-amd64"] + ) + assert ( + manifest["platforms"]["darwin-aarch64"]["signature"] + == signatures["macos-arm64"] + ) + assert manifest["platforms"]["darwin-aarch64"]["url"] == ( + "https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/" + f"bandscope-macos-arm64-{source_commit[:12]}.app.tar.gz" + ) + assert manifest["bandscope"] == { + "schemaVersion": 1, + "sourceCommit": source_commit, + "minimumSupportedVersion": "1.0.0", + "artifacts": { + "windows-x86_64": updater_identities["windows-amd64"], + "windows-aarch64": updater_identities["windows-arm64"], + "darwin-x86_64": updater_identities["macos-amd64"], + "darwin-aarch64": updater_identities["macos-arm64"], + }, + } + + +def test_manifest_rejects_receipt_bound_non_base64_signature(tmp_path: Path) -> None: + """Reject a signature receipt whose bytes cannot satisfy Tauri's outer base64 envelope.""" + source_commit = "9" * 40 + _write_release_graph(tmp_path, source_commit=source_commit) + signature = ( + tmp_path + / "artifacts" + / f"bandscope-windows-amd64-{source_commit[:12]}.exe.sig" + ) + malformed = b"not-base64!" + signature.write_bytes(malformed) + receipt_path = ( + tmp_path + / "artifacts" + / f"bandscope-windows-amd64-{source_commit[:12]}.release-receipt.json" + ) + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + receipt["updaterArtifacts"][0]["signatureSizeBytes"] = len(malformed) + receipt["updaterArtifacts"][0]["signatureSha256"] = _digest(malformed) + receipt_path.write_text(json.dumps(receipt), encoding="utf-8") + + completed = _run_builder(tmp_path, source_commit=source_commit) + + assert completed.returncode != 0 + assert "base64" in completed.stderr.lower() + + +def test_manifest_check_rejects_post_generation_signature_drift( + tmp_path: Path, +) -> None: + """Do not publish a manifest after receipt-bound signature bytes drift.""" + source_commit = "b" * 40 + _write_release_graph(tmp_path, source_commit=source_commit) + assert _run_builder(tmp_path, source_commit=source_commit).returncode == 0 + signature = ( + tmp_path + / "artifacts" + / f"bandscope-windows-amd64-{source_commit[:12]}.exe.sig" + ) + signature.write_text("tampered-signature", encoding="utf-8") + + completed = _run_builder(tmp_path, source_commit=source_commit, check=True) + + assert completed.returncode != 0 + assert "signature" in completed.stderr.lower() + + +def test_manifest_rejects_ambiguous_updater_bundle_for_one_target( + tmp_path: Path, +) -> None: + """Static Tauri targets must resolve to one receipt-authorized updater.""" + source_commit = "c" * 40 + _write_release_graph(tmp_path, source_commit=source_commit) + receipt_path = ( + tmp_path + / "artifacts" + / f"bandscope-windows-amd64-{source_commit[:12]}.release-receipt.json" + ) + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + receipt["updaterArtifacts"].append(dict(receipt["updaterArtifacts"][0])) + receipt_path.write_text(json.dumps(receipt), encoding="utf-8") + + completed = _run_builder(tmp_path, source_commit=source_commit) + + assert completed.returncode != 0 + assert "updater" in completed.stderr.lower() + + +def test_manifest_rejects_duplicate_updater_policy_members(tmp_path: Path) -> None: + """Reject ambiguous minimum-version authority instead of accepting last-value wins.""" + source_commit = "e" * 40 + _write_release_graph(tmp_path, source_commit=source_commit) + policy_path = tmp_path / "release" / "updater-policy.json" + policy_path.write_text( + '{"schemaVersion":1,"minimumSupportedVersion":"1.0.0",' + '"minimumSupportedVersion":"1.1.0"}', + encoding="utf-8", + ) + + completed = _run_builder(tmp_path, source_commit=source_commit) + + assert completed.returncode != 0 + assert "duplicate json member" in completed.stderr.lower() + + +def test_manifest_rejects_noncanonical_minimum_supported_version( + tmp_path: Path, +) -> None: + """Do not publish replay metadata with a non-SemVer compatibility floor.""" + source_commit = "f" * 40 + _write_release_graph(tmp_path, source_commit=source_commit) + policy_path = tmp_path / "release" / "updater-policy.json" + policy = json.loads(policy_path.read_text(encoding="utf-8")) + policy["minimumSupportedVersion"] = "01.0.0" + policy_path.write_text(json.dumps(policy), encoding="utf-8") + + completed = _run_builder(tmp_path, source_commit=source_commit) + + assert completed.returncode != 0 + assert "minimumsupportedversion" in completed.stderr.lower() + + +def test_release_workflow_builds_and_rechecks_manifest_before_publication() -> None: + """Immutable release publication must include the exact generated latest.json.""" + workflow = _WORKFLOW.read_text(encoding="utf-8") + build_command = "python3 scripts/release/build_updater_manifest.py" + publish_command = 'gh release create "$RELEASE_TAG"' + assert build_command in workflow + assert "--check" in workflow + assert "latest.json" in workflow + assert workflow.index(build_command) < workflow.index(publish_command) + + +def test_builder_rejects_non_https_release_host(tmp_path: Path) -> None: + """Updater bundle URLs must not downgrade release transport.""" + source_commit = "d" * 40 + _write_release_graph(tmp_path, source_commit=source_commit) + command = [ + sys.executable, + str(_BUILDER), + "--repo-root", + str(tmp_path), + "--git-sha", + source_commit, + "--repository", + "ContextualWisdomLab/bandscope", + "--server-url", + "http://github.com", + "--output", + str(tmp_path / "latest.json"), + ] + + completed = subprocess.run(command, text=True, capture_output=True, check=False) + + assert completed.returncode != 0 + assert "https" in completed.stderr.lower()