diff --git a/.github/workflows/build-baseline.yml b/.github/workflows/build-baseline.yml index 13de8e648..1458381d8 100644 --- a/.github/workflows/build-baseline.yml +++ b/.github/workflows/build-baseline.yml @@ -410,8 +410,17 @@ jobs: bandscope-sbom.cdx.json 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 + run: python3 scripts/release/select_release_assets.py --output release-artifacts.txt + - 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 + cp release-artifacts.txt release-assets.txt + printf '%s\n' latest.json >> release-assets.txt + - name: Create draft release, re-verify hosted bytes, then publish env: GH_TOKEN: ${{ secrets.BANDSCOPE_RELEASE_TOKEN }} RELEASE_TAG: ${{ github.ref_name }} @@ -425,7 +434,16 @@ jobs: echo "Release $RELEASE_TAG already exists; immutable release assets must be attached before publication." exit 1 fi - python3 scripts/release/select_release_assets.py --input release-assets.txt + python3 scripts/release/select_release_assets.py --input release-artifacts.txt + python3 scripts/release/build_updater_manifest.py \ + --git-sha "${{ github.sha }}" \ + --repository "${{ github.repository }}" \ + --server-url "${{ github.server_url }}" \ + --output latest.json \ + --check + cp release-artifacts.txt expected-release-assets.txt + printf '%s\n' latest.json >> expected-release-assets.txt + cmp -s expected-release-assets.txt release-assets.txt mapfile -t release_assets < release-assets.txt (( ${#release_assets[@]} > 0 )) gh release create "$RELEASE_TAG" \ @@ -435,4 +453,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..3d44bf800 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,9 +48,31 @@ 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 + - 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 + verify: name: ci / build-and-test - needs: lock-validation + needs: + - lock-validation + - distribution-download-platform runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca0df5ac4..63eea8415 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # ARCHITECTURE.md -Last updated: 2026-03-11 +Last updated: 2026-09-15 ## 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, cleanup-on-drop and read-only descriptor-bound verifier access, but not HTTP, signatures, digests or installation +- `apps/desktop/distribution-state` - Distribution-owned bounded append/sync log for the highest authenticated updater identity; 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 cleanup-on-drop. 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. 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 append-only log. It revalidates committed identities, rejects local version regression/equivocation, synchronizes accepted appends, and recovers only a syntactically valid torn final-record prefix; it does not own Tauri networking/signature verification, installer execution, or project persistence. +- 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`, 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..48cd6db5c --- /dev/null +++ b/apps/desktop/distribution-download/src/lib.rs @@ -0,0 +1,642 @@ +//! 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. The file is removed on drop unless `seal` transfers both cleanup +/// and lease ownership to `SealedArtifactFile`. 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() { + drop(file); + } + let _ = fs::remove_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. Dropping this value closes the descriptor before removing the +/// staged path, including on Windows where deleting an open file can fail. The +/// lease is released only after path 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() { + drop(file); + } + let _ = fs::remove_file(&self.path); + let _ = self.staging_lease.take(); + } +} + +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")); + } +} \ No newline at end of file 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..a85983dba --- /dev/null +++ b/apps/desktop/distribution-download/tests/sealed_reader.rs @@ -0,0 +1,113 @@ +use bandscope_distribution_download::{ArtifactDownloadAdmission, StagedArtifactFile}; +use std::fs::{self, OpenOptions}; +use std::io::{ErrorKind, Read, Write}; +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 +} + +#[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!(!staged_path.exists()); + 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!(!staged_path.exists()); + 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!(!staged_path.exists()); + 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..d1d42e3be --- /dev/null +++ b/apps/desktop/distribution-download/tests/staged_artifact.rs @@ -0,0 +1,327 @@ +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_scratch_dir(directory: &Path) { + let lease_path = directory.join(".bandscope-staging.lock"); + match fs::remove_file(&lease_path) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => panic!("remove staging lease fixture: {error}"), + } + fs::remove_dir(directory).expect("remove staging directory"); +} + +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_is_removed_on_drop() { + 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!(!path.exists()); + remove_scratch_dir(&directory); +} + +#[test] +fn sealed_but_unverified_artifact_is_removed_on_drop() { + 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!(!path.exists()); + 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 after sealed cleanup"); + drop(replacement); + remove_scratch_dir(&directory); +} + +#[test] +fn failed_admission_removes_partial_staging_file() { + 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!(!path.exists()); + remove_scratch_dir(&directory); +} + +#[test] +fn receipt_size_mismatch_removes_unsealed_staging_file() { + 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!(!path.exists()); + 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!(!path.exists()); + 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!(!path.exists()); + + 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()); + assert!(!directory.join("update.bin").exists()); + + 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..a8f5105e1 --- /dev/null +++ b/apps/desktop/distribution-state/src/lib.rs @@ -0,0 +1,489 @@ +//! 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. The on-disk format is append-only so a torn final write can be +//! discarded without losing the previous committed release identity. + +#![forbid(unsafe_code)] + +use bandscope_distribution_core::ReleaseIdentity; +use std::fs::{File, OpenOptions}; +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; + +/// Successful result of remembering an authenticated release identity. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RememberOutcome { + /// The new highest authenticated identity was appended and synchronized. + 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 a 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, + /// State bytes changed between admission and append under the single-writer contract. + ConcurrentMutation, +} + +/// Load the highest committed authenticated release identity from local state. +/// +/// 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 bytes = read_state_bytes(path)?.unwrap_or_default(); + parse_state_bytes(&bytes).map(|parsed| parsed.highest) +} + +/// Remember a newly authenticated release identity in an append-only state log. +/// +/// The caller must invoke this only after updater metadata and artifact +/// authenticity have been established. The record is appended, flushed and +/// synchronized before success is returned. If a previous process was torn +/// during its final append, the validated incomplete tail is truncated first; +/// committed records are never rewritten. +pub fn remember_highest_seen( + path: &Path, + identity: &ReleaseIdentity, +) -> Result { + 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() { + truncate_recoverable_tail(path, parsed.committed_len)?; + } + return Ok(RememberOutcome::AlreadyRemembered); + } + } + + if parsed.committed_len != bytes.len() { + truncate_recoverable_tail(path, parsed.committed_len)?; + } + + let record = encode_record(identity); + if parsed + .committed_len + .checked_add(record.len()) + .is_none_or(|next_len| next_len > MAX_STATE_BYTES) + { + return Err(StateError::TooLarge); + } + + 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)?; + let expected_len = parsed.committed_len + record.len(); + 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 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() { + 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() || 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 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; + } + } + true +} + +fn is_lower_hex(byte: u8) -> bool { + byte.is_ascii_digit() || matches!(byte, b'a'..=b'f') +} + +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) +} + +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(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-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..3b0d12866 --- /dev/null +++ b/apps/desktop/distribution-transport/src/lib.rs @@ -0,0 +1,380 @@ +//! 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, 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), + } + } +} + +/// 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, + 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, + 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. + /// + /// 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 { + 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, + 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(), + 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(), + 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 same provisional artifact size, + /// digest and updater signature that admitted its first response. It cannot + /// be replayed across another metadata projection that happens to use the + /// same release URL. 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.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(), + expected_size_bytes: self.expected_size_bytes, + expected_artifact_sha256: self.expected_artifact_sha256.clone(), + artifact_signature: self.artifact_signature.clone(), + } + } +} + +/// One response body being admitted into an exclusive staging artifact. +#[derive(Debug)] +pub struct TransportDownload { + admission: Option, + staged: Option, +} + +impl TransportDownload { + /// 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 return the still-unverified sealed descriptor. + /// + /// Failure leaves the staging value owned by this consumed object, so its + /// existing drop cleanup removes partial or unverified bytes. + 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"); + staged.seal(receipt).map_err(TransportDownloadError::Staging) + } +} + +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/transport_diagnostics.rs b/apps/desktop/distribution-transport/tests/transport_diagnostics.rs new file mode 100644 index 000000000..6d48a1b82 --- /dev/null +++ b/apps/desktop/distribution-transport/tests/transport_diagnostics.rs @@ -0,0 +1,49 @@ +use bandscope_distribution_runtime::admit_untrusted_raw_json; +use bandscope_distribution_transport::{ReleaseTransportPolicy, ResponseDecision}; + +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") +} + +#[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"); +} 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..f109805ab --- /dev/null +++ b/apps/desktop/distribution-transport/tests/transport_policy.rs @@ -0,0 +1,217 @@ +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::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 +} + +#[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!(!path.exists(), "unverified sealed bytes remain cleanup-on-drop"); + 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!(!path.exists(), "unverified sealed bytes remain cleanup-on-drop"); + fs::remove_dir(directory).expect("remove staging directory"); +} + +#[test] +fn cancelled_transport_drops_partial_staging_bytes() { + 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"); + assert_eq!(fs::read_dir(&directory).expect("read staging directory").count(), 1); + drop(download); + assert_eq!(fs::read_dir(&directory).expect("read staging directory").count(), 0); + fs::remove_dir(directory).expect("remove staging directory"); +} 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..b17182958 --- /dev/null +++ b/docs/traceability/release-version-identity.md @@ -0,0 +1,83 @@ +# 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. + +## 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, descriptor device/inode/size must remain stable through the read, and the byte count must match the descriptor size. JSON decoding uses an object-pairs hook that rejects duplicate members before `version` is read. No admitted value is obtained by reopening the pathname after this check. + +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. +- 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 the path twice and compare only size + +Rejected. A same-sized replacement can pass a size-only comparison. Release identity is read once from the admitted descriptor and validated against that descriptor; later pathname state is not used as the source of the already-admitted bytes. + +### 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 read through a bounded, duplicate-rejecting, strict-standard-JSON, descriptor-stable admission boundary. It does not make the entire checked-out repository immutable against a privileged local actor after the gate completes, 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`, and `lstat`*. Python 3 standard library documentation. diff --git a/docs/traceability/updater-bounded-download.md b/docs/traceability/updater-bounded-download.md new file mode 100644 index 000000000..e18506830 --- /dev/null +++ b/docs/traceability/updater-bounded-download.md @@ -0,0 +1,116 @@ +# 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는 열린 descriptor를 닫은 뒤 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는 drop 시 제거되어야 한다는 integration contract를 먼저 만들었습니다. 이 head에서는 기존 source가 sealed path를 보존하므로 새 test가 실패하는 RED입니다. +- Causal fix `e76abddb0c40293901cd8672919172d47a93b5b9`: seal은 더 이상 artifact retention을 의미하지 않습니다. `SealedArtifactFile`이 descriptor cleanup 책임을 넘겨받고, drop 시 descriptor를 먼저 닫은 뒤 staging path를 제거합니다. Windows에서 열린 파일 삭제가 실패할 수 있으므로 descriptor를 `Option`로 보유해 drop 순서를 명시했습니다. 아직 별도의 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`로 함께 이동하고 unverified artifact cleanup 뒤에만 해제됩니다. +- Fixture adaptation `ebf94287ea54d329a3276f02a5251054c9b2d20c`: persistent lease sentinel과 ephemeral artifact cleanup을 test teardown에서 구분했습니다. +- Edge coverage `d2d187288ef27e7fabdacde062d83423bfa2e243`: sealed-but-unverified artifact가 lease를 계속 보유하는지, sealed cleanup 뒤 fresh attempt가 가능한지, Unix에서 lease sentinel symlink를 따라가지 않는지를 검증합니다. +- Restart/concurrency traceability `ebefa230880f3e460be012af9cbc42651814c73f`: stale recovery, active-process ownership, persistent sentinel, OS lock의 claim boundary와 기각 대안을 별도 traceability 문서에 연결했습니다. + +## 실행 계약 + +`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되면 partial staging path를 유지하지 않습니다. +- 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은 신뢰 승격이 아닙니다. `SealedArtifactFile` 자체는 cleanup-on-drop이며 descriptor를 먼저 닫아 artifact pathname을 제거한 뒤 staging lease를 해제합니다. 후속 digest/signature/authenticated-metadata 결합이 성공하기 전에는 unverified bytes가 정상 종료 경로에서 남지 않습니다. +- 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, cancellation cleanup, exact seal 후 unverified cleanup, 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, 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를 증명하지 않습니다. 신뢰 검증 전 sealed bytes를 정상 drop 뒤 남기면 실패한 verifier나 cancelled promotion 뒤 untrusted artifact가 app-owned staging에 잔존할 수 있습니다. + +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 경로를 닫습니다. 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으로 귀결시켜야 합니다. 그 뒤 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는 자동 정리하지 않습니다. 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 \ No newline at end of file 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-restart-recovery.md b/docs/traceability/updater-staging-restart-recovery.md new file mode 100644 index 000000000..8082b23c7 --- /dev/null +++ b/docs/traceability/updater-staging-restart-recovery.md @@ -0,0 +1,68 @@ +# Updater staging restart recovery traceability + +BandScope의 updater staging은 신뢰 검증 전 bytes만 두는 scratch namespace입니다. 정상 cancel/error/drop에서는 partial file을 제거하지만 프로세스 강제 종료나 전원 상실은 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 삭제 동작과도 결과가 달라질 수 있어 cross-platform recovery contract로 둘 수 없습니다. + +Stale bytes를 resume하거나 신뢰하는 것도 허용하지 않습니다. 이전 프로세스가 남긴 bytes에는 response completion, digest, updater signature, metadata authenticity 증거가 없습니다. 이 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를 보호합니다. Artifact 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에서 실행됩니다. + +## 실행 계약 + +- 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의 unverified residue로 간주할 수 있습니다. 해당 bytes는 resume하지 않고 제거한 뒤 `create_new`로 byte zero부터 시작합니다. +- `StagedArtifactFile`에서 `SealedArtifactFile`로 전환해도 lease를 유지합니다. Exact descriptor의 digest/signature 검증과 cleanup 사이에 다른 attempt가 pathname을 reclaim하지 못하게 하는 목적입니다. +- staged/sealed artifact cleanup을 마친 뒤 lease handle이 닫히며 다음 attempt가 lease를 얻을 수 있습니다. Process termination 시 OS가 file handle을 닫으면 lock도 함께 해제되므로 persistent sentinel 자체가 영구 blocker가 되지 않습니다. +- symlink, directory 또는 기타 non-regular artifact destination은 자동 삭제하지 않습니다. +- verified artifact를 이 scratch namespace에 장기 보존하는 API는 없습니다. +- platform-specific lock 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으로 사용합니다. + +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를 영구 차단하는 경로를 함께 닫습니다. `File::try_lock`은 플랫폼에 따라 advisory 또는 mandatory일 수 있으므로, 이 lease가 임의의 로컬 악성 프로세스가 직접 filesystem을 변조하는 것을 막는 mandatory sandbox라고 주장하지 않습니다. Staging root 자체의 ACL/ownership hardening과 pathname TOCTOU 방어도 별도 security boundary입니다. + +Cross-platform CI matrix와 real-process test는 Windows/macOS/Linux에서 현재 cooperating-process exclusion 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는 자동 정리 대상이 아닙니다. Verified artifact는 staging scratch 밖의 별도 owner로 승격되어야 하며 audio/project content는 이 updater staging 경계에 들어오지 않습니다. \ No newline at end of file diff --git a/docs/traceability/updater-transport-diagnostics.md b/docs/traceability/updater-transport-diagnostics.md new file mode 100644 index 000000000..e5c7ef22f --- /dev/null +++ b/docs/traceability/updater-transport-diagnostics.md @@ -0,0 +1,58 @@ +# 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. + +## 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()` for response binding. Redaction must affect diagnostics only, never transport equality or network behavior. +- Preserve exact `artifact_signature()` in both provisional metadata and transport values for the later Tauri verification boundary; diagnostic redaction must not mutate or replace verification input. +- 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. + +## 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 both `AdmittedRedirect` and `AdmittedDownloadHead`: the first holds the URL before the follow-up request, while the second retains the same effective URL after the admitted `200`. Fixing only one would leave the same opaque query reachable from the other diagnostic surface. + +Signature diagnostics use the same fixed `` marker in `ProvisionalUpdateMetadata`, `ReleaseTransportPolicy`, and `AdmittedDownloadHead`. 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. + +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, and final download heads from exposing full provisional signature text; transport types also omit CDN redirect query contents. It does not prove that callers never log the explicit `artifact_signature()`, `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 (URI): 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..54ba2a8d1 --- /dev/null +++ b/docs/traceability/updater-transport-policy.md @@ -0,0 +1,87 @@ +# Updater transport admission traceability + +Status: implemented policy boundary; production network adapter still pending. + +## Problem + +BandScope already has a strict provisional updater-metadata parser and a bounded streaming/staging primitive, but those two boundaries were not connected by an executable transport policy. A future HTTP adapter could therefore reparse `raw_json`, allow the HTTP library to follow redirects implicitly, hand transformed response bytes to staging, or fail to prove which effective URL produced them. + +GitHub's REST release-asset contract requires clients requesting binary asset content to handle either a direct `200` response or a `302` redirect. That makes "disable every redirect" incompatible with the supported release path, while unconstrained automatic redirects would make the final network destination an HTTP-library decision rather than a Distribution decision. + +Tauri's updater CLI writes the textual minisign signature box as standard-base64 text into the `.sig` artifact, and the updater runtime first base64-decodes the manifest `signature` back to UTF-8 before parsing/verifying the signature box. Merely bounding a remote signature string therefore leaves malformed envelopes to fail only after network/download work unless BandScope rejects them earlier. The manifest is one four-target release document: validating only the currently selected target would let one platform accept metadata containing an impossible Tauri signature envelope for another supported platform. That creates target-dependent structural acceptance for what is supposed to be one release truth. + +Updater signatures and SHA-256 evidence are defined over the exact published artifact bytes. HTTP content codings such as gzip or brotli can make an HTTP stack expose decoded bytes that differ from the wire representation while `Content-Length` still describes the encoded body. Distribution must therefore reject transformed response bodies before filesystem mutation rather than depend on client-specific automatic decompression behavior. + +A redirect decision is also state, not merely a URL string. Before the current repair, an `AdmittedRedirect` was bound only to the initial release URL and redirect location. Two provisional metadata projections using the same release URL but different size, digest, or updater signature could therefore exchange the redirect token: `admit_redirect_response` would accept the old CDN location under the new policy and emit a download head carrying the new provisional identity. That did not by itself create cryptographic trust, but it broke attempt-level evidence continuity and made later authenticated descriptor binding harder to reason about. + +## Constraints + +- Consume `ProvisionalUpdateMetadata` directly; do not introduce a second remote-JSON parser. +- Keep metadata URL, signature, expected size and SHA-256 provisional. Transport admission does not authenticate them. +- `distribution-runtime`, as the remote updater-metadata owner, requires **every supported platform signature** to be canonical RFC 4648 standard base64 before it can return `ProvisionalUpdateMetadata`. This validates only the Tauri outer encoding contract, not the decoded minisign structure or cryptographic signature. +- `distribution-transport` consumes that invariant and must not maintain a second signature-envelope parser or a target-only structural rule. +- Publication uses the same outer contract: exact receipt-bound `.sig` bytes must be canonical standard base64 and decode to UTF-8 before entering static updater JSON. +- Do not add a base64 dependency merely to express deterministic metadata syntax; the Rust metadata-owner check is dependency-free and publication uses Python's standard library. +- Disable automatic redirect semantics in the eventual network adapter and make every followed location an explicit policy result. +- Admit a direct `200` only when the HTTP client's reported effective URL equals the exact canonical BandScope release URL already admitted by `distribution-runtime`. +- Admit at most one `302` hop, currently to the exact `https://release-assets.githubusercontent.com/` origin. A GitHub CDN host change must fail closed until the allowlist is deliberately revised; this hostname is an operational BandScope egress decision, not a claim that GitHub documents it as a permanent API guarantee. +- A redirect token is valid only for the same provisional transport identity that created it: initial URL, declared size, SHA-256 and updater signature must still match before the redirected response can be admitted. +- A second redirect is rejected. A redirected `200` must report the exact admitted redirect URL as its effective URL. +- Reject any response `Content-Encoding` other than the explicit identity coding before staging-file creation. An omitted `Content-Encoding` remains admissible. The eventual HTTP adapter must also disable automatic decompression so the header evidence and delivered byte stream cannot diverge. +- Response bodies reach disk only through `distribution-download`, preserving its 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. +- A successfully staged artifact remains unverified and cleanup-on-drop. This layer performs no signature/digest trust promotion. + +## Alternatives considered + +Implicit HTTP-client redirects were rejected because they conceal effective-origin changes from the product's Distribution policy. Rejecting all redirects was rejected because GitHub release-asset downloads may legitimately return `302`. Re-parsing `Update.raw_json` inside the HTTP adapter was rejected because it would create a second, potentially looser interpretation of untrusted metadata. Adding `reqwest` to the deterministic policy crate was rejected for this slice because response-state admission and staging composition can be tested without expanding the direct dependency surface; the eventual production adapter must undergo the repository's normal dependency admission if a new direct client is required. + +Allowing a redirect token to be identified only by its source and destination URLs was rejected because the URL can remain stable while provisional size, digest, or signature evidence changes between metadata fetches. Using a random nonce would also reject cross-attempt mixing, but would introduce nondeterminism without adding useful semantics. The selected binding carries only the already-bounded provisional transport identity needed to prove that the redirect belongs to the same policy; ordinary `Debug` output still does not expose the signature or opaque CDN query. + +Allowing HTTP content codings and trusting the client to produce equivalent bytes was rejected because automatic decompression is library/configuration dependent and breaks the simple invariant that the bytes counted, hashed and signature-verified are the exact release artifact bytes. The updater path does not need content coding, so fail-closed identity/no-encoding semantics are narrower and auditable. + +Deferring all signature syntax checking to Tauri's post-download verifier was rejected because an obviously malformed outer base64 envelope can be rejected without claiming cryptographic trust and without downloading a potentially large updater artifact. Validating only the selected target inside `distribution-transport` was also rejected: it duplicated metadata syntax outside the metadata owner and allowed a four-target manifest to be structurally valid on one platform while carrying an impossible Tauri envelope for another. Reimplementing minisign verification was rejected as well; Tauri remains the signature-verification owner, while BandScope only mirrors the documented outer transport envelope needed for deterministic admission. + +## Selected design + +`apps/desktop/distribution-runtime` owns the exact updater document schema. During the single strict parse it validates all four platform entries, including canonical standard-base64 signature envelopes with valid padding placement and zero pad bits. Only then can it return `ProvisionalUpdateMetadata` for the selected target. The result remains unauthenticated remote metadata. + +`apps/desktop/distribution-transport` is a small Rust owner between that metadata boundary and `distribution-download`. `ReleaseTransportPolicy::from_provisional` copies only the already-selected target projection: canonical initial URL, safe artifact basename, declared byte size, SHA-256 and Tauri signature. It does not revalidate the signature envelope because `ProvisionalUpdateMetadata` cannot exist unless the metadata owner has already validated every supported platform envelope. `admit_initial_response` accepts exact-URL `200`, or returns an explicit one-hop redirect decision for an admitted GitHub release-asset CDN location. The redirect value privately retains the originating policy's provisional size, digest and updater signature in addition to the source/location URLs. `admit_redirect_response` first requires those values to match the current policy, then requires the second request to terminate in `200` at the exact admitted location. `AdmittedDownloadHead::start_staging` rejects non-identity `Content-Encoding`, then creates `ArtifactDownloadAdmission` before it creates a staging file, and `TransportDownload` routes chunks and exact completion into the existing sealed-descriptor lifecycle. + +`scripts/release/build_updater_manifest.py` performs the publication-side companion check after the exact `.sig` size/SHA-256 receipt binding: ASCII/canonical standard-base64 validation, exact decode/re-encode equivalence and UTF-8 validation of the decoded outer payload. It still does not claim the fixture or publication script itself performs minisign verification; actual Tauri signing/verifying authority remains separate. + +The transport API intentionally contains no socket/client, JSON parser, installer, freshness-state repository or project-persistence dependency. It also contains no verified-artifact type: base64 syntax plus size/status/origin/framing evidence is not cryptographic authenticity. + +## RED → repair evidence + +- `8f39dfc57026a25389f985e06dacee025818c5b2` added hostile/product transport cases requiring one GitHub release redirect, arbitrary-host rejection, redirect-chain rejection, effective-URL binding, content-length-before-file admission and cancel cleanup. +- `1b4f7a0a840d917f54fdb6b78ec861ba4b5ba0f7` placed the new crate in the root Python-owned native-suite gate so the locked `cargo test --all-targets` contract is part of ordinary CI. +- At that RED generation the transport source deliberately did not connect `302` to the CDN validator and returned `RedirectUnsupported`; the locked crate was therefore non-green until the causal response-state transition was implemented. The unconnected private validator was also dead code under `warnings = "deny"`; both failures had the same cause: redirect admission was not wired. +- `4964c3cd1472ed6ac9c7a9223d3da533e1af6096` connected the validator to one-hop `302` admission, preserved exact effective-URL checks, rejected redirect chaining, and routed the admitted final response into the existing bounded staging boundary. +- `8313e9fb2fe66711e2c3e0432413a94355fdf6e7` added the original selected-target RED proving that syntactically admitted `not-base64!` metadata must not reach network response admission. `6e5e42f2a20001009330c438178afa1ca811ab51` added the first dependency-free canonical-base64 envelope guard at the transport boundary. +- `03c1314884a4044129ead75db59d341b80ed4499` added publication RED for receipt-consistent but non-base64 `.sig` bytes while converting ordinary fixtures to realistic base64 envelopes. `2c7c772abcceff96dafceaaaa3b6a4e2af5f8cbc` added the publication-side canonical base64/decoded-UTF-8 gate. +- `e37632589960cd3571c99eafafdcf205734bb21b` changed the transport contract first: all staging calls now supply response content-coding evidence, encoded bodies such as `gzip` must fail before a file exists, and explicit `identity` remains admissible. `606095ec6f2ae9b5d22a777f70806dc79baa8f36` is the causal response-framing repair. +- `8d56ab1015077e256e560deba9611979cb81ec5d` added a cross-target RED: a valid Windows x86_64 signature with malformed Windows ARM signature had to fail at `distribution-runtime`, but the predecessor accepted it because only emptiness/size/NUL were checked there and the selected-target transport guard could not see the other platform entry. +- `4e28d0cf5edfb399e3ced07b12daf5c4a7aace62` made canonical standard-base64 admission part of the metadata owner's validation for every supported target and converted runtime fixtures to realistic envelopes. +- `9b691d7f1e67dff23b26b1427a8c7bf63b6fd025` removed the duplicate selected-target base64 parser and error from `distribution-transport`; `5b85e03fde690240df62ac18c4e49b9052047f83` updated the transport contract test to assert rejection at the metadata owner instead. +- `26ff403041958c433239a2359e6cfc32a2b633b9` repaired the remaining `provisional_artifact` integration fixture that still used hyphenated non-base64 placeholder signatures after the metadata-owner rule changed. Without this repair the current strict admission test could not reach the transport-field assertions it was intended to exercise. +- `11a5a47784a405e5cad973d3c40aa8fe18b40940` added the redirect-policy RED: a redirect admitted under one provisional signature must not be consumable by a second policy with the same initial URL but a different signature. The predecessor had no policy-identity mismatch state and accepted the cross-policy redirect. +- `35b851e4724ca625351a14df80f78e121a4f3d6a` is the causal repair: `AdmittedRedirect` now privately retains the originating size/digest/signature and `admit_redirect_response` rejects any cross-policy token before effective-URL/status admission. + +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 entire four-target provisional metadata document, signature envelopes, HTTP status, HTTP client's effective URL, redirect `Location`, `Content-Encoding`, `Content-Length` and response chunks. The metadata owner now applies canonical outer-base64 admission consistently to all supported target signatures before any `ProvisionalUpdateMetadata` can exist. The transport policy uses exact URL equality before body admission, a bounded redirect string, HTTPS exact-origin pinning for the admitted CDN hop, a redirect token bound to the same provisional artifact size/digest/signature that created it, one-hop redirect depth, fail-closed response-content-coding admission, and existing bounded chunk/file admission. No network, credential, subprocess, generic filesystem, installer, project or freshness-state capability is added by this ownership repair. Cancel/error cleanup continues to be owned by `distribution-download`. + +This boundary does not authenticate remote metadata, does not parse or cryptographically verify the decoded minisign signature, does not hash the sealed descriptor, does not itself disable an HTTP client's automatic decompression, and does not prove packaged Windows/macOS networking 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_release_identity.py b/scripts/checks/verify_release_identity.py new file mode 100644 index 000000000..a2fd15378 --- /dev/null +++ b/scripts/checks/verify_release_identity.py @@ -0,0 +1,283 @@ +#!/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 once from bounded regular non-link file + descriptors; descriptor identity/size must remain stable while read, and 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") + + 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) + 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") + 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..22ba2b31a 100755 --- a/scripts/harness/quickcheck.sh +++ b/scripts/harness/quickcheck.sh @@ -9,6 +9,7 @@ python3 scripts/checks/verify_security_notes.py python3 scripts/checks/security_gates.py python3 scripts/checks/verify_supply_chain.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..67af54316 --- /dev/null +++ b/scripts/release/build_updater_manifest.py @@ -0,0 +1,430 @@ +#!/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: + 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/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/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/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_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..42bd125f5 --- /dev/null +++ b/services/analysis-engine/tests/test_release_identity_file_admission.py @@ -0,0 +1,69 @@ +"""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_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()