From 9a6a3b8872a6e1205de9c4b4b13c311883b2f583 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:42:55 +0900 Subject: [PATCH 001/308] test(release): reproduce desktop version identity drift --- .../tests/test_release_version_identity.py | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 services/analysis-engine/tests/test_release_version_identity.py 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..ce06ed771 --- /dev/null +++ b/services/analysis-engine/tests/test_release_version_identity.py @@ -0,0 +1,66 @@ +"""Release identity contracts for the packaged BandScope desktop application.""" + +from __future__ import annotations + +import json +import tomllib +from pathlib import Path + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[3] + + +def _read_json(relative_path: str) -> dict[str, object]: + """Return one checked-in JSON document as an object.""" + document = json.loads((_REPOSITORY_ROOT / relative_path).read_text(encoding="utf-8")) + assert isinstance(document, dict) + return document + + +def _read_toml(relative_path: str) -> dict[str, object]: + """Return one checked-in TOML document as an object.""" + with (_REPOSITORY_ROOT / relative_path).open("rb") as stream: + document = tomllib.load(stream) + assert isinstance(document, dict) + return document + + +def test_packaged_desktop_uses_authoritative_release_version() -> None: + """Reject release metadata that drifts from the repository VERSION authority.""" + expected = (_REPOSITORY_ROOT / "VERSION").read_text(encoding="utf-8").strip() + assert expected + + root_package = _read_json("package.json") + desktop_package = _read_json("apps/desktop/package.json") + npm_lock = _read_json("package-lock.json") + tauri_config = _read_json("apps/desktop/src-tauri/tauri.conf.json") + cargo_manifest = _read_toml("apps/desktop/src-tauri/Cargo.toml") + cargo_lock = _read_toml("apps/desktop/src-tauri/Cargo.lock") + + npm_packages = npm_lock.get("packages") + assert isinstance(npm_packages, dict) + npm_root = npm_packages.get("") + npm_desktop = npm_packages.get("apps/desktop") + assert isinstance(npm_root, dict) + assert isinstance(npm_desktop, dict) + + cargo_package = cargo_manifest.get("package") + assert isinstance(cargo_package, dict) + locked_desktop = [ + package + for package in cargo_lock.get("package", []) + if isinstance(package, dict) and package.get("name") == "bandscope-desktop" + ] + assert len(locked_desktop) == 1 + + observed = { + "package.json": root_package.get("version"), + "apps/desktop/package.json": desktop_package.get("version"), + "package-lock.json": npm_lock.get("version"), + "package-lock.json#packages['']": npm_root.get("version"), + "package-lock.json#packages['apps/desktop']": npm_desktop.get("version"), + "apps/desktop/src-tauri/tauri.conf.json": tauri_config.get("version"), + "apps/desktop/src-tauri/Cargo.toml": cargo_package.get("version"), + "apps/desktop/src-tauri/Cargo.lock#bandscope-desktop": locked_desktop[0].get("version"), + } + + assert observed == dict.fromkeys(observed, expected), observed From 6749f4e650647e093ebf36e70449b7d504222c61 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:44:56 +0900 Subject: [PATCH 002/308] test(release): narrow RED to release version gate --- .../tests/test_release_version_identity.py | 128 ++++++++++-------- 1 file changed, 72 insertions(+), 56 deletions(-) diff --git a/services/analysis-engine/tests/test_release_version_identity.py b/services/analysis-engine/tests/test_release_version_identity.py index ce06ed771..efd723821 100644 --- a/services/analysis-engine/tests/test_release_version_identity.py +++ b/services/analysis-engine/tests/test_release_version_identity.py @@ -2,65 +2,81 @@ from __future__ import annotations +import importlib.util import json -import tomllib 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" + + +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" + spec = importlib.util.spec_from_file_location("verify_release_identity", _GUARD_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _write_release_metadata(root: Path, version: str) -> None: + """Write the minimum release metadata consumed by the identity guard.""" + (root / "apps" / "desktop" / "src-tauri").mkdir(parents=True) + (root / "VERSION").write_text(f"{version}\n", encoding="utf-8") + (root / "package.json").write_text( + json.dumps({"name": "bandscope", "version": version}), encoding="utf-8" + ) + (root / "apps" / "desktop" / "src-tauri" / "tauri.conf.json").write_text( + json.dumps( + { + "productName": "BandScope", + "version": version, + "identifier": "com.bandscope.desktop", + } + ), + encoding="utf-8", + ) + + +def test_release_preflight_executes_version_identity_guard() -> None: + """Keep release preflight fail-closed when version projections drift.""" + quickcheck = (_REPOSITORY_ROOT / "scripts" / "harness" / "quickcheck.sh").read_text( + encoding="utf-8" + ) + release_workflow = ( + _REPOSITORY_ROOT / ".github" / "workflows" / "release.yml" + ).read_text(encoding="utf-8") + + assert "python3 scripts/checks/verify_release_identity.py" in quickcheck + assert "./scripts/harness/quickcheck.sh" in release_workflow + + +def test_repository_release_version_matches_authoritative_version_file() -> None: + """Verify the checked-in package and Tauri release versions against VERSION.""" + guard = _load_guard() + assert guard.verify_release_identity(_REPOSITORY_ROOT) == "0.1.3" + + +def test_release_identity_guard_rejects_metadata_drift(tmp_path: Path) -> None: + """Reject a package projection that diverges from the authoritative version.""" + guard = _load_guard() + _write_release_metadata(tmp_path, "1.2.3") + package = json.loads((tmp_path / "package.json").read_text(encoding="utf-8")) + package["version"] = "1.2.4" + (tmp_path / "package.json").write_text(json.dumps(package), encoding="utf-8") + + with pytest.raises(ValueError, match="package.json version does not match VERSION"): + 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.""" + guard = _load_guard() + _write_release_metadata(tmp_path, "1.2.3") -def _read_json(relative_path: str) -> dict[str, object]: - """Return one checked-in JSON document as an object.""" - document = json.loads((_REPOSITORY_ROOT / relative_path).read_text(encoding="utf-8")) - assert isinstance(document, dict) - return document - - -def _read_toml(relative_path: str) -> dict[str, object]: - """Return one checked-in TOML document as an object.""" - with (_REPOSITORY_ROOT / relative_path).open("rb") as stream: - document = tomllib.load(stream) - assert isinstance(document, dict) - return document - - -def test_packaged_desktop_uses_authoritative_release_version() -> None: - """Reject release metadata that drifts from the repository VERSION authority.""" - expected = (_REPOSITORY_ROOT / "VERSION").read_text(encoding="utf-8").strip() - assert expected - - root_package = _read_json("package.json") - desktop_package = _read_json("apps/desktop/package.json") - npm_lock = _read_json("package-lock.json") - tauri_config = _read_json("apps/desktop/src-tauri/tauri.conf.json") - cargo_manifest = _read_toml("apps/desktop/src-tauri/Cargo.toml") - cargo_lock = _read_toml("apps/desktop/src-tauri/Cargo.lock") - - npm_packages = npm_lock.get("packages") - assert isinstance(npm_packages, dict) - npm_root = npm_packages.get("") - npm_desktop = npm_packages.get("apps/desktop") - assert isinstance(npm_root, dict) - assert isinstance(npm_desktop, dict) - - cargo_package = cargo_manifest.get("package") - assert isinstance(cargo_package, dict) - locked_desktop = [ - package - for package in cargo_lock.get("package", []) - if isinstance(package, dict) and package.get("name") == "bandscope-desktop" - ] - assert len(locked_desktop) == 1 - - observed = { - "package.json": root_package.get("version"), - "apps/desktop/package.json": desktop_package.get("version"), - "package-lock.json": npm_lock.get("version"), - "package-lock.json#packages['']": npm_root.get("version"), - "package-lock.json#packages['apps/desktop']": npm_desktop.get("version"), - "apps/desktop/src-tauri/tauri.conf.json": tauri_config.get("version"), - "apps/desktop/src-tauri/Cargo.toml": cargo_package.get("version"), - "apps/desktop/src-tauri/Cargo.lock#bandscope-desktop": locked_desktop[0].get("version"), - } - - assert observed == dict.fromkeys(observed, expected), observed + with pytest.raises(ValueError, match="release tag does not match VERSION"): + guard.verify_release_identity(tmp_path, release_tag="v1.2.2") From 261a9a2caa03033d0c7801c23935bad8610076a6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:45:16 +0900 Subject: [PATCH 003/308] feat(release): add fail-closed version identity guard --- scripts/checks/verify_release_identity.py | 73 +++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 scripts/checks/verify_release_identity.py diff --git a/scripts/checks/verify_release_identity.py b/scripts/checks/verify_release_identity.py new file mode 100644 index 000000000..8f9e10822 --- /dev/null +++ b/scripts/checks/verify_release_identity.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Fail closed when BandScope release-version projections disagree.""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from typing import Any + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[2] + + +def _read_json_object(path: Path) -> dict[str, Any]: + """Read one release metadata document and require a JSON object root.""" + try: + document = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise ValueError(f"could not read release metadata: {path.name}") from error + if not isinstance(document, dict): + raise ValueError(f"release metadata must be an object: {path.name}") + return document + + +def _required_string(document: dict[str, Any], key: str, source: str) -> str: + """Return a non-empty string field without coercing malformed metadata.""" + value = document.get(key) + if not isinstance(value, str) or not value.strip() or value != value.strip(): + raise ValueError(f"{source} {key} must be a non-empty trimmed string") + return value + + +def verify_release_identity(root: Path, release_tag: str | None = None) -> str: + """Verify package, Tauri, and optional tag versions against ``VERSION``.""" + try: + version_text = (root / "VERSION").read_text(encoding="utf-8") + except (OSError, UnicodeError) as error: + raise ValueError("could not read authoritative VERSION") from error + expected = version_text.strip() + if not expected or version_text != f"{expected}\n": + raise ValueError("VERSION must contain exactly one non-empty version line") + + package = _read_json_object(root / "package.json") + tauri = _read_json_object(root / "apps" / "desktop" / "src-tauri" / "tauri.conf.json") + + package_version = _required_string(package, "version", "package.json") + tauri_version = _required_string(tauri, "version", "tauri.conf.json") + if package_version != expected: + raise ValueError("package.json version does not match VERSION") + if tauri_version != expected: + raise ValueError("tauri.conf.json version does not match VERSION") + + if release_tag is not None and release_tag != f"v{expected}": + raise ValueError("release tag does not match VERSION") + + return expected + + +def main() -> int: + """Run the release identity gate for repository and tag-triggered workflows.""" + release_tag = os.environ.get("GITHUB_REF_NAME") if os.environ.get("GITHUB_REF_TYPE") == "tag" else None + try: + version = verify_release_identity(_REPOSITORY_ROOT, release_tag=release_tag) + except ValueError as error: + print(f"release identity check failed: {error}", file=sys.stderr) + return 1 + print(f"BandScope release identity verified: v{version}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 8d95854c929f7e505730b0973c9c69fbd9d1bc21 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:45:27 +0900 Subject: [PATCH 004/308] fix(release): enforce version identity in preflight harness --- scripts/harness/quickcheck.sh | 1 + 1 file changed, 1 insertion(+) 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 From 164f344fa3d680420fd7e8dff50cb4c1fc06b70a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:46:17 +0900 Subject: [PATCH 005/308] test(release): reject ambiguous VERSION authority --- .../tests/test_release_version_identity.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/services/analysis-engine/tests/test_release_version_identity.py b/services/analysis-engine/tests/test_release_version_identity.py index efd723821..0ae40edc4 100644 --- a/services/analysis-engine/tests/test_release_version_identity.py +++ b/services/analysis-engine/tests/test_release_version_identity.py @@ -80,3 +80,27 @@ def test_release_identity_guard_rejects_wrong_tag(tmp_path: Path) -> None: with pytest.raises(ValueError, match="release tag does not match VERSION"): guard.verify_release_identity(tmp_path, release_tag="v1.2.2") + + +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.""" + guard = _load_guard() + _write_release_metadata(tmp_path, "1.2.3") + ambiguous = "1.2.3\n2.0.0" + (tmp_path / "VERSION").write_text(f"{ambiguous}\n", encoding="utf-8") + (tmp_path / "package.json").write_text( + json.dumps({"name": "bandscope", "version": ambiguous}), encoding="utf-8" + ) + (tmp_path / "apps" / "desktop" / "src-tauri" / "tauri.conf.json").write_text( + json.dumps( + { + "productName": "BandScope", + "version": ambiguous, + "identifier": "com.bandscope.desktop", + } + ), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="VERSION must contain exactly one non-empty version line"): + guard.verify_release_identity(tmp_path) From f3ebe4dcb92e0f62c1d7378f95a210f03e112188 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:46:36 +0900 Subject: [PATCH 006/308] fix(release): parse VERSION as a single authority line --- scripts/checks/verify_release_identity.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/scripts/checks/verify_release_identity.py b/scripts/checks/verify_release_identity.py index 8f9e10822..1f8e1438f 100644 --- a/scripts/checks/verify_release_identity.py +++ b/scripts/checks/verify_release_identity.py @@ -37,9 +37,16 @@ def verify_release_identity(root: Path, release_tag: str | None = None) -> str: version_text = (root / "VERSION").read_text(encoding="utf-8") except (OSError, UnicodeError) as error: raise ValueError("could not read authoritative VERSION") from error - expected = version_text.strip() - if not expected or version_text != f"{expected}\n": + + lines = version_text.splitlines() + if ( + len(lines) != 1 + or not lines[0] + or lines[0] != lines[0].strip() + or version_text != f"{lines[0]}\n" + ): raise ValueError("VERSION must contain exactly one non-empty version line") + expected = lines[0] package = _read_json_object(root / "package.json") tauri = _read_json_object(root / "apps" / "desktop" / "src-tauri" / "tauri.conf.json") @@ -59,7 +66,11 @@ def verify_release_identity(root: Path, release_tag: str | None = None) -> str: def main() -> int: """Run the release identity gate for repository and tag-triggered workflows.""" - release_tag = os.environ.get("GITHUB_REF_NAME") if os.environ.get("GITHUB_REF_TYPE") == "tag" else None + release_tag = ( + os.environ.get("GITHUB_REF_NAME") + if os.environ.get("GITHUB_REF_TYPE") == "tag" + else None + ) try: version = verify_release_identity(_REPOSITORY_ROOT, release_tag=release_tag) except ValueError as error: From d8efdf7dd641eb5fe7d7e2259b5a59c4873be0bd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:10:40 +0900 Subject: [PATCH 007/308] test(release): require identity gate before publication --- .../tests/test_release_version_identity.py | 54 ++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/services/analysis-engine/tests/test_release_version_identity.py b/services/analysis-engine/tests/test_release_version_identity.py index 0ae40edc4..2616a55e4 100644 --- a/services/analysis-engine/tests/test_release_version_identity.py +++ b/services/analysis-engine/tests/test_release_version_identity.py @@ -8,9 +8,11 @@ from types import ModuleType import pytest +import yaml _REPOSITORY_ROOT = Path(__file__).resolve().parents[3] _GUARD_PATH = _REPOSITORY_ROOT / "scripts" / "checks" / "verify_release_identity.py" +_BUILD_BASELINE_PATH = _REPOSITORY_ROOT / ".github" / "workflows" / "build-baseline.yml" def _load_guard() -> ModuleType: @@ -42,6 +44,16 @@ def _write_release_metadata(root: Path, version: str) -> None: ) +def _workflow_needs(job: dict[str, object]) -> set[str]: + """Normalize a workflow job's ``needs`` dependency to a set of job IDs.""" + needs = job.get("needs", []) + if isinstance(needs, str): + return {needs} + assert isinstance(needs, list) + assert all(isinstance(item, str) for item in needs) + return set(needs) + + def test_release_preflight_executes_version_identity_guard() -> None: """Keep release preflight fail-closed when version projections drift.""" quickcheck = (_REPOSITORY_ROOT / "scripts" / "harness" / "quickcheck.sh").read_text( @@ -55,10 +67,48 @@ def test_release_preflight_executes_version_identity_guard() -> None: assert "./scripts/harness/quickcheck.sh" in release_workflow +def test_tag_build_and_publication_depend_on_release_identity_gate() -> None: + """Block package construction and publication when release identity is invalid.""" + document = yaml.safe_load(_BUILD_BASELINE_PATH.read_text(encoding="utf-8")) + assert isinstance(document, dict) + jobs = document.get("jobs") + assert isinstance(jobs, dict) + + identity_job = jobs.get("release-identity") + assert isinstance(identity_job, dict) + steps = identity_job.get("steps") + assert isinstance(steps, list) + assert any( + isinstance(step, dict) + and step.get("run") == "python3 scripts/checks/verify_release_identity.py" + for step in steps + ) + + for build_job_name in ( + "build-windows-native", + "build-windows-arm64", + "build-macos-native", + "build-macos-arm64", + ): + build_job = jobs.get(build_job_name) + assert isinstance(build_job, dict) + assert "release-identity" in _workflow_needs(build_job) + + publisher = jobs.get("publish-immutable-release") + assert isinstance(publisher, dict) + assert {"release-identity", "gate-windows", "gate-macos"} <= _workflow_needs( + publisher + ) + + def test_repository_release_version_matches_authoritative_version_file() -> None: - """Verify the checked-in package and Tauri release versions against VERSION.""" + """Verify checked-in projections without creating another version authority.""" guard = _load_guard() - assert guard.verify_release_identity(_REPOSITORY_ROOT) == "0.1.3" + version_text = (_REPOSITORY_ROOT / "VERSION").read_text(encoding="utf-8") + assert version_text.endswith("\n") + expected = version_text.removesuffix("\n") + assert "\n" not in expected + assert guard.verify_release_identity(_REPOSITORY_ROOT) == expected def test_release_identity_guard_rejects_metadata_drift(tmp_path: Path) -> None: From 96d6f167d8c8ae53428edaccb16d6324907f36ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:13:14 +0900 Subject: [PATCH 008/308] fix(release): gate artifact publication on version identity --- .github/workflows/build-baseline.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/build-baseline.yml b/.github/workflows/build-baseline.yml index abec57b6b..7a06b0652 100644 --- a/.github/workflows/build-baseline.yml +++ b/.github/workflows/build-baseline.yml @@ -21,8 +21,21 @@ env: GIT_CONFIG_VALUE_0: develop jobs: + release-identity: + name: release-identity + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Verify release identity + run: python3 scripts/checks/verify_release_identity.py + build-windows-native: name: build / windows / amd64 + needs: release-identity runs-on: windows-2025 strategy: fail-fast: false @@ -122,6 +135,7 @@ jobs: build-windows-arm64: name: build / windows / arm64 + needs: release-identity runs-on: windows-11-arm strategy: fail-fast: false @@ -232,6 +246,7 @@ jobs: build-macos-native: name: build / macos / amd64 + needs: release-identity runs-on: macos-15-intel strategy: fail-fast: false @@ -294,6 +309,7 @@ jobs: build-macos-arm64: name: build / macos / arm64 + needs: release-identity runs-on: macos-15 strategy: fail-fast: false @@ -369,6 +385,7 @@ jobs: if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest needs: + - release-identity - gate-windows - gate-macos permissions: From 06ef13e62dd85ed439ff25d3732a9619517540a5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:22:07 +0900 Subject: [PATCH 009/308] fix(release): keep workflow contract dependency-free --- .../tests/test_release_version_identity.py | 58 +++++++++---------- 1 file changed, 26 insertions(+), 32 deletions(-) diff --git a/services/analysis-engine/tests/test_release_version_identity.py b/services/analysis-engine/tests/test_release_version_identity.py index 2616a55e4..ee4573461 100644 --- a/services/analysis-engine/tests/test_release_version_identity.py +++ b/services/analysis-engine/tests/test_release_version_identity.py @@ -8,7 +8,6 @@ from types import ModuleType import pytest -import yaml _REPOSITORY_ROOT = Path(__file__).resolve().parents[3] _GUARD_PATH = _REPOSITORY_ROOT / "scripts" / "checks" / "verify_release_identity.py" @@ -44,14 +43,22 @@ def _write_release_metadata(root: Path, version: str) -> None: ) -def _workflow_needs(job: dict[str, object]) -> set[str]: - """Normalize a workflow job's ``needs`` dependency to a set of job IDs.""" - needs = job.get("needs", []) - if isinstance(needs, str): - return {needs} - assert isinstance(needs, list) - assert all(isinstance(item, str) for item in needs) - return set(needs) +def _workflow_job_block(workflow: str, job_name: str) -> str: + """Return one top-level GitHub Actions job without requiring a YAML runtime dependency.""" + marker = f" {job_name}:" + lines = workflow.splitlines() + try: + start = lines.index(marker) + except ValueError as error: + raise AssertionError(f"workflow job is missing: {job_name}") from error + + end = len(lines) + for index in range(start + 1, len(lines)): + line = lines[index] + if line.startswith(" ") and not line.startswith(" ") and line.endswith(":"): + end = index + break + return "\n".join(lines[start:end]) def test_release_preflight_executes_version_identity_guard() -> None: @@ -69,20 +76,10 @@ def test_release_preflight_executes_version_identity_guard() -> None: def test_tag_build_and_publication_depend_on_release_identity_gate() -> None: """Block package construction and publication when release identity is invalid.""" - document = yaml.safe_load(_BUILD_BASELINE_PATH.read_text(encoding="utf-8")) - assert isinstance(document, dict) - jobs = document.get("jobs") - assert isinstance(jobs, dict) - - identity_job = jobs.get("release-identity") - assert isinstance(identity_job, dict) - steps = identity_job.get("steps") - assert isinstance(steps, list) - assert any( - isinstance(step, dict) - and step.get("run") == "python3 scripts/checks/verify_release_identity.py" - for step in steps - ) + workflow = _BUILD_BASELINE_PATH.read_text(encoding="utf-8") + + identity_job = _workflow_job_block(workflow, "release-identity") + assert "run: python3 scripts/checks/verify_release_identity.py" in identity_job for build_job_name in ( "build-windows-native", @@ -90,15 +87,12 @@ def test_tag_build_and_publication_depend_on_release_identity_gate() -> None: "build-macos-native", "build-macos-arm64", ): - build_job = jobs.get(build_job_name) - assert isinstance(build_job, dict) - assert "release-identity" in _workflow_needs(build_job) - - publisher = jobs.get("publish-immutable-release") - assert isinstance(publisher, dict) - assert {"release-identity", "gate-windows", "gate-macos"} <= _workflow_needs( - publisher - ) + build_job = _workflow_job_block(workflow, build_job_name) + assert "needs: release-identity" in build_job + + publisher = _workflow_job_block(workflow, "publish-immutable-release") + for required_job in ("release-identity", "gate-windows", "gate-macos"): + assert f" - {required_job}" in publisher def test_repository_release_version_matches_authoritative_version_file() -> None: From 3ecadd733fff9c50701343f614d5b4fa3581b137 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:32:12 +0900 Subject: [PATCH 010/308] refactor(release): use semantic identity names --- scripts/checks/verify_release_identity.py | 92 ++++++++++++++--------- 1 file changed, 56 insertions(+), 36 deletions(-) diff --git a/scripts/checks/verify_release_identity.py b/scripts/checks/verify_release_identity.py index 1f8e1438f..ad4bdf61c 100644 --- a/scripts/checks/verify_release_identity.py +++ b/scripts/checks/verify_release_identity.py @@ -12,56 +12,74 @@ _REPOSITORY_ROOT = Path(__file__).resolve().parents[2] -def _read_json_object(path: Path) -> dict[str, Any]: +def _read_json_object(metadata_path: Path) -> dict[str, Any]: """Read one release metadata document and require a JSON object root.""" try: - document = json.loads(path.read_text(encoding="utf-8")) - except (OSError, UnicodeError, json.JSONDecodeError) as error: - raise ValueError(f"could not read release metadata: {path.name}") from error - if not isinstance(document, dict): - raise ValueError(f"release metadata must be an object: {path.name}") - return document - - -def _required_string(document: dict[str, Any], key: str, source: str) -> str: + metadata_document = json.loads(metadata_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, 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.""" - value = document.get(key) - if not isinstance(value, str) or not value.strip() or value != value.strip(): - raise ValueError(f"{source} {key} must be a non-empty trimmed string") - return value + 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 verify_release_identity(root: Path, release_tag: str | None = None) -> str: +def verify_release_identity( + repository_root: Path, release_tag: str | None = None +) -> str: """Verify package, Tauri, and optional tag versions against ``VERSION``.""" try: - version_text = (root / "VERSION").read_text(encoding="utf-8") - except (OSError, UnicodeError) as error: - raise ValueError("could not read authoritative VERSION") from error + version_text = (repository_root / "VERSION").read_text(encoding="utf-8") + except (OSError, UnicodeError) as identity_error: + raise ValueError("could not read authoritative VERSION") from identity_error - lines = version_text.splitlines() + version_lines = version_text.splitlines() if ( - len(lines) != 1 - or not lines[0] - or lines[0] != lines[0].strip() - or version_text != f"{lines[0]}\n" + 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") - expected = lines[0] + release_version = version_lines[0] - package = _read_json_object(root / "package.json") - tauri = _read_json_object(root / "apps" / "desktop" / "src-tauri" / "tauri.conf.json") + 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, "version", "package.json") - tauri_version = _required_string(tauri, "version", "tauri.conf.json") - if package_version != expected: + 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 != expected: + 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{expected}": + if release_tag is not None and release_tag != f"v{release_version}": raise ValueError("release tag does not match VERSION") - return expected + return release_version def main() -> int: @@ -72,11 +90,13 @@ def main() -> int: else None ) try: - version = verify_release_identity(_REPOSITORY_ROOT, release_tag=release_tag) - except ValueError as error: - print(f"release identity check failed: {error}", file=sys.stderr) + release_version = verify_release_identity( + _REPOSITORY_ROOT, release_tag=release_tag + ) + except ValueError as identity_error: + print(f"release identity check failed: {identity_error}", file=sys.stderr) return 1 - print(f"BandScope release identity verified: v{version}") + print(f"BandScope release identity verified: v{release_version}") return 0 From b0d5ecbf18f20842b88879c74fdadd7208476ad7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 08:41:52 +0900 Subject: [PATCH 011/308] test(release): use semantic release-identity names --- .../tests/test_release_version_identity.py | 134 +++++++++++------- 1 file changed, 79 insertions(+), 55 deletions(-) diff --git a/services/analysis-engine/tests/test_release_version_identity.py b/services/analysis-engine/tests/test_release_version_identity.py index ee4573461..67a587bcc 100644 --- a/services/analysis-engine/tests/test_release_version_identity.py +++ b/services/analysis-engine/tests/test_release_version_identity.py @@ -17,25 +17,32 @@ 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" - spec = importlib.util.spec_from_file_location("verify_release_identity", _GUARD_PATH) - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module + 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(root: Path, version: str) -> None: +def _write_release_metadata(repository_root: Path, release_version: str) -> None: """Write the minimum release metadata consumed by the identity guard.""" - (root / "apps" / "desktop" / "src-tauri").mkdir(parents=True) - (root / "VERSION").write_text(f"{version}\n", encoding="utf-8") - (root / "package.json").write_text( - json.dumps({"name": "bandscope", "version": version}), encoding="utf-8" + (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", ) - (root / "apps" / "desktop" / "src-tauri" / "tauri.conf.json").write_text( + ( + repository_root / "apps" / "desktop" / "src-tauri" / "tauri.conf.json" + ).write_text( json.dumps( { "productName": "BandScope", - "version": version, + "version": release_version, "identifier": "com.bandscope.desktop", } ), @@ -43,42 +50,46 @@ def _write_release_metadata(root: Path, version: str) -> None: ) -def _workflow_job_block(workflow: str, job_name: str) -> str: +def _workflow_job_block(workflow_text: str, job_name: str) -> str: """Return one top-level GitHub Actions job without requiring a YAML runtime dependency.""" - marker = f" {job_name}:" - lines = workflow.splitlines() + job_marker = f" {job_name}:" + workflow_lines = workflow_text.splitlines() try: - start = lines.index(marker) - except ValueError as error: - raise AssertionError(f"workflow job is missing: {job_name}") from error - - end = len(lines) - for index in range(start + 1, len(lines)): - line = lines[index] - if line.startswith(" ") and not line.startswith(" ") and line.endswith(":"): - end = index + job_start_index = workflow_lines.index(job_marker) + except ValueError as lookup_error: + raise AssertionError(f"workflow job is missing: {job_name}") from lookup_error + + 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(lines[start:end]) + return "\n".join(workflow_lines[job_start_index:job_end_index]) def test_release_preflight_executes_version_identity_guard() -> None: """Keep release preflight fail-closed when version projections drift.""" - quickcheck = (_REPOSITORY_ROOT / "scripts" / "harness" / "quickcheck.sh").read_text( - encoding="utf-8" - ) - release_workflow = ( + 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 - assert "./scripts/harness/quickcheck.sh" in release_workflow + assert "python3 scripts/checks/verify_release_identity.py" in quickcheck_text + assert "./scripts/harness/quickcheck.sh" in release_workflow_text def test_tag_build_and_publication_depend_on_release_identity_gate() -> None: """Block package construction and publication when release identity is invalid.""" - workflow = _BUILD_BASELINE_PATH.read_text(encoding="utf-8") + build_workflow_text = _BUILD_BASELINE_PATH.read_text(encoding="utf-8") - identity_job = _workflow_job_block(workflow, "release-identity") + identity_job = _workflow_job_block(build_workflow_text, "release-identity") assert "run: python3 scripts/checks/verify_release_identity.py" in identity_job for build_job_name in ( @@ -87,64 +98,77 @@ def test_tag_build_and_publication_depend_on_release_identity_gate() -> None: "build-macos-native", "build-macos-arm64", ): - build_job = _workflow_job_block(workflow, build_job_name) + build_job = _workflow_job_block(build_workflow_text, build_job_name) assert "needs: release-identity" in build_job - publisher = _workflow_job_block(workflow, "publish-immutable-release") - for required_job in ("release-identity", "gate-windows", "gate-macos"): - assert f" - {required_job}" in publisher + publication_job = _workflow_job_block( + build_workflow_text, "publish-immutable-release" + ) + for required_job_name in ("release-identity", "gate-windows", "gate-macos"): + assert f" - {required_job_name}" in publication_job def test_repository_release_version_matches_authoritative_version_file() -> None: """Verify checked-in projections without creating another version authority.""" - guard = _load_guard() + release_guard = _load_guard() version_text = (_REPOSITORY_ROOT / "VERSION").read_text(encoding="utf-8") assert version_text.endswith("\n") - expected = version_text.removesuffix("\n") - assert "\n" not in expected - assert guard.verify_release_identity(_REPOSITORY_ROOT) == expected + 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.""" - guard = _load_guard() + release_guard = _load_guard() _write_release_metadata(tmp_path, "1.2.3") - package = json.loads((tmp_path / "package.json").read_text(encoding="utf-8")) - package["version"] = "1.2.4" - (tmp_path / "package.json").write_text(json.dumps(package), encoding="utf-8") + 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"): - guard.verify_release_identity(tmp_path) + 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.""" - guard = _load_guard() + release_guard = _load_guard() _write_release_metadata(tmp_path, "1.2.3") with pytest.raises(ValueError, match="release tag does not match VERSION"): - guard.verify_release_identity(tmp_path, release_tag="v1.2.2") + release_guard.verify_release_identity(tmp_path, release_tag="v1.2.2") 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.""" - guard = _load_guard() + release_guard = _load_guard() _write_release_metadata(tmp_path, "1.2.3") - ambiguous = "1.2.3\n2.0.0" - (tmp_path / "VERSION").write_text(f"{ambiguous}\n", encoding="utf-8") + 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}), encoding="utf-8" + 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": ambiguous_version, "identifier": "com.bandscope.desktop", } ), encoding="utf-8", ) - with pytest.raises(ValueError, match="VERSION must contain exactly one non-empty version line"): - guard.verify_release_identity(tmp_path) + with pytest.raises( + ValueError, match="VERSION must contain exactly one non-empty version line" + ): + release_guard.verify_release_identity(tmp_path) From dfbcbca6cd01fd57f03c27a4b48e6f0459d607a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 12:34:58 +0900 Subject: [PATCH 012/308] docs(release): document identity-gate security boundary --- scripts/checks/verify_release_identity.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/checks/verify_release_identity.py b/scripts/checks/verify_release_identity.py index ad4bdf61c..8b5d1be7e 100644 --- a/scripts/checks/verify_release_identity.py +++ b/scripts/checks/verify_release_identity.py @@ -1,5 +1,15 @@ #!/usr/bin/env python3 -"""Fail closed when BandScope release-version projections disagree.""" +"""Fail closed when BandScope release-version projections disagree. + +Security Notes: +- ``repository_root`` is an already-selected repository boundary; this guard + reads only the fixed ``VERSION``, ``package.json``, and Tauri configuration + paths beneath it and never follows metadata-provided file paths. +- VERSION and JSON fields are validated as exact, non-empty, trimmed strings + before comparison; malformed text or JSON fails closed without echoing values. +- The guard has no network, filesystem-write, subprocess, update, credential, + signing, or publication authority. It only returns a version or a failure. +""" from __future__ import annotations From 764e06c26c55ca6cf8c771389c010e6b0476e95e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:12:19 +0900 Subject: [PATCH 013/308] test(release): require platform trust before publication --- .../tests/test_release_platform_trust.py | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 services/analysis-engine/tests/test_release_platform_trust.py 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..b1362364e --- /dev/null +++ b/services/analysis-engine/tests/test_release_platform_trust.py @@ -0,0 +1,221 @@ +"""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" +_BUILD_BASELINE_PATH = _REPOSITORY_ROOT / ".github" / "workflows" / "build-baseline.yml" + + +def _load_guard() -> ModuleType: + """Load the executable release-trust guard from its repository path.""" + assert _GUARD_PATH.is_file(), "release builds must own a platform trust verifier" + guard_spec = importlib.util.spec_from_file_location( + "verify_release_platform_trust", _GUARD_PATH + ) + assert guard_spec is not None and guard_spec.loader is not None + guard_module = importlib.util.module_from_spec(guard_spec) + guard_spec.loader.exec_module(guard_module) + return guard_module + + +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_builds_verify_platform_trust_before_upload() -> None: + """Keep immutable publication downstream of platform-native trust verification.""" + workflow_text = _BUILD_BASELINE_PATH.read_text(encoding="utf-8") + + for job_name in ("build-windows-native", "build-windows-arm64"): + job_block = _workflow_job_block(workflow_text, job_name) + verification_index = job_block.index( + "python scripts/checks/verify_release_platform_trust.py windows" + ) + upload_index = job_block.index("uses: actions/upload-artifact@") + assert "if: startsWith(github.ref, 'refs/tags/v')" in job_block + assert "BANDSCOPE_WINDOWS_PUBLISHER_SUBJECT" in job_block + assert verification_index < upload_index + + for job_name in ("build-macos-native", "build-macos-arm64"): + job_block = _workflow_job_block(workflow_text, job_name) + verification_index = job_block.index( + "python3 scripts/checks/verify_release_platform_trust.py macos" + ) + upload_index = job_block.index("uses: actions/upload-artifact@") + assert "if: startsWith(github.ref, 'refs/tags/v')" in job_block + assert "BANDSCOPE_APPLE_TEAM_ID" in job_block + assert verification_index < upload_index From 13da983984ab5fa1069acee31af02f5464cd2747 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:13:29 +0900 Subject: [PATCH 014/308] feat(release): verify native signing and notarization evidence --- .../checks/verify_release_platform_trust.py | 242 ++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 scripts/checks/verify_release_platform_trust.py 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()) From 8de728b680c2657a6fc5e6bffb54dfe3861a76b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:15:48 +0900 Subject: [PATCH 015/308] fix(release): block tag packaging without native trust --- scripts/release/package_desktop_artifact.py | 73 ++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/scripts/release/package_desktop_artifact.py b/scripts/release/package_desktop_artifact.py index 5617ce760..7a7b77601 100644 --- a/scripts/release/package_desktop_artifact.py +++ b/scripts/release/package_desktop_artifact.py @@ -7,8 +7,14 @@ import platform import re import shutil +import subprocess +import sys from collections import Counter +from collections.abc import Callable, Sequence from pathlib import Path +from typing import Any + +CommandRunner = Callable[..., Any] def sha256_file(path: Path) -> str: @@ -104,8 +110,72 @@ 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 _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 main() -> int: - """Find the built installer packages, rename them, and calculate checksums.""" + """Find the built installer packages, rename them, calculate checksums, and verify tag trust.""" repo_root = Path(__file__).resolve().parents[2] output_dir = repo_root / "artifacts" output_dir.mkdir(parents=True, exist_ok=True) @@ -155,6 +225,7 @@ def main() -> int: print(f"Packaged {installer_path.name} to artifacts/{archive_name}") + verify_tag_platform_trust(repo_root, output_dir) return 0 From d33bb96ec1794c02492fea3e8cd5e36280709eae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:16:59 +0900 Subject: [PATCH 016/308] test(release): exercise tag packaging trust boundary --- .../tests/test_release_platform_trust.py | 142 ++++++++++++++---- 1 file changed, 115 insertions(+), 27 deletions(-) diff --git a/services/analysis-engine/tests/test_release_platform_trust.py b/services/analysis-engine/tests/test_release_platform_trust.py index b1362364e..f3e0ca207 100644 --- a/services/analysis-engine/tests/test_release_platform_trust.py +++ b/services/analysis-engine/tests/test_release_platform_trust.py @@ -10,19 +10,28 @@ _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.""" - assert _GUARD_PATH.is_file(), "release builds must own a platform trust verifier" - guard_spec = importlib.util.spec_from_file_location( - "verify_release_platform_trust", _GUARD_PATH - ) - assert guard_spec is not None and guard_spec.loader is not None - guard_module = importlib.util.module_from_spec(guard_spec) - guard_spec.loader.exec_module(guard_module) - return guard_module + 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( @@ -82,7 +91,9 @@ def unsigned_runner(command: list[str], **_: object) -> SimpleNamespace: def wrong_publisher_runner(command: list[str], **_: object) -> SimpleNamespace: del command - return _command_result(stdout='{"Status":"Valid","Subject":"CN=Other Publisher"}') + return _command_result( + stdout='{"Status":"Valid","Subject":"CN=Other Publisher"}' + ) with pytest.raises(ValueError, match="approved Windows publisher"): guard.verify_windows_artifacts( @@ -196,26 +207,103 @@ def test_macos_release_trust_fails_closed_without_identity_or_outputs( guard.verify_macos_artifacts(artifact_root, bundle_root, "ABCDE12345") -def test_tag_builds_verify_platform_trust_before_upload() -> None: - """Keep immutable publication downstream of platform-native trust verification.""" - workflow_text = _BUILD_BASELINE_PATH.read_text(encoding="utf-8") +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]] = [] - for job_name in ("build-windows-native", "build-windows-arm64"): - job_block = _workflow_job_block(workflow_text, job_name) - verification_index = job_block.index( - "python scripts/checks/verify_release_platform_trust.py windows" + 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 ) - upload_index = job_block.index("uses: actions/upload-artifact@") - assert "if: startsWith(github.ref, 'refs/tags/v')" in job_block - assert "BANDSCOPE_WINDOWS_PUBLISHER_SUBJECT" in job_block - assert verification_index < upload_index - for job_name in ("build-macos-native", "build-macos-arm64"): + +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) - verification_index = job_block.index( - "python3 scripts/checks/verify_release_platform_trust.py macos" - ) + packaging_index = job_block.index("scripts/release/package_desktop_artifact.py") upload_index = job_block.index("uses: actions/upload-artifact@") - assert "if: startsWith(github.ref, 'refs/tags/v')" in job_block - assert "BANDSCOPE_APPLE_TEAM_ID" in job_block - assert verification_index < upload_index + assert packaging_index < upload_index From 9361e50d329d0cdfcc32ed7212c30ca97cf1f5ed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 20:08:30 +0900 Subject: [PATCH 017/308] test(release): require commercial model artifact admission --- .../tests/test_release_model_policy.py | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 services/analysis-engine/tests/test_release_model_policy.py 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..f91aa9902 --- /dev/null +++ b/services/analysis-engine/tests/test_release_model_policy.py @@ -0,0 +1,223 @@ +"""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" +_POLICY_PATH = _REPOSITORY_ROOT / "release" / "model-artifact-policy.json" +_BUILD_BASELINE_PATH = _REPOSITORY_ROOT / ".github" / "workflows" / "build-baseline.yml" + + +def _load_guard() -> ModuleType: + """Load the Distribution-owned model release guard from its executable path.""" + assert _GUARD_PATH.is_file(), "release preflight must own a model artifact guard" + guard_module_spec = importlib.util.spec_from_file_location( + "verify_release_model_policy", _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_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 _admitted_artifact(artifact_path: str, payload: bytes) -> dict[str, object]: + """Build exact immutable metadata for an admitted test artifact.""" + return { + "modelId": "cwl/rehearsal-separator-v1", + "modelVersion": "1.0.0", + "path": artifact_path, + "sizeBytes": len(payload), + "sha256": hashlib.sha256(payload).hexdigest(), + "serialization": "safetensors", + "rightsEvidenceSha256": "1" * 64, + "provenanceEvidenceSha256": "2" * 64, + } + + +def _workflow_job_block(workflow_text: str, job_name: str) -> str: + """Return one top-level GitHub Actions job without a YAML parser dependency.""" + workflow_lines = workflow_text.splitlines() + job_marker = f" {job_name}:" + try: + start_index = workflow_lines.index(job_marker) + except ValueError as lookup_error: + raise AssertionError(f"workflow job is missing: {job_name}") from lookup_error + + end_index = len(workflow_lines) + for line_index in range(start_index + 1, len(workflow_lines)): + line = workflow_lines[line_index] + if line.startswith(" ") and not line.startswith(" ") and line.endswith(":"): + end_index = line_index + break + return "\n".join(workflow_lines[start_index:end_index]) + + +def test_release_preflight_owns_model_policy_guard() -> None: + """Require normal repository verification to validate the model policy document.""" + quickcheck_text = ( + _REPOSITORY_ROOT / "scripts" / "harness" / "quickcheck.sh" + ).read_text(encoding="utf-8") + assert "python3 scripts/checks/verify_release_model_policy.py" in quickcheck_text + + +def test_tag_build_requires_commercially_admitted_model_before_builds() -> None: + """Fail a version-tag build before packaging when no model artifact is admitted.""" + workflow_text = _BUILD_BASELINE_PATH.read_text(encoding="utf-8") + identity_job = _workflow_job_block(workflow_text, "release-identity") + assert "python3 scripts/checks/verify_release_model_policy.py" in identity_job + assert "--require-admitted" in identity_job + + +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(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(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("../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( + "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(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(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) From 516189abc86faf5e41da05a82d4e1be28ae89242 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 20:09:13 +0900 Subject: [PATCH 018/308] fix(release): add immutable model artifact admission guard --- scripts/checks/verify_release_model_policy.py | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 scripts/checks/verify_release_model_policy.py diff --git a/scripts/checks/verify_release_model_policy.py b/scripts/checks/verify_release_model_policy.py new file mode 100644 index 000000000..690f26667 --- /dev/null +++ b/scripts/checks/verify_release_model_policy.py @@ -0,0 +1,265 @@ +#!/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. +""" + +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") +_MAX_POLICY_BYTES = 64 * 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", + } +) + + +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(path: Path) -> dict[str, Any]: + """Read one small regular non-link policy document with duplicate rejection.""" + if path.is_symlink(): + raise ValueError("model release policy must be a regular non-link file") + try: + metadata = path.stat() + except FileNotFoundError as error: + raise ValueError("model release policy is missing") from error + if not stat.S_ISREG(metadata.st_mode): + raise ValueError("model release policy must be a regular non-link file") + if metadata.st_size <= 0 or metadata.st_size > _MAX_POLICY_BYTES: + raise ValueError("model release policy exceeds its bounded size") + + with path.open("rb") as policy_file: + payload = policy_file.read(_MAX_POLICY_BYTES + 1) + if len(payload) != metadata.st_size or len(payload) > _MAX_POLICY_BYTES: + raise ValueError("model release policy changed while being read") + try: + decoded = payload.decode("utf-8") + except UnicodeDecodeError as error: + raise ValueError("model release policy must be UTF-8") from error + try: + document = json.loads(decoded, object_pairs_hook=_reject_duplicate_members) + except json.JSONDecodeError as error: + raise ValueError("model release policy must be valid JSON") from error + if not isinstance(document, dict): + raise ValueError("model release policy root must be an object") + return document + + +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_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) + if artifact_path.is_symlink(): + raise ValueError("model artifact 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(artifact_path, open_flags) + except FileNotFoundError as error: + raise ValueError("model artifact is missing") from error + except OSError as error: + raise ValueError("model artifact 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("model artifact must be a regular non-link file") + expected_size = metadata["sizeBytes"] + if initial_metadata.st_size != expected_size: + raise ValueError("model artifact size does not match policy") + + digest = hashlib.sha256() + observed_size = 0 + while True: + chunk = os.read(file_descriptor, 1024 * 1024) + if not chunk: + break + observed_size += len(chunk) + if observed_size > expected_size: + raise ValueError("model artifact size does not match policy") + digest.update(chunk) + final_metadata = os.fstat(file_descriptor) + if observed_size != expected_size or final_metadata.st_size != expected_size: + raise ValueError("model artifact size does not match policy") + if digest.hexdigest() != metadata["sha256"]: + raise ValueError("model artifact SHA-256 does not match policy") + finally: + os.close(file_descriptor) + + +def verify_model_policy( + repository_root: Path, *, require_admitted: bool = False +) -> dict[str, Any]: + """Validate release model policy and optionally require exact admitted artifact bytes.""" + 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) + 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()) From b4234e6ba2a07f376dde7f9490ad0a104ea3c038 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 20:09:20 +0900 Subject: [PATCH 019/308] build(release): keep upstream htdemucs commercially blocked --- release/model-artifact-policy.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 release/model-artifact-policy.json 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 +} From 9b81b97592c931791d749ce15be81edd887a2bcf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 20:09:50 +0900 Subject: [PATCH 020/308] test(release): bind loader policy evidence to admitted model --- services/analysis-engine/tests/test_release_model_policy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/analysis-engine/tests/test_release_model_policy.py b/services/analysis-engine/tests/test_release_model_policy.py index f91aa9902..dcbfe2814 100644 --- a/services/analysis-engine/tests/test_release_model_policy.py +++ b/services/analysis-engine/tests/test_release_model_policy.py @@ -12,7 +12,6 @@ _REPOSITORY_ROOT = Path(__file__).resolve().parents[3] _GUARD_PATH = _REPOSITORY_ROOT / "scripts" / "checks" / "verify_release_model_policy.py" -_POLICY_PATH = _REPOSITORY_ROOT / "release" / "model-artifact-policy.json" _BUILD_BASELINE_PATH = _REPOSITORY_ROOT / ".github" / "workflows" / "build-baseline.yml" @@ -67,6 +66,7 @@ def _admitted_artifact(artifact_path: str, payload: bytes) -> dict[str, object]: "serialization": "safetensors", "rightsEvidenceSha256": "1" * 64, "provenanceEvidenceSha256": "2" * 64, + "loaderPolicySha256": "3" * 64, } From 4dd8d76cd545d1460dd93fec8e3a538777c61a87 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 20:10:45 +0900 Subject: [PATCH 021/308] fix(release): gate tag builds on model admission --- scripts/checks/verify_release_identity.py | 54 ++++++++++++++++++----- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/scripts/checks/verify_release_identity.py b/scripts/checks/verify_release_identity.py index 8b5d1be7e..65ce904d3 100644 --- a/scripts/checks/verify_release_identity.py +++ b/scripts/checks/verify_release_identity.py @@ -1,23 +1,27 @@ #!/usr/bin/env python3 -"""Fail closed when BandScope release-version projections disagree. +"""Fail closed when BandScope release-version or model admission projections disagree. Security Notes: -- ``repository_root`` is an already-selected repository boundary; this guard - reads only the fixed ``VERSION``, ``package.json``, and Tauri configuration - paths beneath it and never follows metadata-provided file paths. +- ``repository_root`` is an already-selected repository boundary. Version identity + reads only the fixed ``VERSION``, ``package.json``, and Tauri configuration. +- The CLI composes the sibling Distribution model-policy guard. Normal branch/PR + checks validate that policy; version-tag checks additionally require exact + commercially admitted model bytes 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. -- The guard has no network, filesystem-write, subprocess, update, credential, - signing, or publication authority. It only returns a version or a failure. +- 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 sys from pathlib import Path -from typing import Any +from types import ModuleType +from typing import Any, Callable _REPOSITORY_ROOT = Path(__file__).resolve().parents[2] @@ -51,6 +55,31 @@ def _required_string( return field_value +def _load_model_policy_module() -> ModuleType: + """Load the adjacent Distribution model-policy guard without another package owner.""" + guard_path = Path(__file__).with_name("verify_release_model_policy.py") + guard_spec = importlib.util.spec_from_file_location( + "bandscope_verify_release_model_policy", guard_path + ) + if guard_spec is None or guard_spec.loader is None: + raise ValueError("could not load release model policy guard") + 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("could not load release model policy guard") from load_error + return guard_module + + +def _model_policy_verifier() -> Callable[..., dict[str, Any]]: + """Return the sibling policy verifier and reject an incomplete guard module.""" + guard_module = _load_model_policy_module() + 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 verify_release_identity( repository_root: Path, release_tag: str | None = None ) -> str: @@ -93,7 +122,7 @@ def verify_release_identity( def main() -> int: - """Run the release identity gate for repository and tag-triggered workflows.""" + """Run version and model-admission gates for repository and tag workflows.""" release_tag = ( os.environ.get("GITHUB_REF_NAME") if os.environ.get("GITHUB_REF_TYPE") == "tag" @@ -103,10 +132,15 @@ def main() -> int: 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, + ) except ValueError as identity_error: - print(f"release identity check failed: {identity_error}", file=sys.stderr) + print(f"release preflight check failed: {identity_error}", file=sys.stderr) return 1 - print(f"BandScope release identity verified: v{release_version}") + print(f"BandScope release preflight verified: v{release_version}") return 0 From 9be81ba73c804f0c4aea2ef4ec9ffa5358b4e894 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 20:11:14 +0900 Subject: [PATCH 022/308] test(release): exercise tag model admission through preflight --- .../tests/test_release_model_policy.py | 55 ++++++++++++++----- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/services/analysis-engine/tests/test_release_model_policy.py b/services/analysis-engine/tests/test_release_model_policy.py index dcbfe2814..c893499ac 100644 --- a/services/analysis-engine/tests/test_release_model_policy.py +++ b/services/analysis-engine/tests/test_release_model_policy.py @@ -12,19 +12,37 @@ _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" _BUILD_BASELINE_PATH = _REPOSITORY_ROOT / ".github" / "workflows" / "build-baseline.yml" +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.""" - assert _GUARD_PATH.is_file(), "release preflight must own a model artifact guard" - guard_module_spec = importlib.util.spec_from_file_location( - "verify_release_model_policy", _GUARD_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", ) - 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_policy( @@ -96,12 +114,25 @@ def test_release_preflight_owns_model_policy_guard() -> None: assert "python3 scripts/checks/verify_release_model_policy.py" in quickcheck_text -def test_tag_build_requires_commercially_admitted_model_before_builds() -> None: +def test_tag_build_requires_commercially_admitted_model_before_builds( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: """Fail a version-tag build before packaging when no model artifact is admitted.""" workflow_text = _BUILD_BASELINE_PATH.read_text(encoding="utf-8") identity_job = _workflow_job_block(workflow_text, "release-identity") - assert "python3 scripts/checks/verify_release_model_policy.py" in identity_job - assert "--require-admitted" in identity_job + assert "run: python3 scripts/checks/verify_release_identity.py" in identity_job + + _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: @@ -194,9 +225,7 @@ def test_model_policy_rejects_path_escape_and_symlink(tmp_path: Path) -> None: _write_policy( tmp_path, release_status="admitted", - admitted_artifact=_admitted_artifact( - "release/models/model.safetensors", payload - ), + admitted_artifact=_admitted_artifact("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) From 0fdadef26dbc81b683e612a280105ce3b121adf6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 20:11:46 +0900 Subject: [PATCH 023/308] test(release): keep one composed model admission preflight --- .../analysis-engine/tests/test_release_model_policy.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/services/analysis-engine/tests/test_release_model_policy.py b/services/analysis-engine/tests/test_release_model_policy.py index c893499ac..06479922c 100644 --- a/services/analysis-engine/tests/test_release_model_policy.py +++ b/services/analysis-engine/tests/test_release_model_policy.py @@ -106,12 +106,15 @@ def _workflow_job_block(workflow_text: str, job_name: str) -> str: return "\n".join(workflow_lines[start_index:end_index]) -def test_release_preflight_owns_model_policy_guard() -> None: - """Require normal repository verification to validate the model policy document.""" +def test_release_preflight_composes_model_policy_without_duplicate_workflow() -> None: + """Keep one release preflight path while composing model admission inside it.""" + identity_guard_text = _IDENTITY_GUARD_PATH.read_text(encoding="utf-8") quickcheck_text = ( _REPOSITORY_ROOT / "scripts" / "harness" / "quickcheck.sh" ).read_text(encoding="utf-8") - assert "python3 scripts/checks/verify_release_model_policy.py" in quickcheck_text + 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 def test_tag_build_requires_commercially_admitted_model_before_builds( From 788e8bd82285e5380f498cf8ec425ab1d64e26d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 20:13:52 +0900 Subject: [PATCH 024/308] repair(restack): restore protected develop before Distribution delta --- .github/workflows/bandit.yml | 35 ------------ .github/workflows/build-baseline.yml | 39 ++++++-------- .github/workflows/ci.yml | 6 +++ .github/workflows/codeql.yml | 39 -------------- .github/workflows/ossf-scorecard.yml | 4 ++ .github/workflows/release.yml | 8 +-- .github/workflows/sbom.yml | 6 +++ .github/workflows/secret-scan-gate.yml | 29 ---------- .github/workflows/security-audit.yml | 53 +++++++++++++++--- .github/workflows/trivy.yml | 54 ------------------- CHANGELOG.md | 3 +- docs/architecture/overview.md | 2 +- docs/repository/bootstrap-plan.md | 7 +-- docs/security/code-security.md | 16 ++++-- docs/security/github-required-checks.md | 36 ++++++++++--- .../github-bootstrap-execution-policy.md | 4 +- scripts/checks/verify_supply_chain.py | 33 +++++++----- .../tests/test_supply_chain_policy.py | 43 +++++++++++---- 18 files changed, 181 insertions(+), 236 deletions(-) delete mode 100644 .github/workflows/bandit.yml delete mode 100644 .github/workflows/codeql.yml delete mode 100644 .github/workflows/secret-scan-gate.yml delete mode 100644 .github/workflows/trivy.yml diff --git a/.github/workflows/bandit.yml b/.github/workflows/bandit.yml deleted file mode 100644 index 6db7276da..000000000 --- a/.github/workflows/bandit.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: bandit - -on: - push: - branches: - - develop - - main - pull_request: - branches: - - develop - - main - -permissions: - contents: read - -env: - GIT_CONFIG_COUNT: "1" - GIT_CONFIG_KEY_0: init.defaultBranch - GIT_CONFIG_VALUE_0: develop - -jobs: - bandit-scan: - name: Bandit Security Scan - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 - with: - version: "0.8.6" - enable-cache: false - - name: Sync Python dependencies - run: uv sync --project services/analysis-engine --group dev --frozen - - name: Run Bandit - working-directory: services/analysis-engine - run: uv run bandit -c pyproject.toml -r src diff --git a/.github/workflows/build-baseline.yml b/.github/workflows/build-baseline.yml index 7a06b0652..13de8e648 100644 --- a/.github/workflows/build-baseline.yml +++ b/.github/workflows/build-baseline.yml @@ -12,6 +12,12 @@ on: tags: - "v*" +concurrency: + group: >- + ${{ github.workflow }}-${{ github.repository }}-${{ + github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read @@ -21,21 +27,8 @@ env: GIT_CONFIG_VALUE_0: develop jobs: - release-identity: - name: release-identity - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - name: Verify release identity - run: python3 scripts/checks/verify_release_identity.py - build-windows-native: name: build / windows / amd64 - needs: release-identity runs-on: windows-2025 strategy: fail-fast: false @@ -135,7 +128,6 @@ jobs: build-windows-arm64: name: build / windows / arm64 - needs: release-identity runs-on: windows-11-arm strategy: fail-fast: false @@ -246,7 +238,6 @@ jobs: build-macos-native: name: build / macos / amd64 - needs: release-identity runs-on: macos-15-intel strategy: fail-fast: false @@ -303,13 +294,14 @@ jobs: - name: Explain non-blocking macOS amd64 artifact upload failure if: ${{ steps.upload-macos-amd64.outcome == 'failure' }} run: | - echo "Artifact upload failed after the macOS amd64 bundle was packaged." >> "$GITHUB_STEP_SUMMARY" - echo "Pull request builds keep artifact upload non-blocking because GitHub artifact service or DNS failures do not invalidate the build evidence." >> "$GITHUB_STEP_SUMMARY" - echo "Tag and release builds remain blocking because release publication requires uploaded artifacts." >> "$GITHUB_STEP_SUMMARY" + { + echo "Artifact upload failed after the macOS amd64 bundle was packaged." + echo "Pull request builds keep artifact upload non-blocking because GitHub artifact service or DNS failures do not invalidate the build evidence." + echo "Tag and release builds remain blocking because release publication requires uploaded artifacts." + } >> "$GITHUB_STEP_SUMMARY" build-macos-arm64: name: build / macos / arm64 - needs: release-identity runs-on: macos-15 strategy: fail-fast: false @@ -366,9 +358,11 @@ jobs: - name: Explain non-blocking macOS arm64 artifact upload failure if: ${{ steps.upload-macos-arm64.outcome == 'failure' }} run: | - echo "Artifact upload failed after the macOS arm64 bundle was packaged." >> "$GITHUB_STEP_SUMMARY" - echo "Pull request builds keep artifact upload non-blocking because GitHub artifact service or DNS failures do not invalidate the build evidence." >> "$GITHUB_STEP_SUMMARY" - echo "Tag and release builds remain blocking because release publication requires uploaded artifacts." >> "$GITHUB_STEP_SUMMARY" + { + echo "Artifact upload failed after the macOS arm64 bundle was packaged." + echo "Pull request builds keep artifact upload non-blocking because GitHub artifact service or DNS failures do not invalidate the build evidence." + echo "Tag and release builds remain blocking because release publication requires uploaded artifacts." + } >> "$GITHUB_STEP_SUMMARY" gate-macos: name: gate / build / macos @@ -385,7 +379,6 @@ jobs: if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest needs: - - release-identity - gate-windows - gate-macos permissions: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d17468129..6e743c2ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,12 @@ on: - develop - main +concurrency: + group: >- + ${{ github.workflow }}-${{ github.repository }}-${{ + github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index 27c5b540f..000000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: codeql - -on: - push: - branches: - - develop - - main - workflow_dispatch: - -permissions: - actions: read - contents: read - -env: - GIT_CONFIG_COUNT: "1" - GIT_CONFIG_KEY_0: init.defaultBranch - GIT_CONFIG_VALUE_0: develop - -jobs: - analyze: - name: codeql - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - strategy: - fail-fast: false - matrix: - language: - - javascript-typescript - - python - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 - with: - languages: ${{ matrix.language }} - - uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 - - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 diff --git a/.github/workflows/ossf-scorecard.yml b/.github/workflows/ossf-scorecard.yml index 2a4b6eaa9..8f5b1bc25 100644 --- a/.github/workflows/ossf-scorecard.yml +++ b/.github/workflows/ossf-scorecard.yml @@ -9,6 +9,10 @@ on: - develop - main +concurrency: + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: false + permissions: read-all jobs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 34583b414..aa69a973c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,10 +1,6 @@ name: release on: - pull_request: - branches: - - develop - - main push: branches: - develop @@ -13,6 +9,10 @@ on: - "v*" workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: false + permissions: contents: read diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index 38700f773..df77ed859 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -15,6 +15,12 @@ on: types: - published +concurrency: + group: >- + ${{ github.workflow }}-${{ github.repository }}-${{ + github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read diff --git a/.github/workflows/secret-scan-gate.yml b/.github/workflows/secret-scan-gate.yml deleted file mode 100644 index 88f72b419..000000000 --- a/.github/workflows/secret-scan-gate.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: secret-scan-gate - -on: - pull_request: - branches: - - develop - - main - push: - branches: - - develop - - main - -permissions: - contents: read - -env: - GIT_CONFIG_COUNT: "1" - GIT_CONFIG_KEY_0: init.defaultBranch - GIT_CONFIG_VALUE_0: develop - -jobs: - secret-scan: - name: secret-scan-gate - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Scan for common hardcoded secrets - run: | - ! git grep -nE '(g[h]p_|g[h]o_|A[K]IA[0-9A-Z]{16}|A[I]za[0-9A-Za-z\-_]{35}|BEGIN (R[S]A|E[C]|OPENS[S]H|P[G]P) PRIVATE KEY)' -- . ':(exclude)package-lock.json' ':(exclude)node_modules/**' diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index f6737f1f6..07754a782 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -1,14 +1,15 @@ -name: security-audit +name: security-backstop on: - pull_request: - branches: - - develop - - main push: branches: - develop - main + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: false permissions: contents: read @@ -19,9 +20,12 @@ env: GIT_CONFIG_VALUE_0: develop jobs: - audit: - name: security-audit + security-backstop: + name: security-backstop runs-on: ubuntu-latest + permissions: + contents: read + security-events: write steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -49,6 +53,9 @@ jobs: run: uv sync --project services/analysis-engine --group dev --frozen - name: Audit Python dependencies run: uv run --project services/analysis-engine --with pip-audit==2.8.0 pip-audit --local --strict + - name: Run Bandit + working-directory: services/analysis-engine + run: uv run bandit -c pyproject.toml -r src - name: Install stable Rust toolchain run: rustup toolchain install stable --profile minimal - name: Install cargo-audit @@ -56,3 +63,35 @@ jobs: - name: Audit Rust dependencies working-directory: apps/desktop/src-tauri run: cargo +stable audit + - name: Scan for common hardcoded secrets + run: | + ! git grep -nE '(g[h]p_|g[h]o_|A[K]IA[0-9A-Z]{16}|A[I]za[0-9A-Za-z\-_]{35}|BEGIN (R[S]A|E[C]|OPENS[S]H|P[G]P) PRIVATE KEY)' -- . ':(exclude)package-lock.json' ':(exclude)node_modules/**' + - name: Run Trivy filesystem scan summary + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: fs + scan-ref: . + version: v0.71.2 + format: table + severity: CRITICAL,HIGH,MEDIUM + exit-code: "0" + skip-dirs: services/analysis-engine/.venv + trivyignores: ./.trivyignore + - name: Run Trivy filesystem scan + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: fs + scan-ref: . + version: v0.71.2 + format: sarif + output: trivy-results.sarif + severity: CRITICAL,HIGH,MEDIUM + limit-severities-for-sarif: true + exit-code: "1" + skip-dirs: services/analysis-engine/.venv + trivyignores: ./.trivyignore + - name: Upload Trivy scan results to GitHub Security tab + if: always() + uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + with: + sarif_file: trivy-results.sarif diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml deleted file mode 100644 index d79ec32e1..000000000 --- a/.github/workflows/trivy.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: trivy - -on: - push: - branches: - - develop - - main - -permissions: - contents: read - -env: - GIT_CONFIG_COUNT: "1" - GIT_CONFIG_KEY_0: init.defaultBranch - GIT_CONFIG_VALUE_0: develop - -jobs: - trivy-fs-scan: - name: trivy-fs-scan - runs-on: ubuntu-latest - permissions: - contents: read - security-events: write - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Run Trivy filesystem scan summary - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0; SHA pinning retained as supply-chain attack mitigation, do not replace with tag. - with: - scan-type: fs - scan-ref: . - version: v0.71.2 - format: table - severity: CRITICAL,HIGH,MEDIUM - exit-code: '0' - skip-dirs: 'services/analysis-engine/.venv' - trivyignores: ./.trivyignore - - name: Run Trivy filesystem scan - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0; SHA pinning retained as supply-chain attack mitigation, do not replace with tag. - with: - scan-type: fs - scan-ref: . - version: v0.71.2 - format: sarif - output: trivy-results.sarif - severity: CRITICAL,HIGH,MEDIUM - limit-severities-for-sarif: true - exit-code: '1' - skip-dirs: 'services/analysis-engine/.venv' - trivyignores: ./.trivyignore - - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 peeled commit; SHA pinning retained as supply-chain attack mitigation. - if: always() - with: - sarif_file: trivy-results.sarif diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..34331fb86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Changed +- Consolidated Bandit, dependency audits, supplemental secret checks, and Trivy into one trusted-branch security backstop, delegated CodeQL to GitHub default setup, and removed duplicate local PR security and release-preflight runs. - Pinned npm `10.9.9` as the approved lockfile generator, activated it through Node-bundled Corepack before dependency consumption, and fail closed unless its bundled `tar` is at least `7.5.19`; primary CI still consumes the committed lock only through frozen `npm ci` validation, rejects mutable npm resolution in the lock gate, requires integrity evidence for public-registry lock entries, and preserves generator-sensitive root `@esbuild/*` peer metadata. ### Fixed @@ -74,4 +75,4 @@ - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. - `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file +- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 3cf5261b9..e7e56d311 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -41,6 +41,6 @@ GitHub is the source of truth for repository governance, PR review, CI/CD, Code ## CI/CD and release flow -- PRs into `develop` and `main` run CI, dependency review, security audit, secret-scan gate, SBOM generation, and CodeQL +- PRs into `develop` and `main` run repository CI, SBOM, and platform builds alongside organization-required OSV, dependency-review, Trivy, CodeQL/code-quality, Semgrep SAST, Strix, and Noema evidence; consolidated local security backstops run after trusted-branch pushes - release flows publish desktop artifacts plus SBOM evidence to GitHub Releases through a tag-driven draft-before-publish path - branch protection connects stable required checks after bootstrap workflows exist diff --git a/docs/repository/bootstrap-plan.md b/docs/repository/bootstrap-plan.md index b16f458a1..7aedb1bdd 100644 --- a/docs/repository/bootstrap-plan.md +++ b/docs/repository/bootstrap-plan.md @@ -31,12 +31,13 @@ After workflows exist, require these stable checks on `main` and `develop`: - `CodeRabbit` - `ci / build-and-test` - `dependency-review` -- `security-audit` -- `CodeQL` - `sbom` -- `release-preflight` - `gate / build / windows` - `gate / build / macos` +- `trivy-fs` +- `Analyze (javascript-typescript)` +- `Analyze (python)` +- organization-required Security Scan, CodeQL/code-quality, SAST Semgrep, Strix, Noema, OpenCode, scheduler, and empty-PR workflows ## Initial README exception diff --git a/docs/security/code-security.md b/docs/security/code-security.md index f9163b9c4..472d4d936 100644 --- a/docs/security/code-security.md +++ b/docs/security/code-security.md @@ -6,12 +6,18 @@ BandScope treats GitHub Code Security as part of bootstrap governance. ## Required controls -- CodeQL or equivalent code scanning workflow -- Trivy filesystem vulnerability scan -- dependency review on pull requests -- security audit workflow for npm, Python, and Rust dependencies in scope +- organization-required CodeQL/code-quality evidence and multi-language SAST on pull requests +- organization-required Trivy filesystem and OSV vulnerability scans +- organization-required dependency review on pull requests +- repository trusted-branch security backstop for npm, Python, and Rust dependencies in scope - Dependabot alerts and security updates -- secret scanning in GitHub plus a supplemental secret-scan gate workflow +- secret scanning in GitHub plus a supplemental trusted-branch secret check + +The central Security Scan owns PR OSV, dependency-review, Trivy, and soft +Scorecard evidence. BandScope combines npm, pip, Cargo, Bandit, supplemental +secret, and Trivy checks into one trusted-branch/manual backstop. GitHub default +setup owns CodeQL, while Scorecard remains separate for its restricted publish +permissions. Central workflows own every pull-request security path. ## Enforcement diff --git a/docs/security/github-required-checks.md b/docs/security/github-required-checks.md index eb62fdae3..ce74b1af6 100644 --- a/docs/security/github-required-checks.md +++ b/docs/security/github-required-checks.md @@ -8,13 +8,18 @@ These are the merge-gate status checks that should be required on protected bran - `ci / build-and-test` - `dependency-review` -- `security-audit` -- `CodeQL` -- `trivy-fs-scan` - `sbom` -- `release-preflight` - `gate / build / windows` - `gate / build / macos` +- `trivy-fs` +- `coverage-evidence` +- `opencode-review` +- `strix` +- `scan-pr-queue` +- `osv-scan` +- `scorecard` +- `Analyze (javascript-typescript)` +- `Analyze (python)` `gate / build / windows` must cover both Windows `amd64` and Windows `arm64`. `gate / build / macos` must cover both macOS Intel (`amd64`) and macOS `arm64`. @@ -23,13 +28,28 @@ These are the merge-gate status checks that should be required on protected bran - `ci / build-and-test` - `dependency-review` -- `security-audit` -- `CodeQL` -- `trivy-fs-scan` - `sbom` -- `release-preflight` - `gate / build / windows` - `gate / build / macos` +- `trivy-fs` +- `Analyze (javascript-typescript)` +- `Analyze (python)` + +The organization required-workflow rule is the authoritative PR owner for +`osv-scan`, `dependency-review`, `trivy-fs`, Scorecard visibility, Semgrep SAST, +Strix, and Noema. GitHub default setup owns CodeQL. One repository-local +`security-backstop` job combines dependency audits, Bandit, supplemental secret +checks, and Trivy after trusted-branch pushes or manual dispatch. Scorecard stays +separate because its publishing path has stricter permissions and SARIF handling. + +The lists above reflect the live classic required-status contexts verified on +2026-09-04. The active organization ruleset separately requires the central +`close-empty-pr.yml`, `opencode-review.yml`, `pr-review-merge-scheduler.yml`, +`security-scan.yml`, `strix.yml`, `sast-semgrep.yml`, and `noema-review.yml` +workflows on the default branch. Keep these two enforcement mechanisms distinct +when changing local triggers. The retired local `security-audit` and +`release-preflight` PR contexts were removed from classic protection with this +workflow consolidation. ## GitHub settings baseline diff --git a/docs/workflow/github-bootstrap-execution-policy.md b/docs/workflow/github-bootstrap-execution-policy.md index 736b695aa..a88f0cddb 100644 --- a/docs/workflow/github-bootstrap-execution-policy.md +++ b/docs/workflow/github-bootstrap-execution-policy.md @@ -38,12 +38,10 @@ The expected sequence is: Bootstrap or setup work is not complete unless GitHub-facing supply-chain controls are both committed and, where permissions allow, enforced: - `.github/dependabot.yml` -- `.github/workflows/dependency-review.yml` - `.github/workflows/security-audit.yml` -- `.github/workflows/codeql.yml` - `.github/workflows/sbom.yml` - `.github/workflows/release.yml` -- branch protection or rulesets for `main` and `develop` that require `ci / build-and-test`, `dependency-review`, `security-audit`, `CodeQL`, `sbom`, `release-preflight`, `gate / build / windows`, and `gate / build / macos` +- branch protection or rulesets for `main` and `develop` that require repository CI, SBOM, platform builds, and the organization-required Security Scan, CodeQL/code-quality, SAST, Strix, and review workflows - PR workflow that still requests CodeRabbit review and records its result when the provider responds cleanly - release retention for the generated SBOM and supplemental inventory diff --git a/scripts/checks/verify_supply_chain.py b/scripts/checks/verify_supply_chain.py index 1cd561e5c..5b87b8bff 100644 --- a/scripts/checks/verify_supply_chain.py +++ b/scripts/checks/verify_supply_chain.py @@ -18,13 +18,11 @@ Path("apps/desktop/src-tauri/Cargo.lock"), Path(".github/dependabot.yml"), # Dependency review runs via the org-level required workflow in - # ContextualWisdomLab/.github; repo-local CodeQL and Scorecard stay push-only - # so GitHub/Scorecard can still observe SAST and supply-chain security tabs. + # ContextualWisdomLab/.github; one repo-local security backstop and + # Scorecard stay push/schedule-only while central workflows own PR scans. Path(".github/workflows/security-audit.yml"), - Path(".github/workflows/codeql.yml"), Path(".github/workflows/sbom.yml"), Path(".github/workflows/release.yml"), - Path(".github/workflows/secret-scan-gate.yml"), Path(".github/workflows/build-baseline.yml"), Path(".github/workflows/ossf-scorecard.yml"), Path(".trivyignore"), @@ -1218,7 +1216,7 @@ def _verify_dependency_review_coverage(missing: list[str]) -> None: def _verify_security_audit_coverage(missing: list[str]) -> None: audit = read_workflow(Path(".github/workflows/security-audit.yml"), "security audit", missing) - for token in ["develop", "main", "pull_request", "push"]: + for token in ["develop", "main", "push", "bandit", "git grep", "trivy-action"]: if audit and token not in audit: missing.append(f"security audit workflow missing trigger token: {token}") audit_run_commands: list[str] = [] @@ -1240,13 +1238,20 @@ def _verify_security_audit_coverage(missing: list[str]) -> None: missing.append(f"security audit workflow missing vulnerability audit token: {token}") +def _verify_bandit_coverage(missing: list[str]) -> None: + bandit = read_workflow(Path(".github/workflows/security-audit.yml"), "bandit", missing) + for token in ["develop", "main", "push", "bandit"]: + if bandit and token not in bandit: + missing.append(f"bandit workflow missing token: {token}") + if bandit and "pull_request:" in bandit: + missing.append( + "bandit workflow must stay push/manual-only; central SAST owns PR scanning" + ) + + def _verify_codeql_coverage(missing: list[str]) -> None: - codeql = read_workflow( - Path(".github/workflows/codeql.yml"), "codeql", missing, optional=True - ) - for token in ["develop", "main", "push", "codeql"]: - if codeql and token not in codeql: - missing.append(f"codeql workflow missing token: {token}") + if Path(".github/workflows/codeql.yml").exists(): + missing.append("repo-local codeql workflow duplicates GitHub default setup") def _verify_release_coverage(missing: list[str]) -> None: @@ -1254,7 +1259,6 @@ def _verify_release_coverage(missing: list[str]) -> None: for token in [ "develop", "main", - "pull_request", "push", "tags:", "release-preflight", @@ -1265,9 +1269,9 @@ def _verify_release_coverage(missing: list[str]) -> None: def _verify_secret_scan_coverage(missing: list[str]) -> None: secret_scan = read_workflow( - Path(".github/workflows/secret-scan-gate.yml"), "secret scan", missing + Path(".github/workflows/security-audit.yml"), "secret scan", missing ) - for token in ["develop", "main", "pull_request", "push", "secret-scan-gate"]: + for token in ["develop", "main", "push", "git grep"]: if secret_scan and token not in secret_scan: missing.append(f"secret scan workflow missing token: {token}") @@ -1352,6 +1356,7 @@ def verify_workflow_coverage() -> list[str]: missing: list[str] = [] _verify_ci_coverage(missing) _verify_sbom_coverage(missing) + _verify_bandit_coverage(missing) _verify_security_audit_coverage(missing) _verify_codeql_coverage(missing) _verify_release_coverage(missing) diff --git a/services/analysis-engine/tests/test_supply_chain_policy.py b/services/analysis-engine/tests/test_supply_chain_policy.py index ab43df89f..1d8224c5a 100644 --- a/services/analysis-engine/tests/test_supply_chain_policy.py +++ b/services/analysis-engine/tests/test_supply_chain_policy.py @@ -1235,30 +1235,53 @@ def test_supply_chain_check_accepts_repo_ossf_publish_restrictions( assert not any("ossf scorecard" in violation for violation in violations) -def test_central_governance_workflows_are_push_only_where_local_signals_remain() -> None: - """Ensure central PR governance keeps only repo-local push security signals.""" +def test_central_governance_workflows_are_consolidated_push_backstops() -> None: + """Ensure central PR governance leaves one local push security backstop.""" repo_root = Path(__file__).resolve().parents[3] workflows_dir = repo_root / ".github" / "workflows" assert not (workflows_dir / "dependency-review.yml").exists() - for local_signal in ("codeql.yml", "ossf-scorecard.yml", "trivy.yml"): - workflow = workflows_dir / local_signal - assert workflow.exists(), ( - f"{local_signal} keeps repository-local security-tab/SAST signal " - "while central required workflows handle PR enforcement" - ) - assert "pull_request:" not in workflow.read_text(encoding="utf-8") + security_backstop = workflows_dir / "security-audit.yml" + assert security_backstop.exists() + workflow = security_backstop.read_text(encoding="utf-8") + assert "pull_request:" not in workflow + for retired_workflow in ("bandit.yml", "codeql.yml", "secret-scan-gate.yml", "trivy.yml"): + assert not (workflows_dir / retired_workflow).exists() supply_chain = load_module( "scripts/checks/verify_supply_chain.py", "verify_supply_chain_central" ) required = {path.as_posix() for path in supply_chain.REQUIRED_FILES} assert ".github/workflows/dependency-review.yml" not in required - assert ".github/workflows/codeql.yml" in required + assert ".github/workflows/codeql.yml" not in required + assert ".github/workflows/security-audit.yml" in required assert ".github/workflows/ossf-scorecard.yml" in required +def test_workflow_concurrency_cancels_only_superseded_pr_heads() -> None: + """Cancel same-PR stale heads without cancelling push, release, or schedule work.""" + repo_root = Path(__file__).resolve().parents[3] + workflows_dir = repo_root / ".github" / "workflows" + + for workflow_name in ("build-baseline.yml", "ci.yml", "sbom.yml"): + workflow = (workflows_dir / workflow_name).read_text(encoding="utf-8") + assert "concurrency:" in workflow, workflow_name + assert "github.workflow }}-${{ github.repository }}" in workflow, workflow_name + assert "github.event.pull_request.number" in workflow, workflow_name + assert "cancel-in-progress: ${{ github.event_name == 'pull_request' }}" in workflow + + for workflow_name in ("ossf-scorecard.yml", "release.yml", "security-audit.yml"): + workflow = (workflows_dir / workflow_name).read_text(encoding="utf-8") + assert "concurrency:" in workflow, workflow_name + assert "cancel-in-progress: false" in workflow, workflow_name + assert "contents: read" in workflow or "permissions: read-all" in workflow, ( + workflow_name + ) + + assert "pull_request:" not in (workflows_dir / "release.yml").read_text(encoding="utf-8") + + def test_opencode_review_declares_top_level_token_permissions() -> None: """Ensure OpenCode token posture is delegated to the central required workflow.""" policy = central_required_workflow_policy_text() From 1313fba927d98ac70a5cc24cb3daf188dea14866 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 20:14:55 +0900 Subject: [PATCH 025/308] fix(release): gate tag artifact writes on release preflight --- scripts/release/package_desktop_artifact.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/scripts/release/package_desktop_artifact.py b/scripts/release/package_desktop_artifact.py index 7a7b77601..20ecf14c1 100644 --- a/scripts/release/package_desktop_artifact.py +++ b/scripts/release/package_desktop_artifact.py @@ -115,6 +115,23 @@ def _is_tag_release() -> bool: 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 and model admission before a tag build writes release artifacts.""" + 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" @@ -175,8 +192,10 @@ def verify_tag_platform_trust( def main() -> int: - """Find the built installer packages, rename them, calculate checksums, and verify tag trust.""" + """Preflight, package installers, calculate checksums, and verify tag 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) From d63a954a9b8ae5139dae2e9a1b96f1d241522626 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 20:15:17 +0900 Subject: [PATCH 026/308] test(release): bind preflight to artifact packaging boundary --- .../tests/test_release_version_identity.py | 54 ++++--------------- 1 file changed, 11 insertions(+), 43 deletions(-) diff --git a/services/analysis-engine/tests/test_release_version_identity.py b/services/analysis-engine/tests/test_release_version_identity.py index 67a587bcc..41e80eca8 100644 --- a/services/analysis-engine/tests/test_release_version_identity.py +++ b/services/analysis-engine/tests/test_release_version_identity.py @@ -11,7 +11,7 @@ _REPOSITORY_ROOT = Path(__file__).resolve().parents[3] _GUARD_PATH = _REPOSITORY_ROOT / "scripts" / "checks" / "verify_release_identity.py" -_BUILD_BASELINE_PATH = _REPOSITORY_ROOT / ".github" / "workflows" / "build-baseline.yml" +_PACKAGER_PATH = _REPOSITORY_ROOT / "scripts" / "release" / "package_desktop_artifact.py" def _load_guard() -> ModuleType: @@ -50,30 +50,8 @@ def _write_release_metadata(repository_root: Path, release_version: str) -> None ) -def _workflow_job_block(workflow_text: str, job_name: str) -> str: - """Return one top-level GitHub Actions job without requiring a YAML runtime dependency.""" - job_marker = f" {job_name}:" - workflow_lines = workflow_text.splitlines() - try: - job_start_index = workflow_lines.index(job_marker) - except ValueError as lookup_error: - raise AssertionError(f"workflow job is missing: {job_name}") from lookup_error - - 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_release_preflight_executes_version_identity_guard() -> None: - """Keep release preflight fail-closed when version projections drift.""" + """Keep repository and release preflight fail-closed when versions drift.""" quickcheck_text = ( _REPOSITORY_ROOT / "scripts" / "harness" / "quickcheck.sh" ).read_text(encoding="utf-8") @@ -85,27 +63,17 @@ def test_release_preflight_executes_version_identity_guard() -> None: assert "./scripts/harness/quickcheck.sh" in release_workflow_text -def test_tag_build_and_publication_depend_on_release_identity_gate() -> None: - """Block package construction and publication when release identity is invalid.""" - build_workflow_text = _BUILD_BASELINE_PATH.read_text(encoding="utf-8") - - identity_job = _workflow_job_block(build_workflow_text, "release-identity") - assert "run: python3 scripts/checks/verify_release_identity.py" in identity_job - - for build_job_name in ( - "build-windows-native", - "build-windows-arm64", - "build-macos-native", - "build-macos-arm64", - ): - build_job = _workflow_job_block(build_workflow_text, build_job_name) - assert "needs: release-identity" in build_job +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)" - publication_job = _workflow_job_block( - build_workflow_text, "publish-immutable-release" + 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 ) - for required_job_name in ("release-identity", "gate-windows", "gate-macos"): - assert f" - {required_job_name}" in publication_job def test_repository_release_version_matches_authoritative_version_file() -> None: From a6e48e0f63e0eaa87e6304abb629fac80a66cd89 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 20:15:47 +0900 Subject: [PATCH 027/308] test(release): enforce blocked model at packaging preflight --- .../tests/test_release_model_policy.py | 32 ++++--------------- 1 file changed, 6 insertions(+), 26 deletions(-) diff --git a/services/analysis-engine/tests/test_release_model_policy.py b/services/analysis-engine/tests/test_release_model_policy.py index 06479922c..14e75c620 100644 --- a/services/analysis-engine/tests/test_release_model_policy.py +++ b/services/analysis-engine/tests/test_release_model_policy.py @@ -13,7 +13,7 @@ _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" -_BUILD_BASELINE_PATH = _REPOSITORY_ROOT / ".github" / "workflows" / "build-baseline.yml" +_PACKAGER_PATH = _REPOSITORY_ROOT / "scripts" / "release" / "package_desktop_artifact.py" def _load_module(module_name: str, module_path: Path) -> ModuleType: @@ -88,43 +88,23 @@ def _admitted_artifact(artifact_path: str, payload: bytes) -> dict[str, object]: } -def _workflow_job_block(workflow_text: str, job_name: str) -> str: - """Return one top-level GitHub Actions job without a YAML parser dependency.""" - workflow_lines = workflow_text.splitlines() - job_marker = f" {job_name}:" - try: - start_index = workflow_lines.index(job_marker) - except ValueError as lookup_error: - raise AssertionError(f"workflow job is missing: {job_name}") from lookup_error - - end_index = len(workflow_lines) - for line_index in range(start_index + 1, len(workflow_lines)): - line = workflow_lines[line_index] - if line.startswith(" ") and not line.startswith(" ") and line.endswith(":"): - end_index = line_index - break - return "\n".join(workflow_lines[start_index:end_index]) - - def test_release_preflight_composes_model_policy_without_duplicate_workflow() -> None: - """Keep one release preflight path while composing model admission inside it.""" + """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_build_requires_commercially_admitted_model_before_builds( +def test_tag_packaging_requires_commercially_admitted_model( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """Fail a version-tag build before packaging when no model artifact is admitted.""" - workflow_text = _BUILD_BASELINE_PATH.read_text(encoding="utf-8") - identity_job = _workflow_job_block(workflow_text, "release-identity") - assert "run: python3 scripts/checks/verify_release_identity.py" in identity_job - + """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) From 0f1d1ca687d86ff0af465ed1eaa53bc2b82064fa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 21:00:21 +0900 Subject: [PATCH 028/308] test(release): require backing bytes for model evidence digests --- .../test_release_model_evidence_binding.py | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 services/analysis-engine/tests/test_release_model_evidence_binding.py 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) From 3e35c935eece9e54a70fdf9df4c549022a16b13e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 21:01:31 +0900 Subject: [PATCH 029/308] fix(release): bind model admission to evidence bytes --- scripts/checks/verify_release_model_policy.py | 98 +++++++++++++++---- 1 file changed, 78 insertions(+), 20 deletions(-) diff --git a/scripts/checks/verify_release_model_policy.py b/scripts/checks/verify_release_model_policy.py index 690f26667..30ecf057a 100644 --- a/scripts/checks/verify_release_model_policy.py +++ b/scripts/checks/verify_release_model_policy.py @@ -4,6 +4,15 @@ 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, and release evidence are untrusted local inputs. +- Commercial admission uses fixed repository-relative evidence locations rather than + policy-controlled evidence paths, preventing path traversal or evidence 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. +- No network lookup, credential access, deserialization, model execution, or write is + performed by this verifier. Missing or drifting evidence fails closed. """ from __future__ import annotations @@ -20,6 +29,7 @@ _POLICY_RELATIVE_PATH = Path("release/model-artifact-policy.json") _MAX_POLICY_BYTES = 64 * 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"}) @@ -42,6 +52,19 @@ "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]: @@ -166,28 +189,36 @@ def _validate_admitted_metadata(value: Any) -> dict[str, Any]: return value -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) - if artifact_path.is_symlink(): - raise ValueError("model artifact must be a regular non-link file") +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(artifact_path, open_flags) + file_descriptor = os.open(path, open_flags) except FileNotFoundError as error: - raise ValueError("model artifact is missing") from error + raise ValueError(f"{label} is missing") from error except OSError as error: - raise ValueError("model artifact must be a regular non-link file") from 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("model artifact must be a regular non-link file") - expected_size = metadata["sizeBytes"] - if initial_metadata.st_size != expected_size: - raise ValueError("model artifact size does not match policy") + 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 @@ -196,22 +227,48 @@ def _verify_artifact_bytes(repository_root: Path, metadata: dict[str, Any]) -> N if not chunk: break observed_size += len(chunk) - if observed_size > expected_size: - raise ValueError("model artifact size does not match policy") + 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 observed_size != expected_size or final_metadata.st_size != expected_size: - raise ValueError("model artifact size does not match policy") - if digest.hexdigest() != metadata["sha256"]: - raise ValueError("model artifact SHA-256 does not match policy") + 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_model_policy( repository_root: Path, *, require_admitted: bool = False ) -> dict[str, Any]: - """Validate release model policy and optionally require exact admitted artifact bytes.""" + """Validate model policy and exact artifact/evidence bytes for admitted releases.""" document = _read_bounded_json(repository_root / _POLICY_RELATIVE_PATH) _require_exact_keys(document, _POLICY_KEYS, "policy") if document["schemaVersion"] != 1: @@ -232,6 +289,7 @@ def verify_model_policy( admitted_metadata = _validate_admitted_metadata(admitted_artifact) _verify_artifact_bytes(repository_root, admitted_metadata) + _verify_evidence_bytes(repository_root, admitted_metadata) return document From 3ab03f042c82f2a759c74a51518bfbf6cdd90125 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 21:02:04 +0900 Subject: [PATCH 030/308] test(release): exercise model evidence byte bindings --- .../tests/test_release_model_policy.py | 68 ++++++++++++++++--- 1 file changed, 57 insertions(+), 11 deletions(-) diff --git a/services/analysis-engine/tests/test_release_model_policy.py b/services/analysis-engine/tests/test_release_model_policy.py index 14e75c620..e32cd29ba 100644 --- a/services/analysis-engine/tests/test_release_model_policy.py +++ b/services/analysis-engine/tests/test_release_model_policy.py @@ -14,6 +14,20 @@ _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: @@ -73,8 +87,22 @@ def _write_policy( return policy_path -def _admitted_artifact(artifact_path: str, payload: bytes) -> dict[str, object]: - """Build exact immutable metadata for an admitted test artifact.""" +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 exact immutable metadata and backing evidence for an admitted test artifact.""" + evidence_digests = _write_evidence_files(repository_root) return { "modelId": "cwl/rehearsal-separator-v1", "modelVersion": "1.0.0", @@ -82,9 +110,7 @@ def _admitted_artifact(artifact_path: str, payload: bytes) -> dict[str, object]: "sizeBytes": len(payload), "sha256": hashlib.sha256(payload).hexdigest(), "serialization": "safetensors", - "rightsEvidenceSha256": "1" * 64, - "provenanceEvidenceSha256": "2" * 64, - "loaderPolicySha256": "3" * 64, + **evidence_digests, } @@ -140,7 +166,7 @@ def test_admitted_artifact_requires_exact_size_and_full_sha256(tmp_path: Path) - _write_policy( tmp_path, release_status="admitted", - admitted_artifact=_admitted_artifact(artifact_path, payload), + admitted_artifact=_admitted_artifact(tmp_path, artifact_path, payload), ) policy = guard.verify_model_policy(tmp_path, require_admitted=True) @@ -162,7 +188,7 @@ def test_admitted_artifact_rejects_same_size_digest_mismatch(tmp_path: Path) -> _write_policy( tmp_path, release_status="admitted", - admitted_artifact=_admitted_artifact(artifact_path, payload), + admitted_artifact=_admitted_artifact(tmp_path, artifact_path, payload), ) artifact_file.write_bytes(b"model-B") @@ -192,7 +218,7 @@ def test_model_policy_rejects_path_escape_and_symlink(tmp_path: Path) -> None: _write_policy( tmp_path, release_status="admitted", - admitted_artifact=_admitted_artifact("../outside.safetensors", payload), + 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) @@ -208,7 +234,9 @@ def test_model_policy_rejects_path_escape_and_symlink(tmp_path: Path) -> None: _write_policy( tmp_path, release_status="admitted", - admitted_artifact=_admitted_artifact("release/models/model.safetensors", payload), + 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) @@ -222,14 +250,32 @@ def test_model_policy_rejects_unknown_keys_and_malformed_evidence(tmp_path: Path artifact_file = tmp_path / artifact_path artifact_file.parent.mkdir(parents=True) artifact_file.write_bytes(payload) - admitted = _admitted_artifact(artifact_path, 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(artifact_path, payload) + 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) From b33958cb19ee55fdc75f2858c4f8700369ed463b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 22:38:17 +0900 Subject: [PATCH 031/308] test(release): require exact immutable release receipt --- .../tests/test_release_receipt.py | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 services/analysis-engine/tests/test_release_receipt.py 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..684930065 --- /dev/null +++ b/services/analysis-engine/tests/test_release_receipt.py @@ -0,0 +1,159 @@ +"""Distribution contracts for the exact packaged-release receipt.""" + +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 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 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) + monkeypatch.setenv("GITHUB_REF", "refs/tags/v1.2.3") + monkeypatch.setenv("GITHUB_SHA", "a" * 40) + + receipt_path = packager.write_release_receipt( + tmp_path, output_dir, [packaged_artifact] + ) + + assert receipt_path == output_dir / "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") + monkeypatch.setenv("GITHUB_REF", "refs/tags/v1.2.3") + monkeypatch.setenv("GITHUB_SHA", "b" * 40) + + 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_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 not (output_dir / "release-receipt.json").exists() + + +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(repo_root, output_dir, packaged_artifacts)" + assert trust_call in packager_text + assert receipt_call in packager_text + assert packager_text.index(trust_call) < packager_text.index(receipt_call) From 73a213c31b73524dc5e32f0d8682d868e557a4e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 22:39:33 +0900 Subject: [PATCH 032/308] fix(release): bind trusted packages into exact release receipt --- scripts/release/package_desktop_artifact.py | 187 +++++++++++++++++++- 1 file changed, 185 insertions(+), 2 deletions(-) diff --git a/scripts/release/package_desktop_artifact.py b/scripts/release/package_desktop_artifact.py index 20ecf14c1..b8e20174a 100644 --- a/scripts/release/package_desktop_artifact.py +++ b/scripts/release/package_desktop_artifact.py @@ -3,18 +3,36 @@ 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 dataclasses import dataclass from pathlib import Path from typing import Any CommandRunner = Callable[..., Any] +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_FULL_GIT_SHA_RE = re.compile(r"^[0-9a-f]{40}$") + + +@dataclass(frozen=True) +class PackagedArtifact: + """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 def sha256_file(path: Path) -> str: @@ -26,6 +44,34 @@ 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 normalized_platform() -> str: """Return the normalized artifact platform label for the current environment.""" if artifact_platform := os.environ.get("BANDSCOPE_ARTIFACT_OS"): @@ -191,6 +237,128 @@ def verify_tag_platform_trust( 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 _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 write_release_receipt( + repo_root: Path, + output_dir: Path, + packaged_artifacts: Sequence[PackagedArtifact], +) -> Path | None: + """Bind trusted tagged installer bytes to one deterministic 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 = { + "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"])), + } + receipt_path = output_dir / "release-receipt.json" + payload = json.dumps(receipt, indent=2, sort_keys=False) + "\n" + _write_receipt_atomically(receipt_path, payload) + return receipt_path + + def main() -> int: """Preflight, package installers, calculate checksums, and verify tag trust.""" repo_root = Path(__file__).resolve().parents[2] @@ -206,6 +374,7 @@ def main() -> int: ) suffix_counts = Counter(path.suffix.lower() for path in installers) + packaged_artifacts: list[PackagedArtifact] = [] for installer_path in installers: identity = artifact_identity(installer_path.name) archive_name = identity["archive_name"] @@ -220,19 +389,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}", @@ -241,10 +413,21 @@ def main() -> int: + "\n", encoding="utf-8", ) + packaged_artifacts.append( + 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, + ) + ) print(f"Packaged {installer_path.name} to artifacts/{archive_name}") verify_tag_platform_trust(repo_root, output_dir) + write_release_receipt(repo_root, output_dir, packaged_artifacts) return 0 From 6d63fbf802636474c98552e855574688d414513d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 22:40:55 +0900 Subject: [PATCH 033/308] fix(release): keep receipt value object import-safe --- scripts/release/package_desktop_artifact.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/scripts/release/package_desktop_artifact.py b/scripts/release/package_desktop_artifact.py index b8e20174a..beb799c95 100644 --- a/scripts/release/package_desktop_artifact.py +++ b/scripts/release/package_desktop_artifact.py @@ -14,17 +14,15 @@ import tempfile from collections import Counter from collections.abc import Callable, Sequence -from dataclasses import dataclass from pathlib import Path -from typing import Any +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}$") -@dataclass(frozen=True) -class PackagedArtifact: +class PackagedArtifact(NamedTuple): """Identify one packaged installer and its supporting checksum/manifest evidence.""" platform: str From 6b0c520b56ded9b60258e64f18b6afa8db334e69 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 22:42:37 +0900 Subject: [PATCH 034/308] test(release): cover receipt admission edge cases --- .../tests/test_release_receipt.py | 172 +++++++++++++++++- 1 file changed, 167 insertions(+), 5 deletions(-) diff --git a/services/analysis-engine/tests/test_release_receipt.py b/services/analysis-engine/tests/test_release_receipt.py index 684930065..dfea661d9 100644 --- a/services/analysis-engine/tests/test_release_receipt.py +++ b/services/analysis-engine/tests/test_release_receipt.py @@ -5,8 +5,10 @@ import hashlib import importlib.util import json +import os +import stat from pathlib import Path -from types import ModuleType +from types import ModuleType, SimpleNamespace import pytest @@ -56,6 +58,12 @@ def _write_packaged_artifact( ) +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: @@ -64,8 +72,7 @@ def test_tag_release_receipt_binds_version_commit_and_exact_artifact_bytes( (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.3") - monkeypatch.setenv("GITHUB_SHA", "a" * 40) + _set_tag_identity(monkeypatch) receipt_path = packager.write_release_receipt( tmp_path, output_dir, [packaged_artifact] @@ -105,8 +112,7 @@ def test_release_receipt_rejects_artifact_drift_after_checksum( 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") - monkeypatch.setenv("GITHUB_REF", "refs/tags/v1.2.3") - monkeypatch.setenv("GITHUB_SHA", "b" * 40) + _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]) @@ -131,6 +137,162 @@ def test_release_receipt_requires_exact_tag_and_full_source_commit( 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: From 838b67539ae4bb325e79620b7ca831671e772fe4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 22:44:21 +0900 Subject: [PATCH 035/308] docs(release): trace exact package receipt boundary --- docs/traceability/release-artifact-receipt.md | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 docs/traceability/release-artifact-receipt.md diff --git a/docs/traceability/release-artifact-receipt.md b/docs/traceability/release-artifact-receipt.md new file mode 100644 index 000000000..3f3db7705 --- /dev/null +++ b/docs/traceability/release-artifact-receipt.md @@ -0,0 +1,71 @@ +# Release artifact receipt traceability + +BandScope의 Distribution/update bounded context는 설치 파일을 만들었다는 사실과 상용 릴리즈로 신뢰할 수 있다는 판단을 구분합니다. 이 문서는 `scripts/release/package_desktop_artifact.py`가 생성하는 `release-receipt.json`의 현재 계약과 아직 해결되지 않은 updater/provenance 경계를 기록합니다. + +## 문제 + +기존 패키저는 각 설치 파일에 `.sha256`과 사람이 읽는 `.manifest.txt`를 만들었지만, 태그·전체 source commit·platform/architecture·실제 패키지 bytes를 하나의 기계 판독 가능한 receipt로 묶지 않았습니다. 플랫폼 서명 또는 notarization 검증과 artifact checksum이 각각 성공해도 어떤 exact source commit의 어떤 검증된 installer bytes를 릴리즈 후보로 취급했는지 단일 증거로 연결되지 않았습니다. + +이 상태에서 per-file checksum을 release provenance, updater manifest 또는 immutable release receipt와 같은 것으로 취급하면 안 됩니다. + +## 제약과 소유권 + +- `VERSION`이 버전 권위입니다. `package.json`, Tauri config와 tag parity는 `verify_release_identity.py`가 검증합니다. +- Windows Authenticode와 macOS code signing/notarization/Gatekeeper 검증은 `verify_release_platform_trust.py`가 소유합니다. +- Commercial separation-model admission은 `verify_release_model_policy.py`와 #1180/#1181 경계에 남습니다. +- `release-receipt.json`은 Distribution package evidence입니다. Project Persistence, Resource Admission 또는 Signal/MIR가 이 포맷을 복제하거나 source-audio/model scientific identity로 사용하지 않습니다. +- 실제 updater verification key, Windows signing identity, Apple Developer ID/notarization authority는 repository에서 임의로 생성하지 않습니다. + +## 선택 + +태그 패키징에서 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. + +Receipt를 만들 때 archive는 한 descriptor에서 regular-file 여부, size와 SHA-256을 다시 확인합니다. 앞서 생성한 checksum과 현재 bytes가 다르면 receipt 생성을 거부합니다. Receipt 자체는 같은 output directory에 staged write + `fsync` 후 `os.replace`로 게시합니다. PR/develop의 unsigned validation artifact에는 release receipt를 만들지 않습니다. + +### 기각한 대안 + +1. 기존 `.sha256`만 release receipt로 간주: source commit/tag/target과 하나의 machine-readable contract로 결합되지 않으므로 기각했습니다. +2. 플랫폼 trust 검증 전에 receipt 생성: 실패한 Authenticode/notarization 후보가 release authority처럼 보일 수 있으므로 기각했습니다. +3. 짧은 commit SHA 사용: 충돌 가능성과 exact protected source 증거 부족 때문에 전체 40-hex commit을 요구합니다. +4. receipt를 updater signature 또는 SLSA provenance라고 부르기: 현재 파일은 별도 서명된 attestation이 아니므로 기각합니다. + +## 실행 근거 + +- 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를 추가 검증합니다. + +Hosted exact-head workflow evidence가 terminal GREEN이 되기 전에는 위 source lineage만으로 release-ready 또는 merge-ready라고 주장하지 않습니다. + +## 현재 claim boundary + +`release-receipt.json`은 **검증된 tag package bytes와 exact source identity를 결합하는 local build receipt**입니다. 다음을 아직 증명하지 않습니다. + +- receipt 자체의 authenticated provenance 또는 build-service non-forgeability; +- Tauri updater public-key pinning 및 `.sig` 검증; +- immutable updater manifest hosting, replay/stale-update 방지, staged rollout/deferral; +- failed/cancelled update 후 known-good rollback과 project-schema compatibility; +- SBOM/provenance/NOTICE/model artifact와 receipt의 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는 Tauri v2 updater의 실제 contract를 사용해 `createUpdaterArtifacts`, pinned public verification key, HTTPS endpoint/static manifest, generated artifact `.sig`를 하나의 source/release gate로 연결하는 것입니다. 승인된 updater signing public key가 provision되기 전에는 임의 키를 source에 넣지 않습니다. 그 다음 단계에서 updater가 wrong key/signature/digest, stale/replayed metadata, unsupported target을 거부하고 offline startup 및 rollback 경로를 보존하는지 packaged-platform evidence로 검증해야 합니다. + +## 참고문헌 + +SLSA Community. (2026). *SLSA specification, version 1.2: Provenance*. https://slsa.dev/spec/v1.2/provenance + +The Update Framework/Tauri Contributors. (2026). *Tauri v2 updater plugin*. https://v2.tauri.app/plugin/updater/ + +in-toto Authors. (2024). *in-toto specifications: Stable specification and Attestation Framework v1.0*. https://in-toto.io/docs/specs/ From 59e5b7cce79fc86d7d88df25d8c3e705745d8953 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 22:44:53 +0900 Subject: [PATCH 036/308] docs(release): correct updater reference attribution --- docs/traceability/release-artifact-receipt.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/traceability/release-artifact-receipt.md b/docs/traceability/release-artifact-receipt.md index 3f3db7705..17c5b6e6d 100644 --- a/docs/traceability/release-artifact-receipt.md +++ b/docs/traceability/release-artifact-receipt.md @@ -66,6 +66,6 @@ Hosted exact-head workflow evidence가 terminal GREEN이 되기 전에는 위 so SLSA Community. (2026). *SLSA specification, version 1.2: Provenance*. https://slsa.dev/spec/v1.2/provenance -The Update Framework/Tauri Contributors. (2026). *Tauri v2 updater plugin*. https://v2.tauri.app/plugin/updater/ +Tauri Contributors. (2026). *Tauri v2 updater plugin*. https://v2.tauri.app/plugin/updater/ in-toto Authors. (2024). *in-toto specifications: Stable specification and Attestation Framework v1.0*. https://in-toto.io/docs/specs/ From 3d094169f76cf6960717e6ed1f71e70905cc950a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 22:46:28 +0900 Subject: [PATCH 037/308] test(release): require admitted model inventory binding --- .../test_release_model_inventory_binding.py | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 services/analysis-engine/tests/test_release_model_inventory_binding.py 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) From 7a8664d24f7ee5f493b605a0d33f1c9f66ce3fba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 22:47:36 +0900 Subject: [PATCH 038/308] fix(release): bind admitted model to component inventory --- scripts/checks/verify_release_model_policy.py | 108 ++++++++++++++---- 1 file changed, 87 insertions(+), 21 deletions(-) diff --git a/scripts/checks/verify_release_model_policy.py b/scripts/checks/verify_release_model_policy.py index 30ecf057a..92bc5cc2e 100644 --- a/scripts/checks/verify_release_model_policy.py +++ b/scripts/checks/verify_release_model_policy.py @@ -6,13 +6,15 @@ it does not claim scientific accuracy or create commercial rights. Security Notes: -- Model policy, model bytes, and release evidence are untrusted local inputs. -- Commercial admission uses fixed repository-relative evidence locations rather than - policy-controlled evidence paths, preventing path traversal or evidence aliasing. +- 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 or drifting evidence fails closed. + performed by this verifier. Missing, ambiguous, or drifting evidence fails closed. """ from __future__ import annotations @@ -28,7 +30,9 @@ 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"}) @@ -77,36 +81,62 @@ def _reject_duplicate_members(pairs: list[tuple[str, Any]]) -> dict[str, Any]: return result -def _read_bounded_json(path: Path) -> dict[str, Any]: - """Read one small regular non-link policy document with duplicate rejection.""" +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("model release policy must be a regular non-link file") + raise ValueError(f"{label} must be a regular non-link file") try: metadata = path.stat() except FileNotFoundError as error: - raise ValueError("model release policy is missing") from error + raise ValueError(missing_message) from error if not stat.S_ISREG(metadata.st_mode): - raise ValueError("model release policy must be a regular non-link file") - if metadata.st_size <= 0 or metadata.st_size > _MAX_POLICY_BYTES: - raise ValueError("model release policy exceeds its bounded size") - - with path.open("rb") as policy_file: - payload = policy_file.read(_MAX_POLICY_BYTES + 1) - if len(payload) != metadata.st_size or len(payload) > _MAX_POLICY_BYTES: - raise ValueError("model release policy changed while being read") + 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("model release policy must be UTF-8") from 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("model release policy must be valid JSON") from error + raise ValueError(f"{label} must be valid JSON") from error if not isinstance(document, dict): - raise ValueError("model release policy root must be an object") + 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) @@ -234,7 +264,10 @@ def _verify_regular_file_digest( 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: + 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") @@ -265,10 +298,42 @@ def _verify_evidence_bytes(repository_root: Path, metadata: dict[str, Any]) -> N ) +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 bytes for admitted releases.""" + """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: @@ -290,6 +355,7 @@ def verify_model_policy( 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 From 0cd9a592501daa3cf5c0aeaf0945ae84ba8e8081 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 22:48:50 +0900 Subject: [PATCH 039/308] test(release): keep admitted fixtures inventory-complete --- .../tests/test_release_model_policy.py | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/services/analysis-engine/tests/test_release_model_policy.py b/services/analysis-engine/tests/test_release_model_policy.py index e32cd29ba..98255e086 100644 --- a/services/analysis-engine/tests/test_release_model_policy.py +++ b/services/analysis-engine/tests/test_release_model_policy.py @@ -101,9 +101,9 @@ def _write_evidence_files(repository_root: Path) -> dict[str, str]: def _admitted_artifact( repository_root: Path, artifact_path: str, payload: bytes ) -> dict[str, object]: - """Build exact immutable metadata and backing evidence for an admitted test artifact.""" + """Build immutable metadata, evidence, and inventory for an admitted test artifact.""" evidence_digests = _write_evidence_files(repository_root) - return { + admitted: dict[str, object] = { "modelId": "cwl/rehearsal-separator-v1", "modelVersion": "1.0.0", "path": artifact_path, @@ -112,6 +112,32 @@ def _admitted_artifact( "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: From 38712e8a7fd1314abdb565af9b9ae4e819461b87 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 23:08:09 +0900 Subject: [PATCH 040/308] test(release): add updater admission RED --- .../tests/test_release_updater_policy.py | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 services/analysis-engine/tests/test_release_updater_policy.py 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..22f136518 --- /dev/null +++ b/services/analysis-engine/tests/test_release_updater_policy.py @@ -0,0 +1,219 @@ +"""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 policy and Tauri config consumed by the guard.""" + (repository_root / "release").mkdir(parents=True) + tauri_root = repository_root / "apps" / "desktop" / "src-tauri" + tauri_root.mkdir(parents=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" + ) + + +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) From 2cd667c5928c1a394fb1e5443c717603b5b1424e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 23:09:01 +0900 Subject: [PATCH 041/308] fix(release): add fail-closed updater policy guard --- .../checks/verify_release_updater_policy.py | 279 ++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 scripts/checks/verify_release_updater_policy.py diff --git a/scripts/checks/verify_release_updater_policy.py b/scripts/checks/verify_release_updater_policy.py new file mode 100644 index 000000000..2e75228ce --- /dev/null +++ b/scripts/checks/verify_release_updater_policy.py @@ -0,0 +1,279 @@ +#!/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 and Tauri + configuration paths; callers cannot supply alternate files or remote URLs; +- JSON inputs are bounded, duplicate-member rejecting, regular non-link files + whose opened descriptor identity must remain stable while read; +- an admitted updater requires Tauri v2 updater artifacts, an exact embedded + public verification key, and exact HTTPS endpoints with insecure transport + disabled; +- 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 +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") +_MAX_POLICY_BYTES = 64 * 1024 +_MAX_TAURI_CONFIG_BYTES = 256 * 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-]+)*)?$" +) + + +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 _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 verify_updater_policy( + repository_root: Path, *, require_admitted: bool = False +) -> dict[str, Any]: + """Verify updater authority and its exact Tauri projection for this repository.""" + 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") + 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()) From 50ca2a80a228652cde0ae402b8c410c07aed7b7c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 23:09:10 +0900 Subject: [PATCH 042/308] fix(release): record blocked updater authority --- release/updater-policy.json | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 release/updater-policy.json 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." +} From 10cf39dda49206f2810583f8f4c3d6afb2e1709f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 23:09:43 +0900 Subject: [PATCH 043/308] fix(release): compose updater admission with tag preflight --- scripts/checks/verify_release_identity.py | 51 ++++++++++++++++------- 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/scripts/checks/verify_release_identity.py b/scripts/checks/verify_release_identity.py index 65ce904d3..3d834cfb6 100644 --- a/scripts/checks/verify_release_identity.py +++ b/scripts/checks/verify_release_identity.py @@ -1,12 +1,13 @@ #!/usr/bin/env python3 -"""Fail closed when BandScope release-version or model admission projections disagree. +"""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. -- The CLI composes the sibling Distribution model-policy guard. Normal branch/PR - checks validate that policy; version-tag checks additionally require exact - commercially admitted model bytes before any platform build can start. +- 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. - These guards have no network, filesystem-write, update, credential, signing, @@ -55,31 +56,46 @@ def _required_string( return field_value -def _load_model_policy_module() -> ModuleType: - """Load the adjacent Distribution model-policy guard without another package owner.""" - guard_path = Path(__file__).with_name("verify_release_model_policy.py") - guard_spec = importlib.util.spec_from_file_location( - "bandscope_verify_release_model_policy", guard_path - ) +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("could not load release model policy guard") + 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("could not load release model policy guard") from 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 policy verifier and reject an incomplete guard module.""" - guard_module = _load_model_policy_module() + """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: @@ -122,7 +138,7 @@ def verify_release_identity( def main() -> int: - """Run version and model-admission gates for repository and tag workflows.""" + """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" @@ -137,6 +153,11 @@ def main() -> int: _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 From 9e00feec31a348fadf9346f40a5016992192e2e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 23:10:27 +0900 Subject: [PATCH 044/308] test(release): repair updater fixture reuse --- services/analysis-engine/tests/test_release_updater_policy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/analysis-engine/tests/test_release_updater_policy.py b/services/analysis-engine/tests/test_release_updater_policy.py index 22f136518..7c8a7fa6c 100644 --- a/services/analysis-engine/tests/test_release_updater_policy.py +++ b/services/analysis-engine/tests/test_release_updater_policy.py @@ -36,9 +36,9 @@ def _write_fixture( updater_config: dict[str, object] | None = None, ) -> None: """Write the minimum policy and Tauri config consumed by the guard.""" - (repository_root / "release").mkdir(parents=True) + (repository_root / "release").mkdir(parents=True, exist_ok=True) tauri_root = repository_root / "apps" / "desktop" / "src-tauri" - tauri_root.mkdir(parents=True) + tauri_root.mkdir(parents=True, exist_ok=True) (repository_root / "release" / "updater-policy.json").write_text( json.dumps( { From 0b565ba7da8dcdcbaa83bd7adb0a7f9087b6ea6f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 23:11:15 +0900 Subject: [PATCH 045/308] docs(release): trace updater admission boundary --- .../traceability/updater-release-admission.md | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 docs/traceability/updater-release-admission.md diff --git a/docs/traceability/updater-release-admission.md b/docs/traceability/updater-release-admission.md new file mode 100644 index 000000000..353f11c27 --- /dev/null +++ b/docs/traceability/updater-release-admission.md @@ -0,0 +1,105 @@ +# 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. + +Before this change, BandScope's release work described those requirements but no executable repository contract distinguished these states: + +- updater authority is absent and commercial tag publication must remain blocked; +- updater authority is present but Tauri config drifts to a different key or endpoint; +- updater artifact generation is not enabled; +- an insecure updater transport escape hatch is enabled. + +That gap allowed a future release branch to satisfy version/model checks while updater trust remained only prose. + +## 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. + +## Implemented contract + +`verify_release_updater_policy.py` reads only fixed repository-relative policy and Tauri configuration files. Both are bounded regular non-link files, and 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. + +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. + +`verify_release_identity.py` composes this guard with the existing version and commercial-model admission guards. `package_desktop_artifact.py` already invokes that release preflight before creating `artifacts/` for version tags, so updater admission is now on the same fail-closed path as tag packaging rather than a detached audit. + +## 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. + +### 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. + +### Allow HTTP for development and rely on environment discipline + +Rejected for commercial admission. Tauri exposes `dangerousInsecureTransportProtocol`; production policy explicitly refuses that escape hatch. Development-only update experiments should remain separate from the release authority. + +### 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 + +This change proves that BandScope cannot label a version-tag build commercially updater-ready while updater authority is absent or the admitted Tauri projection drifts. + +It does **not** yet prove: + +- that an approved signing key has been provisioned; +- that Tauri updater plugin/runtime dependencies are installed and initialized; +- that `.sig` files are generated and published for every supported target; +- that a static/dynamic updater manifest is immutable and bound to exact release receipts; +- that wrong-key/wrong-signature, stale/replayed metadata, partial download, disk-full, cancellation, first-launch failure, staged rollout, deferral, retry, or rollback behavior has passed packaged Windows/macOS acceptance; +- that project-schema compatibility permits a given rollback. + +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: + +- current checked-in blocked authority; +- tag-preflight composition; +- exact admitted public-key and HTTPS-endpoint projection; +- endpoint drift and insecure transport; +- missing updater artifact generation; +- a blocked policy hiding partially enabled updater capability; +- duplicate-member JSON ambiguity. + +Hosted current-head CI remains authoritative for merge/release status. + +## 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 From 8843a308303d7731a175abfc0b90fb365cb7518e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 23:37:31 +0900 Subject: [PATCH 046/308] test(release): require updater runtime wiring before admission --- .../test_release_updater_runtime_wiring.py | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 services/analysis-engine/tests/test_release_updater_runtime_wiring.py 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" From 9869d32bdad9787f3af6556f73d74070f8541b5f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 23:40:03 +0900 Subject: [PATCH 047/308] fix(release): bind updater admission to compiled runtime wiring --- .../checks/verify_release_updater_policy.py | 217 +++++++++++++++++- 1 file changed, 210 insertions(+), 7 deletions(-) diff --git a/scripts/checks/verify_release_updater_policy.py b/scripts/checks/verify_release_updater_policy.py index 2e75228ce..10e944d96 100644 --- a/scripts/checks/verify_release_updater_policy.py +++ b/scripts/checks/verify_release_updater_policy.py @@ -2,13 +2,14 @@ """Verify BandScope's fail-closed commercial updater release policy. Security Notes: -- updater authority is read only from fixed repository-relative policy and Tauri - configuration paths; callers cannot supply alternate files or remote URLs; -- JSON inputs are bounded, duplicate-member rejecting, regular non-link files - whose opened descriptor identity must remain stable while read; +- 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, and exact HTTPS endpoints with insecure transport - disabled; + 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, @@ -22,6 +23,7 @@ import re import stat import sys +import tomllib from pathlib import Path from typing import Any from urllib.parse import urlsplit @@ -29,8 +31,14 @@ _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( @@ -54,6 +62,11 @@ 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]: @@ -117,6 +130,20 @@ def _load_bounded_json_object(path: Path, *, maximum_bytes: int, label: str) -> 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(): @@ -187,10 +214,185 @@ def _create_updater_artifacts_value(tauri_document: dict[str, Any]) -> Any: 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 projection for this repository.""" + """Verify updater authority and its exact Tauri/runtime projection.""" policy = _load_bounded_json_object( repository_root / _POLICY_PATH, maximum_bytes=_MAX_POLICY_BYTES, @@ -251,6 +453,7 @@ def verify_updater_policy( 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 From 6d91f7c7bc117da9ece7563e45a7d88de09b09d4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 23:40:34 +0900 Subject: [PATCH 048/308] test(release): project admitted updater fixtures through runtime wiring --- .../tests/test_release_updater_policy.py | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/services/analysis-engine/tests/test_release_updater_policy.py b/services/analysis-engine/tests/test_release_updater_policy.py index 7c8a7fa6c..5d8b08273 100644 --- a/services/analysis-engine/tests/test_release_updater_policy.py +++ b/services/analysis-engine/tests/test_release_updater_policy.py @@ -35,10 +35,11 @@ def _write_fixture( create_updater_artifacts: bool = False, updater_config: dict[str, object] | None = None, ) -> None: - """Write the minimum policy and Tauri config consumed by the guard.""" + """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" - tauri_root.mkdir(parents=True, exist_ok=True) + source_root = tauri_root / "src" + source_root.mkdir(parents=True, exist_ok=True) (repository_root / "release" / "updater-policy.json").write_text( json.dumps( { @@ -66,6 +67,32 @@ def _write_fixture( 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.""" From b535013d9f381b91c9498ab469f374739192c081 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 23:41:06 +0900 Subject: [PATCH 049/308] docs(traceability): bind updater admission to runtime wiring evidence --- .../traceability/updater-release-admission.md | 63 ++++++++++++++----- 1 file changed, 46 insertions(+), 17 deletions(-) diff --git a/docs/traceability/updater-release-admission.md b/docs/traceability/updater-release-admission.md index 353f11c27..f8f231f88 100644 --- a/docs/traceability/updater-release-admission.md +++ b/docs/traceability/updater-release-admission.md @@ -8,16 +8,11 @@ The current policy is deliberately `blocked`. BandScope does not yet have an org ## 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. +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. -Before this change, BandScope's release work described those requirements but no executable repository contract distinguished these states: +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. -- updater authority is absent and commercial tag publication must remain blocked; -- updater authority is present but Tauri config drifts to a different key or endpoint; -- updater artifact generation is not enabled; -- an insecure updater transport escape hatch is enabled. - -That gap allowed a future release branch to satisfy version/model checks while updater trust remained only prose. +That is a release-truth defect, not a UI or Active Player concern. Distribution must bind admission to the application dependency/runtime graph before a tag is allowed to proceed. ## Constraints @@ -26,10 +21,11 @@ That gap allowed a future release branch to satisfy version/model checks while u - 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. ## Implemented contract -`verify_release_updater_policy.py` reads only fixed repository-relative policy and Tauri configuration files. Both are bounded regular non-link files, and JSON duplicate members are rejected. +`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: @@ -40,6 +36,8 @@ For `state=blocked`, the guard requires: - 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; @@ -47,9 +45,19 @@ For a future `state=admitted`, the guard requires: - 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. +- 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` already 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. -`verify_release_identity.py` composes this guard with the existing version and commercial-model admission guards. `package_desktop_artifact.py` already invokes that release preflight before creating `artifacts/` for version tags, so updater admission is now on the same fail-closed path as tag packaging rather than a detached audit. +### RED → repair lineage + +- `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. ## Alternatives rejected @@ -57,6 +65,18 @@ For a future `state=admitted`, the guard requires: 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. + +### Accept a source initializer without a locked dependency + +Rejected. Source text alone does not establish the immutable package graph. The release contract requires the registry-resolved package and checksum as well. + ### 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. @@ -71,16 +91,18 @@ Rejected. Tauri's private signing key is secret release authority. Repository po ## Claim boundary -This change proves that BandScope cannot label a version-tag build commercially updater-ready while updater authority is absent or the admitted Tauri projection drifts. +This change 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. It does **not** yet prove: - that an approved signing key has been provisioned; -- that Tauri updater plugin/runtime dependencies are installed and initialized; +- that a production updater endpoint has been provisioned and is operational; - that `.sig` files are generated and published for every supported target; - that a static/dynamic updater manifest is immutable and bound to exact release receipts; -- that wrong-key/wrong-signature, stale/replayed metadata, partial download, disk-full, cancellation, first-launch failure, staged rollout, deferral, retry, or rollback behavior has passed packaged Windows/macOS acceptance; -- that project-schema compatibility permits a given rollback. +- 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. @@ -94,9 +116,16 @@ Those remain repository-owned work under #960 once external updater key/endpoint - endpoint drift and insecure transport; - missing updater artifact generation; - a blocked policy hiding partially enabled updater capability; -- duplicate-member JSON ambiguity. +- duplicate-member JSON ambiguity; +- admitted configuration projected through a valid locked updater/runtime fixture. + +`services/analysis-engine/tests/test_release_updater_runtime_wiring.py` covers: + +- admitted config with no compiled `tauri-plugin-updater` dependency; +- a locked updater dependency with no runtime initializer; +- the positive manifest/lock/runtime wiring contract. -Hosted current-head CI remains authoritative for merge/release status. +Hosted current-head CI remains authoritative for merge/release status. No predecessor-head GREEN or review transfers after these source commits. ## References From 421aaeec44fcb93f0250f44487d56a7b711aede0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 23:51:08 +0900 Subject: [PATCH 050/308] test(release): require updater bundles and signatures in release evidence --- .../test_release_updater_artifact_binding.py | 287 ++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 services/analysis-engine/tests/test_release_updater_artifact_binding.py 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", []) == [] From 0e012723e2bff7068d162b721a29d3141c036175 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 23:52:26 +0900 Subject: [PATCH 051/308] fix(release): bind Tauri updater bundles and signatures to release receipt --- scripts/release/package_desktop_artifact.py | 297 +++++++++++++++++++- 1 file changed, 282 insertions(+), 15 deletions(-) diff --git a/scripts/release/package_desktop_artifact.py b/scripts/release/package_desktop_artifact.py index beb799c95..4cad43658 100644 --- a/scripts/release/package_desktop_artifact.py +++ b/scripts/release/package_desktop_artifact.py @@ -1,4 +1,15 @@ -"""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 @@ -20,6 +31,7 @@ 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): @@ -33,6 +45,20 @@ class PackagedArtifact(NamedTuple): 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: """Return the SHA-256 digest for a file.""" digest = hashlib.sha256() @@ -70,6 +96,40 @@ def _stable_regular_file_identity(path: Path) -> tuple[int, str]: 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"): @@ -164,7 +224,7 @@ def verify_tag_release_preflight( *, runner: CommandRunner = subprocess.run, ) -> None: - """Require version and model admission before a tag build writes release artifacts.""" + """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" @@ -254,6 +314,153 @@ def _release_source_commit() -> str: 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(): @@ -293,12 +500,59 @@ def _write_receipt_atomically(receipt_path: Path, payload: str) -> None: 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 bytes to one deterministic machine-readable receipt.""" + """Bind trusted tagged installer and updater bytes to one machine-readable receipt.""" if not _is_tag_release(): return None if not packaged_artifacts: @@ -339,7 +593,7 @@ def write_release_receipt( } ) - receipt = { + receipt: dict[str, object] = { "schemaVersion": 1, "version": version, "tag": tag, @@ -351,6 +605,10 @@ def write_release_receipt( }, "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_path = output_dir / "release-receipt.json" payload = json.dumps(receipt, indent=2, sort_keys=False) + "\n" _write_receipt_atomically(receipt_path, payload) @@ -358,7 +616,7 @@ def write_release_receipt( def main() -> int: - """Preflight, package installers, calculate checksums, and verify tag trust.""" + """Preflight, package installers/updater evidence, then verify tagged trust.""" repo_root = Path(__file__).resolve().parents[2] verify_tag_release_preflight(repo_root) @@ -373,6 +631,7 @@ 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"] @@ -411,21 +670,29 @@ def main() -> int: + "\n", encoding="utf-8", ) - packaged_artifacts.append( - 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_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) + write_release_receipt( + repo_root, + output_dir, + packaged_artifacts, + updater_artifacts, + ) return 0 From 0fa723801b72c57a0bee8cc706581b0e2b3129d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 23:53:26 +0900 Subject: [PATCH 052/308] docs(traceability): bind updater artifact bytes to release receipt --- docs/traceability/release-artifact-receipt.md | 55 ++++++++++++++----- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/docs/traceability/release-artifact-receipt.md b/docs/traceability/release-artifact-receipt.md index 17c5b6e6d..9dde967fb 100644 --- a/docs/traceability/release-artifact-receipt.md +++ b/docs/traceability/release-artifact-receipt.md @@ -1,56 +1,81 @@ # Release artifact receipt traceability -BandScope의 Distribution/update bounded context는 설치 파일을 만들었다는 사실과 상용 릴리즈로 신뢰할 수 있다는 판단을 구분합니다. 이 문서는 `scripts/release/package_desktop_artifact.py`가 생성하는 `release-receipt.json`의 현재 계약과 아직 해결되지 않은 updater/provenance 경계를 기록합니다. +BandScope의 Distribution/update bounded context는 설치 파일을 만들었다는 사실과 상용 릴리즈로 신뢰할 수 있다는 판단을 구분합니다. 이 문서는 `scripts/release/package_desktop_artifact.py`가 생성하는 `release-receipt.json`의 현재 계약, Tauri v2 updater artifact binding, 그리고 아직 해결되지 않은 publication/provenance 경계를 기록합니다. ## 문제 기존 패키저는 각 설치 파일에 `.sha256`과 사람이 읽는 `.manifest.txt`를 만들었지만, 태그·전체 source commit·platform/architecture·실제 패키지 bytes를 하나의 기계 판독 가능한 receipt로 묶지 않았습니다. 플랫폼 서명 또는 notarization 검증과 artifact checksum이 각각 성공해도 어떤 exact source commit의 어떤 검증된 installer bytes를 릴리즈 후보로 취급했는지 단일 증거로 연결되지 않았습니다. -이 상태에서 per-file checksum을 release provenance, updater manifest 또는 immutable release receipt와 같은 것으로 취급하면 안 됩니다. +첫 receipt 구현 뒤에도 updater 쪽에는 별도의 결함이 남았습니다. Tauri v2는 `createUpdaterArtifacts=true`일 때 Windows installer 옆에 `.sig`를 만들고, macOS에서는 `.app.tar.gz` updater bundle과 `.sig`를 생성합니다. 그런데 BandScope 패키저는 DMG/EXE/MSI만 release artifact로 복사했습니다. 따라서 updater admission이 source/config 수준에서 맞더라도 실제 Tauri updater bundle/signature bytes가 immutable release candidate와 같은 receipt에 묶이지 않을 수 있었습니다. 설치 파일 checksum만으로 updater payload/signature publication을 대신했다고 볼 수 없습니다. Tauri는 updater signature 검증을 비활성화할 수 없고 static manifest의 `signature`에는 생성된 `.sig`의 내용 자체가 들어가야 합니다. ## 제약과 소유권 - `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 경계에 남습니다. - `release-receipt.json`은 Distribution package evidence입니다. Project Persistence, Resource Admission 또는 Signal/MIR가 이 포맷을 복제하거나 source-audio/model scientific identity로 사용하지 않습니다. -- 실제 updater verification key, Windows signing identity, Apple Developer ID/notarization authority는 repository에서 임의로 생성하지 않습니다. +- 실제 updater private signing key, approved public verification key/production endpoint, Windows signing identity, Apple Developer ID/notarization authority는 repository에서 임의로 생성하지 않습니다. ## 선택 -태그 패키징에서 native platform trust가 성공한 뒤에만 target별 `release-receipt.json`을 생성합니다. Receipt는 다음을 기록합니다. +태그 패키징은 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. +- 각 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입니다. -Receipt를 만들 때 archive는 한 descriptor에서 regular-file 여부, size와 SHA-256을 다시 확인합니다. 앞서 생성한 checksum과 현재 bytes가 다르면 receipt 생성을 거부합니다. Receipt 자체는 같은 output directory에 staged write + `fsync` 후 `os.replace`로 게시합니다. PR/develop의 unsigned validation artifact에는 release receipt를 만들지 않습니다. +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을 요구하지 않습니다. ### 기각한 대안 1. 기존 `.sha256`만 release receipt로 간주: source commit/tag/target과 하나의 machine-readable contract로 결합되지 않으므로 기각했습니다. -2. 플랫폼 trust 검증 전에 receipt 생성: 실패한 Authenticode/notarization 후보가 release authority처럼 보일 수 있으므로 기각했습니다. -3. 짧은 commit SHA 사용: 충돌 가능성과 exact protected source 증거 부족 때문에 전체 40-hex commit을 요구합니다. -4. receipt를 updater signature 또는 SLSA provenance라고 부르기: 현재 파일은 별도 서명된 attestation이 아니므로 기각합니다. +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를 이 함수에서 검증하지 않으므로 기각합니다. ## 실행 근거 +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를 추가 검증합니다. -Hosted exact-head workflow evidence가 terminal GREEN이 되기 전에는 위 source lineage만으로 release-ready 또는 merge-ready라고 주장하지 않습니다. +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도 확인합니다. + +Hosted exact-head workflow evidence가 terminal GREEN이 되기 전에는 위 source lineage만으로 release-ready 또는 merge-ready라고 주장하지 않습니다. 이 slice 이후의 head는 predecessor check/review evidence를 승계하지 않습니다. ## 현재 claim boundary -`release-receipt.json`은 **검증된 tag package bytes와 exact source identity를 결합하는 local build receipt**입니다. 다음을 아직 증명하지 않습니다. +`release-receipt.json`은 **검증된 tag package bytes, copied updater bundle/signature bytes와 exact source identity를 결합하는 local build receipt**입니다. Updater signature의 존재와 exact bytes를 보존하지만 그 signature가 approved updater key로 cryptographically valid하다는 사실을 이 receipt writer 자체가 증명하지는 않습니다. + +다음은 아직 별도 acceptance 대상입니다. - receipt 자체의 authenticated provenance 또는 build-service non-forgeability; -- Tauri updater public-key pinning 및 `.sig` 검증; -- immutable updater manifest hosting, replay/stale-update 방지, staged rollout/deferral; +- approved Tauri updater public-key provisioning 및 generated `.sig` cryptographic verification; +- static/dynamic updater manifest가 exact release receipt와 bundle/signature bytes를 참조한다는 publication evidence; +- immutable updater manifest hosting, 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와 receipt의 complete release-graph 결합; - #770의 rights-cleared real-audio scientific acceptance; @@ -60,7 +85,9 @@ Hosted exact-head workflow evidence가 terminal GREEN이 되기 전에는 위 so ## 다음 단계 -다음 Distribution causal slice는 Tauri v2 updater의 실제 contract를 사용해 `createUpdaterArtifacts`, pinned public verification key, HTTPS endpoint/static manifest, generated artifact `.sig`를 하나의 source/release gate로 연결하는 것입니다. 승인된 updater signing public key가 provision되기 전에는 임의 키를 source에 넣지 않습니다. 그 다음 단계에서 updater가 wrong key/signature/digest, stale/replayed metadata, unsupported target을 거부하고 offline startup 및 rollback 경로를 보존하는지 packaged-platform evidence로 검증해야 합니다. +다음 Distribution causal slice는 static/dynamic updater manifest를 exact `updaterArtifacts` receipt에 연결하는 것입니다. Manifest의 version/target/url/signature 내용이 이 release candidate의 exact bundle과 `.sig` bytes에서 파생되고 immutable publication까지 이어져야 합니다. 승인된 updater public key/production endpoint가 provision되기 전에는 임의 값을 source에 넣지 않습니다. + +그 뒤 packaged-platform acceptance에서 wrong key/signature/digest, truncated 또는 stale/replayed metadata, unsupported target, partial download/disk-full/cancel, offline startup, first-launch failure와 known-good rollback을 검증해야 합니다. ## 참고문헌 From 45772bf5ab29b2b06e6ecd7cd16b1da7f32fd7d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 23:54:02 +0900 Subject: [PATCH 053/308] docs(traceability): include exact updater artifact receipt boundary --- .../traceability/updater-release-admission.md | 60 +++++++++++-------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/docs/traceability/updater-release-admission.md b/docs/traceability/updater-release-admission.md index f8f231f88..58a855003 100644 --- a/docs/traceability/updater-release-admission.md +++ b/docs/traceability/updater-release-admission.md @@ -12,7 +12,9 @@ Tauri v2 requires update artifacts to be signed and verifies them with a public 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. -That is a release-truth defect, not a UI or Active Player concern. Distribution must bind admission to the application dependency/runtime graph before a tag is allowed to proceed. +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 @@ -22,6 +24,7 @@ That is a release-truth defect, not a UI or Active Player concern. Distribution - 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 @@ -51,14 +54,32 @@ For a future `state=admitted`, the guard requires: - 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` already 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. +`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 @@ -73,17 +94,17 @@ Rejected. Configuration can describe a plugin that Cargo does not compile or the Rejected. A locked crate can remain unused. Dependency presence is supply-chain evidence, not evidence that the desktop runtime actually installs the updater plugin. -### Accept a source initializer without a locked dependency +### Treat `createUpdaterArtifacts=true` as proof that updater bytes are in the release -Rejected. Source text alone does not establish the immutable package graph. The release contract requires the registry-resolved package and checksum as well. +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. -### Enable Tauri updater with placeholder key or endpoint +### Treat macOS DMG as the updater payload -Rejected. Placeholder release authority is materially worse than an explicit blocked state because it can be mistaken for production readiness or accidentally shipped. +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. -### Allow HTTP for development and rely on environment discipline +### Enable Tauri updater with placeholder key or endpoint -Rejected for commercial admission. Tauri exposes `dangerousInsecureTransportProtocol`; production policy explicitly refuses that escape hatch. Development-only update experiments should remain separate from the release authority. +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 @@ -91,14 +112,14 @@ Rejected. Tauri's private signing key is secret release authority. Repository po ## Claim boundary -This change 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. +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 `.sig` files are generated and published for every supported target; -- that a static/dynamic updater manifest is immutable and bound to exact release receipts; +- 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; @@ -108,22 +129,11 @@ Those remain repository-owned work under #960 once external updater key/endpoint ## Test evidence -`services/analysis-engine/tests/test_release_updater_policy.py` covers: - -- current checked-in blocked authority; -- tag-preflight composition; -- exact admitted public-key and HTTPS-endpoint projection; -- endpoint drift and insecure transport; -- missing updater artifact generation; -- a blocked policy hiding partially enabled updater capability; -- duplicate-member JSON ambiguity; -- admitted configuration projected through a valid locked updater/runtime fixture. +`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: +`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. -- admitted config with no compiled `tauri-plugin-updater` dependency; -- a locked updater dependency with no runtime initializer; -- the positive manifest/lock/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. From b948e708cf87f69e9938256df2c90954e6e92c38 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 23:55:53 +0900 Subject: [PATCH 054/308] test(release): require updater evidence through immutable publication graph --- .../test_release_updater_publication_graph.py | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 services/analysis-engine/tests/test_release_updater_publication_graph.py 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") From ef17d4a7be0f63bbaff14a4f4d9adfa7ba065a9f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 23:56:58 +0900 Subject: [PATCH 055/308] fix(release): preserve updater evidence across artifact extraction --- scripts/release/extract_release_artifacts.py | 33 ++++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) 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}") From 6d50e44f2252a64868c3b610185a2e9f76348696 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 23:58:06 +0900 Subject: [PATCH 056/308] fix(release): re-admit updater receipts before immutable publication --- scripts/release/select_release_assets.py | 378 +++++++++++++++++++++-- 1 file changed, 345 insertions(+), 33 deletions(-) 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), From a456b1aad8f0c5ed1a69676d13dc8dea06b78a43 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 23:59:09 +0900 Subject: [PATCH 057/308] fix(release): make target receipts collision-free across publication --- scripts/release/package_desktop_artifact.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/scripts/release/package_desktop_artifact.py b/scripts/release/package_desktop_artifact.py index 4cad43658..746d4aa18 100644 --- a/scripts/release/package_desktop_artifact.py +++ b/scripts/release/package_desktop_artifact.py @@ -96,7 +96,9 @@ def _stable_regular_file_identity(path: Path) -> tuple[int, str]: os.close(file_descriptor) -def _updater_source_identity(path: Path, *, label: str, maximum_bytes: int | None = None) -> tuple[int, str]: +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) @@ -204,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)) @@ -603,13 +609,19 @@ def write_release_receipt( "arch": first.arch, "targetTriple": first.target_triple, }, - "artifacts": sorted(receipt_artifacts, key=lambda artifact: str(artifact["archive"])), + "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_path = output_dir / "release-receipt.json" + 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 From 4ab309f8250ba0311d61e8af6b599d2953303c18 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 23:59:53 +0900 Subject: [PATCH 058/308] test(release): use target-qualified receipts across publication --- services/analysis-engine/tests/test_release_receipt.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/services/analysis-engine/tests/test_release_receipt.py b/services/analysis-engine/tests/test_release_receipt.py index dfea661d9..9a185c036 100644 --- a/services/analysis-engine/tests/test_release_receipt.py +++ b/services/analysis-engine/tests/test_release_receipt.py @@ -78,7 +78,9 @@ def test_tag_release_receipt_binds_version_commit_and_exact_artifact_bytes( tmp_path, output_dir, [packaged_artifact] ) - assert receipt_path == output_dir / "release-receipt.json" + 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, @@ -308,14 +310,14 @@ def test_non_tag_packaging_does_not_publish_release_receipt( packager.write_release_receipt(tmp_path, output_dir, [packaged_artifact]) is None ) - assert not (output_dir / "release-receipt.json").exists() + 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(repo_root, output_dir, packaged_artifacts)" + 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) From 63774aac2ef0676a42567d69626c6bc3adff2022 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 00:00:30 +0900 Subject: [PATCH 059/308] test(release): model complete receipt-bound updater publication set --- .../tests/test_release_asset_selection.py | 111 ++++++++++++++---- 1 file changed, 90 insertions(+), 21 deletions(-) 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", From 03555ee9eb54150798363bcc7c634fe6800b13d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 00:09:40 +0900 Subject: [PATCH 060/308] test(release): reproduce updater manifest publication gap --- .../test_updater_manifest_publication.py | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 services/analysis-engine/tests/test_updater_manifest_publication.py 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..8491b43fe --- /dev/null +++ b/services/analysis-engine/tests/test_updater_manifest_publication.py @@ -0,0 +1,216 @@ +"""Tests for exact updater-manifest generation and publication wiring.""" + +from __future__ import annotations + +import hashlib +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +_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) -> dict[str, str]: + """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") + artifacts = repo_root / "artifacts" + artifacts.mkdir() + signatures: dict[str, str] = {} + + 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 = f"signature-{platform}-{arch}" + signature_payload = signature_text.encode() + (artifacts / signature_name).write_bytes(signature_payload) + signatures[f"{platform}-{arch}"] = signature_text + + 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 + + +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 = _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" + ) + + +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 exactly one updater bundle.""" + 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 "exactly one updater" 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() From 340b476e0039a5367d4d471da99d396c9045aa3a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 00:11:50 +0900 Subject: [PATCH 061/308] fix(release): bind updater manifest to receipt-authorized bytes --- scripts/release/build_updater_manifest.py | 310 ++++++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 scripts/release/build_updater_manifest.py diff --git a/scripts/release/build_updater_manifest.py b/scripts/release/build_updater_manifest.py new file mode 100644 index 000000000..5ad15dbae --- /dev/null +++ b/scripts/release/build_updater_manifest.py @@ -0,0 +1,310 @@ +#!/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 + and an exact size/SHA-256 comparison against the target receipt. Release + URLs are exact-tag HTTPS URLs; no mutable latest URL or untrusted receipt + path is used as a filesystem authority. +""" + +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 Any +from urllib.parse import quote, urlsplit + +import select_release_assets as release_assets + +_FULL_SHA_RE = re.compile(r"^[0-9a-f]{40}$") +_REPOSITORY_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +_MAX_VERSION_BYTES = 256 +_MAX_SIGNATURE_BYTES = 64 * 1024 +_PLATFORM_KEYS = { + ("windows", "amd64"): "windows-x86_64", + ("windows", "arm64"): "windows-aarch64", + ("macos", "amd64"): "darwin-x86_64", + ("macos", "arm64"): "darwin-aarch64", +} + + +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 _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 _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("utf-8").strip() + except UnicodeError as error: + raise ValueError("updater signature must contain UTF-8 text") from error + if not text or "\x00" in text or "\r" in text or "\n" in text: + raise ValueError("updater signature must contain one non-empty text value") + 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) + platforms: dict[str, dict[str, str]] = {} + 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") + 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, + } + + return { + "version": version, + "platforms": platforms, + } + + +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()) From 4d6f7cb8cefd382ada8e01925425e5ea192f579b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 00:13:21 +0900 Subject: [PATCH 062/308] fix(release): publish receipt-bound updater manifest --- .github/workflows/build-baseline.yml | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-baseline.yml b/.github/workflows/build-baseline.yml index 13de8e648..ca3ea26c9 100644 --- a/.github/workflows/build-baseline.yml +++ b/.github/workflows/build-baseline.yml @@ -410,7 +410,16 @@ 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 + 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 with complete assets, then publish env: GH_TOKEN: ${{ secrets.BANDSCOPE_RELEASE_TOKEN }} @@ -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,4 @@ jobs: --title "BandScope ${RELEASE_TAG#v}" \ --verify-tag \ --repo "${{ github.repository }}" - gh release edit "$RELEASE_TAG" --draft=false --repo "${{ github.repository }}" + gh release edit "$RELEASE_TAG" --draft=false --repo "${{ github.repository }}" \ No newline at end of file From 64fbd14df9bf92ae2618f7cc13008cc283c19545 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 00:14:28 +0900 Subject: [PATCH 063/308] fix(release): preserve exact updater signature text --- scripts/release/build_updater_manifest.py | 43 +++++++++++++++++------ 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/scripts/release/build_updater_manifest.py b/scripts/release/build_updater_manifest.py index 5ad15dbae..55ce1b90d 100644 --- a/scripts/release/build_updater_manifest.py +++ b/scripts/release/build_updater_manifest.py @@ -58,7 +58,10 @@ def _stable_read_bytes(path: Path, *, label: str, maximum_bytes: int) -> 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))) + chunk = os.read( + descriptor, + min(64 * 1024, maximum_bytes + 1 - len(payload)), + ) if not chunk: break payload.extend(chunk) @@ -85,7 +88,9 @@ def _version(repo_root: Path) -> str: 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): + 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 @@ -117,7 +122,9 @@ def _normalized_repository(repository: str) -> str: return value -def _receipt_path(repo_root: Path, target: tuple[str, str], source_commit: str) -> Path: +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 ( @@ -132,7 +139,11 @@ def _exact_updater_entry( ) -> 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): + 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]}" ) @@ -157,7 +168,11 @@ def _signature_text( ) 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: + 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") @@ -165,11 +180,11 @@ def _signature_text( if not isinstance(expected_digest, str) or digest != expected_digest: raise ValueError("updater signature digest does not match release receipt") try: - text = payload.decode("utf-8").strip() + text = payload.decode("utf-8") except UnicodeError as error: raise ValueError("updater signature must contain UTF-8 text") from error - if not text or "\x00" in text or "\r" in text or "\n" in text: - raise ValueError("updater signature must contain one non-empty text value") + if not text.strip() or "\x00" in text: + raise ValueError("updater signature must contain non-empty UTF-8 text") return text @@ -267,7 +282,9 @@ def _check_output(path: Path, expected: bytes) -> None: maximum_bytes=max(len(expected), 256 * 1024), ) if actual != expected: - raise ValueError("updater manifest does not match receipt-authorized release bytes") + raise ValueError( + "updater manifest does not match receipt-authorized release bytes" + ) def main() -> int: @@ -275,8 +292,12 @@ def main() -> int: 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( + "--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", From 889554d2c9200ec1258d1845655b2785486a6a31 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 00:14:52 +0900 Subject: [PATCH 064/308] test(release): cover manifest publication boundary cleanly --- .../test_updater_manifest_publication.py | 42 +++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/services/analysis-engine/tests/test_updater_manifest_publication.py b/services/analysis-engine/tests/test_updater_manifest_publication.py index 8491b43fe..65c94f6a8 100644 --- a/services/analysis-engine/tests/test_updater_manifest_publication.py +++ b/services/analysis-engine/tests/test_updater_manifest_publication.py @@ -8,8 +8,6 @@ import sys from pathlib import Path -import pytest - _REPO_ROOT = Path(__file__).resolve().parents[3] _BUILDER = _REPO_ROOT / "scripts" / "release" / "build_updater_manifest.py" _WORKFLOW = _REPO_ROOT / ".github" / "workflows" / "build-baseline.yml" @@ -57,7 +55,9 @@ def _write_release_graph(repo_root: Path, *, source_commit: str) -> dict[str, st updater_name = archive_name updater_payload = archive_payload else: - updater_name = f"bandscope-macos-{arch}-{source_commit[:12]}.app.tar.gz" + 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" @@ -99,11 +99,15 @@ def _write_release_graph(repo_root: Path, *, source_commit: str) -> dict[str, st receipt_name = ( f"bandscope-{platform}-{arch}-{source_commit[:12]}.release-receipt.json" ) - (artifacts / receipt_name).write_text(json.dumps(receipt), encoding="utf-8") + (artifacts / receipt_name).write_text( + json.dumps(receipt), encoding="utf-8" + ) return signatures -def _run_builder(repo_root: Path, *, source_commit: str, check: bool = False) -> subprocess.CompletedProcess[str]: +def _run_builder( + repo_root: Path, *, source_commit: str, check: bool = False +) -> subprocess.CompletedProcess[str]: command = [ sys.executable, str(_BUILDER), @@ -139,20 +143,32 @@ def test_manifest_binds_exact_receipts_and_signature_contents(tmp_path: Path) -> "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"]["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" ) -def test_manifest_check_rejects_post_generation_signature_drift(tmp_path: Path) -> None: +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 = ( + 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) @@ -161,8 +177,10 @@ def test_manifest_check_rejects_post_generation_signature_drift(tmp_path: Path) 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 exactly one updater bundle.""" +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 = ( @@ -177,7 +195,7 @@ def test_manifest_rejects_ambiguous_updater_bundle_for_one_target(tmp_path: Path completed = _run_builder(tmp_path, source_commit=source_commit) assert completed.returncode != 0 - assert "exactly one updater" in completed.stderr.lower() + assert "updater" in completed.stderr.lower() def test_release_workflow_builds_and_rechecks_manifest_before_publication() -> None: From f0d6dd57451984820afb07c41cae172f3c7f3628 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 00:15:38 +0900 Subject: [PATCH 065/308] docs(release): trace receipt-bound updater manifest publication --- docs/traceability/release-artifact-receipt.md | 43 ++++++++++++++----- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/docs/traceability/release-artifact-receipt.md b/docs/traceability/release-artifact-receipt.md index 9dde967fb..e47d09a34 100644 --- a/docs/traceability/release-artifact-receipt.md +++ b/docs/traceability/release-artifact-receipt.md @@ -1,12 +1,14 @@ # Release artifact receipt traceability -BandScope의 Distribution/update bounded context는 설치 파일을 만들었다는 사실과 상용 릴리즈로 신뢰할 수 있다는 판단을 구분합니다. 이 문서는 `scripts/release/package_desktop_artifact.py`가 생성하는 `release-receipt.json`의 현재 계약, Tauri v2 updater artifact binding, 그리고 아직 해결되지 않은 publication/provenance 경계를 기록합니다. +BandScope의 Distribution/update bounded context는 설치 파일을 만들었다는 사실과 상용 릴리즈로 신뢰할 수 있다는 판단을 구분합니다. 이 문서는 `scripts/release/package_desktop_artifact.py`가 생성하는 target receipt, Tauri v2 updater artifact와 static manifest binding, 그리고 아직 해결되지 않은 publication/provenance 경계를 기록합니다. ## 문제 기존 패키저는 각 설치 파일에 `.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 admission이 source/config 수준에서 맞더라도 실제 Tauri updater bundle/signature bytes가 immutable release candidate와 같은 receipt에 묶이지 않을 수 있었습니다. 설치 파일 checksum만으로 updater payload/signature publication을 대신했다고 볼 수 없습니다. Tauri는 updater signature 검증을 비활성화할 수 없고 static manifest의 `signature`에는 생성된 `.sig`의 내용 자체가 들어가야 합니다. +첫 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에 넣는 방식은 계약과 맞지 않습니다. ## 제약과 소유권 @@ -14,8 +16,8 @@ BandScope의 Distribution/update bounded context는 설치 파일을 만들었 - 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 경계에 남습니다. -- `release-receipt.json`은 Distribution package evidence입니다. Project Persistence, Resource Admission 또는 Signal/MIR가 이 포맷을 복제하거나 source-audio/model scientific identity로 사용하지 않습니다. -- 실제 updater private signing key, approved public verification key/production endpoint, Windows signing identity, Apple Developer ID/notarization authority는 repository에서 임의로 생성하지 않습니다. +- Target `release-receipt.json`과 `latest.json`은 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에서 임의로 생성하지 않습니다. ## 선택 @@ -39,6 +41,10 @@ Updater source와 copied output은 각각 안정된 regular-file descriptor에 표준 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으로 전달한 뒤에만 immutable release를 publish합니다. 현재 공개 `v0.1.3` release가 GitHub API에서 immutable release로 보고되는 repository publication model을 그대로 사용하며, manifest URL도 같은 exact-tag release namespace를 사용합니다. + ### 기각한 대안 1. 기존 `.sha256`만 release receipt로 간주: source commit/tag/target과 하나의 machine-readable contract로 결합되지 않으므로 기각했습니다. @@ -48,6 +54,9 @@ Updater source와 copied output은 각각 안정된 regular-file descriptor에 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→publish를 수행합니다. ## 실행 근거 @@ -62,19 +71,31 @@ 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를 제거했습니다. Hosted exact-head workflow evidence가 terminal GREEN이 되기 전에는 위 source lineage만으로 release-ready 또는 merge-ready라고 주장하지 않습니다. 이 slice 이후의 head는 predecessor check/review evidence를 승계하지 않습니다. ## 현재 claim boundary -`release-receipt.json`은 **검증된 tag package bytes, copied updater bundle/signature bytes와 exact source identity를 결합하는 local build receipt**입니다. Updater signature의 존재와 exact bytes를 보존하지만 그 signature가 approved updater key로 cryptographically valid하다는 사실을 이 receipt writer 자체가 증명하지는 않습니다. +Target receipt와 generated `latest.json`은 **검증된 tag package bytes, copied updater bundle/signature bytes, exact source identity와 exact-tag download URL을 하나의 publication graph로 결합하는 local release evidence**입니다. Manifest가 receipt에 기록된 `.sig` bytes의 exact text를 싣는다는 것은 검증하지만, 그 signature가 아직 provision되지 않은 organization-approved updater public key로 cryptographically valid하다는 사실까지 증명하지 않습니다. + +현재 updater policy는 의도적으로 `blocked`입니다. 승인된 public verification key와 production discovery endpoint가 provision되지 않았기 때문에 현재 source를 상용 updater authority가 준비된 상태라고 해석하지 않습니다. `latest.json` 생성 기능은 future admitted release에서 사용할 deterministic publication primitive이며, tag preflight는 blocked policy에서 계속 fail closed합니다. 다음은 아직 별도 acceptance 대상입니다. -- receipt 자체의 authenticated provenance 또는 build-service non-forgeability; +- receipt/manifest 자체의 authenticated provenance 또는 build-service non-forgeability; - approved Tauri updater public-key provisioning 및 generated `.sig` cryptographic verification; -- static/dynamic updater manifest가 exact release receipt와 bundle/signature bytes를 참조한다는 publication evidence; -- immutable updater manifest hosting, wrong-key/signature/digest/truncation 및 replay/stale-update 방지; +- immutable publication 뒤 hosted `latest.json`과 hosted bundle/signature bytes의 post-publish re-fetch/re-admission evidence; +- 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와 receipt의 complete release-graph 결합; @@ -85,9 +106,9 @@ Hosted exact-head workflow evidence가 terminal GREEN이 되기 전에는 위 so ## 다음 단계 -다음 Distribution causal slice는 static/dynamic updater manifest를 exact `updaterArtifacts` receipt에 연결하는 것입니다. Manifest의 version/target/url/signature 내용이 이 release candidate의 exact bundle과 `.sig` bytes에서 파생되고 immutable publication까지 이어져야 합니다. 승인된 updater public key/production endpoint가 provision되기 전에는 임의 값을 source에 넣지 않습니다. +다음 Distribution causal slice는 **published release re-verification과 updater anti-replay/rollback contract**입니다. Draft asset set이 immutable publication으로 승격된 뒤 hosted `latest.json`, target updater bundle과 signature를 다시 읽어 local receipt/digest와 같은지 검증하는 evidence가 필요합니다. 그 다음 version monotonicity/stale metadata 거부, wrong key/signature/digest, truncated download, unsupported target, partial download/disk-full/cancel, offline startup, first-launch failure와 known-good rollback을 packaged-platform acceptance로 연결해야 합니다. -그 뒤 packaged-platform acceptance에서 wrong key/signature/digest, truncated 또는 stale/replayed metadata, unsupported target, partial download/disk-full/cancel, offline startup, first-launch failure와 known-good rollback을 검증해야 합니다. +승인된 updater public key/production discovery endpoint, Windows/macOS signer authority와 commercial model rights는 외부 권위입니다. 이 값들은 source repair 과정에서 임의 생성하지 않습니다. ## 참고문헌 @@ -95,4 +116,6 @@ SLSA Community. (2026). *SLSA specification, version 1.2: Provenance*. https://s 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/ From db4ad0660bc0b262bdf921c243c9691bee29118c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 00:18:45 +0900 Subject: [PATCH 066/308] test(release): reproduce hosted asset re-verification gap --- ...est_hosted_release_asset_reverification.py | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 services/analysis-engine/tests/test_hosted_release_asset_reverification.py 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..7c2af75b0 --- /dev/null +++ b/services/analysis-engine/tests/test_hosted_release_asset_reverification.py @@ -0,0 +1,106 @@ +"""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 the admitted local upload byte-for-byte.""" + 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 must neither drop admitted assets nor add unreviewed uploaded 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: + """The expected publication set must be one unique basename per uploaded asset.""" + 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: + """Release publication must compare downloaded draft and 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) From e0227942e31100d4a9a0dc3e8c8f7fa95a8613fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 00:19:12 +0900 Subject: [PATCH 067/308] fix(release): re-admit downloaded hosted release assets --- .../release/verify_hosted_release_assets.py | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 scripts/release/verify_hosted_release_assets.py 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()) From 3c9221b4ea517aea296739302e025b0c9196d198 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 00:20:12 +0900 Subject: [PATCH 068/308] fix(release): re-verify draft and published release bytes --- .github/workflows/build-baseline.yml | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-baseline.yml b/.github/workflows/build-baseline.yml index ca3ea26c9..04aef165f 100644 --- a/.github/workflows/build-baseline.yml +++ b/.github/workflows/build-baseline.yml @@ -420,7 +420,7 @@ jobs: --output latest.json cp release-artifacts.txt release-assets.txt printf '%s\n' latest.json >> release-assets.txt - - name: Create draft release with complete assets, then publish + - name: Create draft release, re-verify hosted bytes, then publish env: GH_TOKEN: ${{ secrets.BANDSCOPE_RELEASE_TOKEN }} RELEASE_TAG: ${{ github.ref_name }} @@ -453,4 +453,25 @@ jobs: --title "BandScope ${RELEASE_TAG#v}" \ --verify-tag \ --repo "${{ github.repository }}" - gh release edit "$RELEASE_TAG" --draft=false --repo "${{ github.repository }}" \ No newline at end of file + + 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 From 3412a19eb9c2f90ce10fde2d374e3a8a74b91b8e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 00:22:27 +0900 Subject: [PATCH 069/308] test(release): format hosted re-verification coverage --- ...est_hosted_release_asset_reverification.py | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/services/analysis-engine/tests/test_hosted_release_asset_reverification.py b/services/analysis-engine/tests/test_hosted_release_asset_reverification.py index 7c2af75b0..1fb6970cc 100644 --- a/services/analysis-engine/tests/test_hosted_release_asset_reverification.py +++ b/services/analysis-engine/tests/test_hosted_release_asset_reverification.py @@ -38,7 +38,11 @@ def _fixture(tmp_path: Path) -> tuple[Path, Path, Path]: hosted_root = tmp_path / "hosted" local_root.mkdir() hosted_root.mkdir() - names = ["latest.json", "bandscope-windows-amd64.exe", "bandscope-windows-amd64.exe.sig"] + 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) @@ -49,7 +53,7 @@ def _fixture(tmp_path: Path) -> tuple[Path, Path, Path]: def test_hosted_verifier_accepts_exact_uploaded_asset_bytes(tmp_path: Path) -> None: - """Every hosted release asset must match the admitted local upload byte-for-byte.""" + """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) @@ -70,8 +74,10 @@ def test_hosted_verifier_rejects_signature_drift(tmp_path: Path) -> None: assert "digest" in completed.stderr.lower() -def test_hosted_verifier_rejects_missing_or_unexpected_assets(tmp_path: Path) -> None: - """Publication must neither drop admitted assets nor add unreviewed uploaded assets.""" +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") @@ -85,9 +91,12 @@ def test_hosted_verifier_rejects_missing_or_unexpected_assets(tmp_path: Path) -> def test_hosted_verifier_rejects_duplicate_or_nested_asset_list_members( tmp_path: Path, ) -> None: - """The expected publication set must be one unique basename per uploaded asset.""" + """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") + asset_list.write_text( + "latest.json\nlatest.json\n../escape.bin\n", + encoding="utf-8", + ) completed = _run_verifier(local_root, hosted_root, asset_list) @@ -96,7 +105,7 @@ def test_hosted_verifier_rejects_duplicate_or_nested_asset_list_members( def test_release_workflow_reverifies_draft_and_published_assets() -> None: - """Release publication must compare downloaded draft and final bytes to local authority.""" + """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 From 4c1dfe95bf059ba738ee81aac3d82f0758b90df9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 00:23:33 +0900 Subject: [PATCH 070/308] docs(release): trace hosted release byte re-verification --- docs/traceability/release-artifact-receipt.md | 43 ++++++++++++++----- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/docs/traceability/release-artifact-receipt.md b/docs/traceability/release-artifact-receipt.md index e47d09a34..0eb24110c 100644 --- a/docs/traceability/release-artifact-receipt.md +++ b/docs/traceability/release-artifact-receipt.md @@ -1,6 +1,6 @@ # Release artifact receipt traceability -BandScope의 Distribution/update bounded context는 설치 파일을 만들었다는 사실과 상용 릴리즈로 신뢰할 수 있다는 판단을 구분합니다. 이 문서는 `scripts/release/package_desktop_artifact.py`가 생성하는 target receipt, Tauri v2 updater artifact와 static manifest binding, 그리고 아직 해결되지 않은 publication/provenance 경계를 기록합니다. +BandScope의 Distribution/update bounded context는 설치 파일을 만들었다는 사실과 상용 릴리즈로 신뢰할 수 있다는 판단을 구분합니다. 이 문서는 `scripts/release/package_desktop_artifact.py`가 생성하는 target receipt, Tauri v2 updater artifact와 static manifest binding, hosted release byte re-verification, 그리고 아직 해결되지 않은 signing/rollback 경계를 기록합니다. ## 문제 @@ -10,13 +10,15 @@ BandScope의 Distribution/update bounded context는 설치 파일을 만들었 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를 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`은 Distribution package/publication evidence입니다. Project Persistence, Resource Admission 또는 Signal/MIR가 이 포맷을 복제하거나 source-audio/model scientific identity로 사용하지 않습니다. +- Target `release-receipt.json`, `latest.json`, hosted byte 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에서 임의로 생성하지 않습니다. ## 선택 @@ -43,7 +45,11 @@ Updater source와 copied output은 각각 안정된 regular-file descriptor에 `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으로 전달한 뒤에만 immutable release를 publish합니다. 현재 공개 `v0.1.3` release가 GitHub API에서 immutable release로 보고되는 repository publication model을 그대로 사용하며, manifest URL도 같은 exact-tag release namespace를 사용합니다. +`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 안에서 확인합니다. 이 단계는 GitHub의 release attestation 자체를 검증하는 것과는 구분됩니다. GitHub immutable-release attestation의 cryptographic verification은 이후 `gh release verify`/`gh release verify-asset`을 release gate에 결합하는 별도 강화 항목입니다. ### 기각한 대안 @@ -53,10 +59,12 @@ Updater source와 copied output은 각각 안정된 regular-file descriptor에 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를 이 함수에서 검증하지 않으므로 기각합니다. +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→publish를 수행합니다. +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로 먼저 둡니다. ## 실행 근거 @@ -81,24 +89,31 @@ Static updater-manifest slice: - 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를 정리했습니다. 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`은 **검증된 tag package bytes, copied updater bundle/signature bytes, exact source identity와 exact-tag download URL을 하나의 publication graph로 결합하는 local release evidence**입니다. Manifest가 receipt에 기록된 `.sig` bytes의 exact text를 싣는다는 것은 검증하지만, 그 signature가 아직 provision되지 않은 organization-approved updater public key로 cryptographically valid하다는 사실까지 증명하지 않습니다. +Target receipt, generated `latest.json`, draft/final hosted byte parity는 **검증된 tag package bytes, copied updater bundle/signature bytes, exact source identity, exact-tag download URL과 GitHub release asset namespace를 하나의 Distribution publication graph로 결합하는 evidence**입니다. Manifest가 receipt에 기록된 `.sig` bytes의 exact text를 싣고 uploaded/downloaded bytes가 일치한다는 것은 검증하지만, 그 signature가 아직 provision되지 않은 organization-approved updater public key로 cryptographically valid하다는 사실까지 증명하지 않습니다. -현재 updater policy는 의도적으로 `blocked`입니다. 승인된 public verification key와 production discovery endpoint가 provision되지 않았기 때문에 현재 source를 상용 updater authority가 준비된 상태라고 해석하지 않습니다. `latest.json` 생성 기능은 future admitted release에서 사용할 deterministic publication primitive이며, tag preflight는 blocked policy에서 계속 fail closed합니다. +현재 updater policy는 의도적으로 `blocked`입니다. 승인된 public verification key와 production discovery endpoint가 provision되지 않았기 때문에 현재 source를 상용 updater authority가 준비된 상태라고 해석하지 않습니다. `latest.json` 및 hosted re-verification은 future admitted release에서 사용할 deterministic publication primitive이며, tag preflight는 blocked policy에서 계속 fail closed합니다. 다음은 아직 별도 acceptance 대상입니다. -- receipt/manifest 자체의 authenticated provenance 또는 build-service non-forgeability; +- GitHub immutable-release attestation에 대한 `gh release verify` 및 각 local asset의 `gh release verify-asset` gate; - approved Tauri updater public-key provisioning 및 generated `.sig` cryptographic verification; -- immutable publication 뒤 hosted `latest.json`과 hosted bundle/signature bytes의 post-publish re-fetch/re-admission evidence; - 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와 receipt의 complete release-graph 결합; +- SBOM/provenance/NOTICE/model artifact와 release attestation의 complete release-graph 결합; - #770의 rights-cleared real-audio scientific acceptance; - #1181의 commercial model-rights 해결. @@ -106,12 +121,18 @@ Target receipt와 generated `latest.json`은 **검증된 tag package bytes, copi ## 다음 단계 -다음 Distribution causal slice는 **published release re-verification과 updater anti-replay/rollback contract**입니다. Draft asset set이 immutable publication으로 승격된 뒤 hosted `latest.json`, target updater bundle과 signature를 다시 읽어 local receipt/digest와 같은지 검증하는 evidence가 필요합니다. 그 다음 version monotonicity/stale metadata 거부, wrong key/signature/digest, truncated download, unsupported target, partial download/disk-full/cancel, offline startup, first-launch failure와 known-good rollback을 packaged-platform acceptance로 연결해야 합니다. +다음 Distribution causal slice는 **immutable release attestation + updater anti-replay/rollback contract**입니다. GitHub가 published immutable release에 대해 생성하는 cryptographically signed release attestation을 `gh release verify`로 확인하고, local admitted asset 각각을 `gh release verify-asset`으로 검증하여 자체 byte parity와 GitHub attestation을 결합해야 합니다. 그 다음 version monotonicity/stale metadata 거부, wrong key/signature/digest, truncated download, unsupported target, partial download/disk-full/cancel, offline startup, first-launch failure와 known-good rollback을 packaged-platform acceptance로 연결해야 합니다. 승인된 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 + 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/ From 0f13ef0e11612e8bd3e25ae5783d372b812b6c6d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 00:23:50 +0900 Subject: [PATCH 071/308] test(release): require immutable release attestation gate --- .../tests/test_hosted_release_asset_reverification.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/services/analysis-engine/tests/test_hosted_release_asset_reverification.py b/services/analysis-engine/tests/test_hosted_release_asset_reverification.py index 1fb6970cc..d56aebc26 100644 --- a/services/analysis-engine/tests/test_hosted_release_asset_reverification.py +++ b/services/analysis-engine/tests/test_hosted_release_asset_reverification.py @@ -113,3 +113,14 @@ def test_release_workflow_reverifies_draft_and_published_assets() -> None: 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) From b65f00cd607df30f9807894368667b92e2911e38 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 00:24:41 +0900 Subject: [PATCH 072/308] fix(release): verify immutable release attestation --- .github/workflows/build-baseline.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/build-baseline.yml b/.github/workflows/build-baseline.yml index 04aef165f..1458381d8 100644 --- a/.github/workflows/build-baseline.yml +++ b/.github/workflows/build-baseline.yml @@ -475,3 +475,10 @@ jobs: --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 From d6cc359b5386ac1ee13be2790db648c23f4c6e52 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 00:25:32 +0900 Subject: [PATCH 073/308] docs(release): trace immutable release attestation gate --- docs/traceability/release-artifact-receipt.md | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/traceability/release-artifact-receipt.md b/docs/traceability/release-artifact-receipt.md index 0eb24110c..8fc02b269 100644 --- a/docs/traceability/release-artifact-receipt.md +++ b/docs/traceability/release-artifact-receipt.md @@ -1,6 +1,6 @@ # 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 re-verification, 그리고 아직 해결되지 않은 signing/rollback 경계를 기록합니다. +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 경계를 기록합니다. ## 문제 @@ -10,7 +10,7 @@ BandScope의 Distribution/update bounded context는 설치 파일을 만들었 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를 publication 전후에 다시 맞추는 단계가 필요합니다. +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 경계에서 결합해야 합니다. ## 제약과 소유권 @@ -18,7 +18,7 @@ Manifest를 같은 draft release asset set에 포함시킨 뒤에도 마지막 p - 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 re-verification은 Distribution package/publication evidence입니다. Project Persistence, Resource Admission 또는 Signal/MIR가 이 포맷을 복제하거나 source-audio/model scientific identity로 사용하지 않습니다. +- 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에서 임의로 생성하지 않습니다. ## 선택 @@ -49,7 +49,9 @@ Updater source와 copied output은 각각 안정된 regular-file descriptor에 `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 안에서 확인합니다. 이 단계는 GitHub의 release attestation 자체를 검증하는 것과는 구분됩니다. GitHub immutable-release attestation의 cryptographic verification은 이후 `gh release verify`/`gh release verify-asset`을 release gate에 결합하는 별도 강화 항목입니다. +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를 추가합니다. ### 기각한 대안 @@ -65,6 +67,7 @@ Draft hosted bytes가 local admitted bytes와 일치한 뒤에만 release를 pub 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을 추가합니다. ## 실행 근거 @@ -97,18 +100,19 @@ Hosted publication re-verification slice: - 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는 **검증된 tag package bytes, copied updater bundle/signature bytes, exact source identity, exact-tag download URL과 GitHub release asset namespace를 하나의 Distribution publication graph로 결합하는 evidence**입니다. Manifest가 receipt에 기록된 `.sig` bytes의 exact text를 싣고 uploaded/downloaded bytes가 일치한다는 것은 검증하지만, 그 signature가 아직 provision되지 않은 organization-approved updater public key로 cryptographically valid하다는 사실까지 증명하지 않습니다. +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은 future admitted release에서 사용할 deterministic publication primitive이며, tag preflight는 blocked policy에서 계속 fail closed합니다. +현재 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 대상입니다. -- GitHub immutable-release attestation에 대한 `gh release verify` 및 각 local asset의 `gh release verify-asset` gate; - 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; @@ -121,7 +125,7 @@ Target receipt, generated `latest.json`, draft/final hosted byte parity는 **검 ## 다음 단계 -다음 Distribution causal slice는 **immutable release attestation + updater anti-replay/rollback contract**입니다. GitHub가 published immutable release에 대해 생성하는 cryptographically signed release attestation을 `gh release verify`로 확인하고, local admitted asset 각각을 `gh release verify-asset`으로 검증하여 자체 byte parity와 GitHub attestation을 결합해야 합니다. 그 다음 version monotonicity/stale metadata 거부, wrong key/signature/digest, truncated download, unsupported target, partial download/disk-full/cancel, offline startup, first-launch failure와 known-good rollback을 packaged-platform acceptance로 연결해야 합니다. +다음 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 과정에서 임의 생성하지 않습니다. @@ -133,6 +137,10 @@ GitHub. (2026). *Verifying the integrity of a release*. https://docs.github.com/ 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/ From 57ced89e61529a984010c8b351fef54cac543b6f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 01:06:32 +0900 Subject: [PATCH 074/308] test(release): require updater manifest security metadata --- .../test_updater_manifest_publication.py | 42 +++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/services/analysis-engine/tests/test_updater_manifest_publication.py b/services/analysis-engine/tests/test_updater_manifest_publication.py index 65c94f6a8..382a076b0 100644 --- a/services/analysis-engine/tests/test_updater_manifest_publication.py +++ b/services/analysis-engine/tests/test_updater_manifest_publication.py @@ -23,16 +23,35 @@ def _digest(payload: bytes) -> str: return hashlib.sha256(payload).hexdigest() -def _write_release_graph(repo_root: Path, *, source_commit: str) -> dict[str, str]: +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}" @@ -65,6 +84,10 @@ def _write_release_graph(repo_root: Path, *, source_commit: str) -> dict[str, st 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, @@ -102,7 +125,7 @@ def _write_release_graph(repo_root: Path, *, source_commit: str) -> dict[str, st (artifacts / receipt_name).write_text( json.dumps(receipt), encoding="utf-8" ) - return signatures + return signatures, updater_identities def _run_builder( @@ -130,7 +153,9 @@ def _run_builder( 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 = _write_release_graph(tmp_path, source_commit=source_commit) + signatures, updater_identities = _write_release_graph( + tmp_path, source_commit=source_commit + ) completed = _run_builder(tmp_path, source_commit=source_commit) @@ -155,6 +180,17 @@ def test_manifest_binds_exact_receipts_and_signature_contents(tmp_path: Path) -> "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_check_rejects_post_generation_signature_drift( From 21ee4ff52789659cbf70db33c24d9e925fb8d4f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 01:07:11 +0900 Subject: [PATCH 075/308] fix(release): bind updater security metadata to receipts --- scripts/release/build_updater_manifest.py | 92 ++++++++++++++++++++++- 1 file changed, 89 insertions(+), 3 deletions(-) diff --git a/scripts/release/build_updater_manifest.py b/scripts/release/build_updater_manifest.py index 55ce1b90d..0b0d3d8f7 100644 --- a/scripts/release/build_updater_manifest.py +++ b/scripts/release/build_updater_manifest.py @@ -7,9 +7,13 @@ ``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 - and an exact size/SHA-256 comparison against the target receipt. Release - URLs are exact-tag HTTPS URLs; no mutable latest URL or untrusted receipt - path is used as a filesystem authority. + and an exact size/SHA-256 comparison against the target receipt. 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 a filesystem authority. """ from __future__ import annotations @@ -28,9 +32,19 @@ 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", @@ -39,6 +53,16 @@ } +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(): @@ -95,6 +119,34 @@ def _version(repo_root: Path) -> str: 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()) @@ -150,6 +202,27 @@ def _exact_updater_entry( 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, *, @@ -207,13 +280,17 @@ def build_manifest( 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: @@ -231,10 +308,19 @@ def build_manifest( "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, + }, } From 209f58e7e647df836be50fa31c90a751829cca38 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 01:07:53 +0900 Subject: [PATCH 076/308] docs(release): trace updater security metadata boundary --- .../traceability/updater-security-metadata.md | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 docs/traceability/updater-security-metadata.md diff --git a/docs/traceability/updater-security-metadata.md b/docs/traceability/updater-security-metadata.md new file mode 100644 index 000000000..bfcad8b19 --- /dev/null +++ b/docs/traceability/updater-security-metadata.md @@ -0,0 +1,58 @@ +# Updater security metadata traceability + +BandScope의 Distribution/update bounded context는 Tauri가 설치에 필요한 최소 정적 manifest와 BandScope가 재생·프로젝트 안전성 판단에 필요한 보안 메타데이터를 구분합니다. `latest.json`은 Tauri의 `version`, target별 `url`·`signature`를 유지하면서, exact release receipt에서 파생한 `bandscope` 확장 메타데이터를 함께 싣습니다. + +## 문제 + +기존 `build_updater_manifest.py`는 exact tag URL과 `.sig` 내용을 receipt에 묶었지만, #960이 요구하는 updater artifact digest와 `minimumSupportedVersion`은 manifest에 없었습니다. 따라서 향후 desktop runtime이 Tauri의 `Update.raw_json`을 사용해 replay/rollback 또는 compatibility 결정을 추가하더라도, 어떤 source commit과 어떤 updater bundle bytes가 제시됐는지 manifest 자체에서 확인할 수 없었습니다. + +이 문제는 Tauri signature 검증과 별개입니다. Tauri updater는 update artifact signature 검증을 비활성화할 수 없고, static JSON에는 `version`, target별 `url`, `signature`가 필요합니다. 동시에 현재 Tauri API는 updater response의 원본 JSON을 `Update.raw_json`으로 보존하므로 제품별 추가 필드를 별도 updater protocol을 만들지 않고 소비할 수 있습니다. + +## 구현 + +`build_updater_manifest.py`는 release graph를 `select_release_assets.py`로 다시 admit한 뒤 각 target receipt의 updater entry에서 다음 값을 `bandscope` 객체에 기록합니다. + +- `schemaVersion: 1` +- exact 40-hex `sourceCommit` +- `release/updater-policy.json`의 `minimumSupportedVersion` +- Windows amd64/arm64, macOS amd64/arm64 각각의 exact updater bundle `sizeBytes`와 full SHA-256 + +정책 파일은 고정 repository-relative path에서 최대 64 KiB regular non-link file로 읽고, descriptor identity drift와 duplicate JSON member를 거부합니다. `minimumSupportedVersion`은 SemVer 형태를 다시 확인합니다. Receipt의 `sourceCommit`이 요청된 exact release commit과 다르면 manifest 생성을 중단합니다. 각 bundle size/digest도 positive integer와 full lowercase SHA-256 계약을 만족해야 합니다. + +TDD lineage: + +- RED `57ced89e61529a984010c8b351fef54cac543b6f`: static manifest가 exact source commit, policy version floor, target별 updater bundle size/full SHA-256을 노출해야 한다는 실행 계약을 추가했습니다. +- Fix `21ee4ff52789659cbf70db33c24d9e925fb8d4f1`: receipt/policy-derived `bandscope` metadata를 deterministic manifest에 결합하고 malformed policy·receipt identity를 fail closed 처리했습니다. + +## 보안 경계 + +이 메타데이터는 artifact identity와 향후 anti-replay 판단의 입력이지, 독립적인 서명 권위가 아닙니다. Tauri의 `.sig`는 updater bundle을 검증하고, GitHub immutable-release attestation은 published release asset 집합을 검증합니다. `bandscope` JSON 필드만 보고 signature validity나 repository compromise resilience를 주장하지 않습니다. + +현재 `release/updater-policy.json`은 updater public key와 production endpoint가 provision되지 않아 `blocked`입니다. 따라서 이 slice는 updater를 활성화하지 않고, private key·public key·endpoint를 만들거나 추측하지 않습니다. 실제 runtime anti-replay는 승인된 updater authority가 생긴 뒤 Tauri의 기본 SemVer 비교를 약화하지 않은 채 `Update.raw_json`의 exact metadata와 locally persisted highest-seen evidence를 결합해야 합니다. + +TUF가 정의하는 rollback/freeze 계열 공격까지 완전히 방어했다고 주장하지 않습니다. TUF의 freshness/rollback 모델은 서명된 metadata version과 expiry를 포함하는 더 강한 repository metadata protocol입니다. BandScope는 현재 Tauri signed-artifact updater 위에 제품별 evidence를 추가하는 단계이며, TUF와 동등한 metadata security model을 구현한 상태가 아닙니다. + +## 다음 단계 + +Repository-owned 다음 slice는 실제 runtime decision/state machine입니다. 최소한 다음을 별도 RED로 요구합니다. + +- 현재 설치 버전보다 낮은 SemVer를 자동 설치하지 않음 +- 사용자가 이전에 관측한 highest-seen release보다 낮은 metadata를 replay로 거부 +- target/architecture mismatch 및 malformed/truncated metadata 거부 +- offline update-check 실패가 일반 startup을 막지 않음 +- failed/cancelled install 뒤 last-known-good installer와 project data를 보존 +- rollback target이 현재 on-disk project schema를 읽을 수 없으면 자동 downgrade 금지 + +`allowDowngrades` 또는 custom version comparator를 사용해 Tauri의 기본 버전 비교를 우회하는 방식은 recovery 설계가 끝나기 전에는 채택하지 않습니다. + +## Security Notes + +Attack surface는 remote updater metadata, release receipts, updater bundle identity와 이후 runtime update decision입니다. Distribution이 manifest publication과 update trust를 소유하며 Active Player, MIR, Project Persistence는 해당 권위를 복제하지 않습니다. 입력은 fixed-path/bounded/stable-descriptor admission과 exact digest로 제한하고 오류는 release publication 실패로 처리합니다. 원본 audio/project payload는 manifest에 포함하거나 update 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/ + +The Update Framework. (2026). *The Update Framework specification and security model*. https://theupdateframework.github.io/ From 857e1e9324e29f896d5bad6216631bd723fd1f65 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 01:08:43 +0900 Subject: [PATCH 077/308] test(release): cover updater policy metadata admission --- .../test_updater_manifest_publication.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/services/analysis-engine/tests/test_updater_manifest_publication.py b/services/analysis-engine/tests/test_updater_manifest_publication.py index 382a076b0..c03ff400f 100644 --- a/services/analysis-engine/tests/test_updater_manifest_publication.py +++ b/services/analysis-engine/tests/test_updater_manifest_publication.py @@ -234,6 +234,40 @@ def test_manifest_rejects_ambiguous_updater_bundle_for_one_target( 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") From 4467a9e80b3fa7e7e7a95cb1ff7606749606b3d0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 01:11:56 +0900 Subject: [PATCH 078/308] test(distribution): require Rust updater lifecycle core --- .../tests/test_distribution_update_core.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 services/analysis-engine/tests/test_distribution_update_core.py 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..8ebe6446a --- /dev/null +++ b/services/analysis-engine/tests/test_distribution_update_core.py @@ -0,0 +1,30 @@ +"""Native contract gate for the Distribution updater decision core.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_MANIFEST = _REPO_ROOT / "apps" / "desktop" / "distribution-core" / "Cargo.toml" + + +def test_distribution_update_core_native_suite_is_green() -> None: + """Run the Rust anti-replay/rollback contract with its own locked graph.""" + 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, completed.stdout + completed.stderr From 1339cfd44aef17743a770449651ee9a233baf4e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 01:13:20 +0900 Subject: [PATCH 079/308] feat(distribution): add updater lifecycle Rust core crate --- apps/desktop/distribution-core/Cargo.toml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 apps/desktop/distribution-core/Cargo.toml diff --git a/apps/desktop/distribution-core/Cargo.toml b/apps/desktop/distribution-core/Cargo.toml new file mode 100644 index 000000000..2fcbd09a9 --- /dev/null +++ b/apps/desktop/distribution-core/Cargo.toml @@ -0,0 +1,11 @@ +[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" From 0fbb2e8a5396e5b5b123704537e0c4b9719a92e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 01:13:28 +0900 Subject: [PATCH 080/308] build(distribution): lock updater lifecycle core --- apps/desktop/distribution-core/Cargo.lock | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 apps/desktop/distribution-core/Cargo.lock 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" From 42fdeed9a1ddf57d807889e61dee864b931b62a2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 01:14:17 +0900 Subject: [PATCH 081/308] fix(distribution): enforce updater anti-replay and rollback decisions --- apps/desktop/distribution-core/src/lib.rs | 562 ++++++++++++++++++++++ 1 file changed, 562 insertions(+) create mode 100644 apps/desktop/distribution-core/src/lib.rs 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) + ); + } +} From cd25fab5f40f6379f5b2c25017510f4b6f3378cf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 01:15:24 +0900 Subject: [PATCH 082/308] docs(distribution): trace anti-replay rollback decision core --- .../traceability/updater-security-metadata.md | 60 +++++++++++++------ 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/docs/traceability/updater-security-metadata.md b/docs/traceability/updater-security-metadata.md index bfcad8b19..9d4234838 100644 --- a/docs/traceability/updater-security-metadata.md +++ b/docs/traceability/updater-security-metadata.md @@ -4,11 +4,11 @@ BandScope의 Distribution/update bounded context는 Tauri가 설치에 필요한 ## 문제 -기존 `build_updater_manifest.py`는 exact tag URL과 `.sig` 내용을 receipt에 묶었지만, #960이 요구하는 updater artifact digest와 `minimumSupportedVersion`은 manifest에 없었습니다. 따라서 향후 desktop runtime이 Tauri의 `Update.raw_json`을 사용해 replay/rollback 또는 compatibility 결정을 추가하더라도, 어떤 source commit과 어떤 updater bundle bytes가 제시됐는지 manifest 자체에서 확인할 수 없었습니다. +기존 `build_updater_manifest.py`는 exact tag URL과 `.sig` 내용을 receipt에 묶었지만, #960이 요구하는 updater artifact digest와 `minimumSupportedVersion`은 manifest에 없었습니다. 따라서 desktop runtime이 Tauri의 `Update.raw_json`을 사용해 replay/rollback 또는 compatibility 결정을 추가하더라도, 어떤 source commit과 어떤 updater bundle bytes가 제시됐는지 manifest 자체에서 확인할 수 없었습니다. -이 문제는 Tauri signature 검증과 별개입니다. Tauri updater는 update artifact signature 검증을 비활성화할 수 없고, static JSON에는 `version`, target별 `url`, `signature`가 필요합니다. 동시에 현재 Tauri API는 updater response의 원본 JSON을 `Update.raw_json`으로 보존하므로 제품별 추가 필드를 별도 updater protocol을 만들지 않고 소비할 수 있습니다. +이 문제는 Tauri signature 검증과 별개입니다. Tauri updater는 update artifact signature 검증을 비활성화할 수 없고, static JSON에는 `version`, target별 `url`, `signature`가 필요합니다. 현재 Tauri API는 updater response의 원본 JSON을 `Update.raw_json`으로 보존하므로 제품별 추가 필드를 별도 updater protocol을 만들지 않고 소비할 수 있습니다. -## 구현 +## Manifest evidence `build_updater_manifest.py`는 release graph를 `select_release_assets.py`로 다시 admit한 뒤 각 target receipt의 updater entry에서 다음 값을 `bandscope` 객체에 기록합니다. @@ -19,35 +19,61 @@ BandScope의 Distribution/update bounded context는 Tauri가 설치에 필요한 정책 파일은 고정 repository-relative path에서 최대 64 KiB regular non-link file로 읽고, descriptor identity drift와 duplicate JSON member를 거부합니다. `minimumSupportedVersion`은 SemVer 형태를 다시 확인합니다. Receipt의 `sourceCommit`이 요청된 exact release commit과 다르면 manifest 생성을 중단합니다. 각 bundle size/digest도 positive integer와 full lowercase SHA-256 계약을 만족해야 합니다. -TDD lineage: +Manifest TDD lineage: - RED `57ced89e61529a984010c8b351fef54cac543b6f`: static manifest가 exact source commit, policy version floor, target별 updater bundle size/full SHA-256을 노출해야 한다는 실행 계약을 추가했습니다. - Fix `21ee4ff52789659cbf70db33c24d9e925fb8d4f1`: receipt/policy-derived `bandscope` metadata를 deterministic manifest에 결합하고 malformed policy·receipt identity를 fail closed 처리했습니다. +- Edge coverage `857e1e9324e29f896d5bad6216631bd723fd1f65`: duplicate policy authority와 non-canonical minimum version을 거부하도록 고정했습니다. -## 보안 경계 +## Rust anti-replay / rollback decision core -이 메타데이터는 artifact identity와 향후 anti-replay 판단의 입력이지, 독립적인 서명 권위가 아닙니다. Tauri의 `.sig`는 updater bundle을 검증하고, GitHub immutable-release attestation은 published release asset 집합을 검증합니다. `bandscope` JSON 필드만 보고 signature validity나 repository compromise resilience를 주장하지 않습니다. +Updater key와 production endpoint가 아직 provision되지 않았다고 해서 replay/rollback 정책 자체를 미룰 이유는 없습니다. 네트워크·Tauri·installer I/O에서 분리된 Rust-first policy core를 `apps/desktop/distribution-core`에 두고, 외부 authority가 생긴 뒤 runtime이 이 계약을 소비하도록 했습니다. Python은 repository CI에서 독립 Rust suite를 실행하는 validation boundary만 담당합니다. -현재 `release/updater-policy.json`은 updater public key와 production endpoint가 provision되지 않아 `blocked`입니다. 따라서 이 slice는 updater를 활성화하지 않고, private key·public key·endpoint를 만들거나 추측하지 않습니다. 실제 runtime anti-replay는 승인된 updater authority가 생긴 뒤 Tauri의 기본 SemVer 비교를 약화하지 않은 채 `Update.raw_json`의 exact metadata와 locally persisted highest-seen evidence를 결합해야 합니다. +`bandscope-distribution-core`는 다음을 fail closed로 결정합니다. -TUF가 정의하는 rollback/freeze 계열 공격까지 완전히 방어했다고 주장하지 않습니다. TUF의 freshness/rollback 모델은 서명된 metadata version과 expiry를 포함하는 더 강한 repository metadata protocol입니다. BandScope는 현재 Tauri signed-artifact updater 위에 제품별 evidence를 추가하는 단계이며, TUF와 동등한 metadata security model을 구현한 상태가 아닙니다. +- stable channel version은 canonical numeric `MAJOR.MINOR.PATCH`만 허용합니다. `v` prefix, leading zero, prerelease/build metadata, whitespace와 numeric overflow를 거부합니다. Beta/prerelease channel이 필요하면 full SemVer 구현을 임의로 확장하지 않고 별도 ADR과 canonical parser를 도입해야 합니다. +- release identity는 exact 40 lowercase-hex source commit과 exact 64 lowercase-hex updater SHA-256을 요구합니다. +- target token은 bounded safe ASCII로 제한하고 현재 desktop target과 exact match해야 합니다. +- 현재 설치 버전보다 낮은 candidate는 `Rollback`, locally persisted highest-seen release보다 낮은 candidate는 `Replay`로 거부합니다. +- 동일 version이 다른 source commit 또는 artifact digest로 다시 나타나면 `Equivocation`으로 거부합니다. +- 동일한 highest-seen release를 사용자가 이전에 설치하지 않았거나 연기했더라도 재제안은 허용하되, 새 release처럼 freshness를 다시 부여하지 않습니다. +- 현재 client가 release의 `minimumSupportedVersion`보다 낮으면 automatic path를 거부하여 별도 recovery/manual upgrade 경로로 보냅니다. +- last-known-good rollback은 target version이 현재 설치본보다 실제로 오래되고, 해당 build가 현재 on-disk project schema를 읽을 수 있는 경우에만 허용합니다. -## 다음 단계 +Highest-seen identity는 설치 완료 시점이 아니라 signature/metadata admission이 성공해 release를 신뢰한 시점에 Distribution-owned durable state로 기록해야 합니다. 사용자가 설치를 미뤘다는 이유로 같은 공격자-controlled metadata가 다시 fresh해지면 replay 방어가 성립하지 않기 때문입니다. Project Persistence는 project bytes/schema truth만 제공하며 updater freshness state를 소유하지 않습니다. -Repository-owned 다음 slice는 실제 runtime decision/state machine입니다. 최소한 다음을 별도 RED로 요구합니다. +Runtime-core lineage: -- 현재 설치 버전보다 낮은 SemVer를 자동 설치하지 않음 -- 사용자가 이전에 관측한 highest-seen release보다 낮은 metadata를 replay로 거부 -- target/architecture mismatch 및 malformed/truncated metadata 거부 +- RED `4467a9e80b3fa7e7e7a95cb1ff7606749606b3d0`: repository CI가 독립 Rust Distribution suite를 `--locked --all-targets`로 실행하도록 요구했습니다. +- Crate/lock foundation `1339cfd44aef17743a770449651ee9a233baf4e5` / `0fbb2e8a5396e5b5b123704537e0c4b9719a92e3`: 다른 desktop bounded context나 Tauri/WebView에 의존하지 않는 standalone Rust core를 만들었습니다. +- Causal fix `42fdeed9a1ddf57d807889e61dee864b931b62a2`: version monotonicity, highest-seen replay/equivocation, target, compatibility floor와 project-schema-aware known-good rollback decision을 구현하고 hostile edge cases를 native unit test로 고정했습니다. + +이 core는 아직 network check/install을 실행하지 않습니다. 현재 updater authority가 `blocked`인 상태에서 fake endpoint/key를 넣어 runtime을 강제로 활성화하는 것보다, pure decision contract를 먼저 고정하고 실제 authority provision 후 Tauri `Update.raw_json` + authenticated artifact flow에 연결하는 편이 신뢰 경계를 보존합니다. + +## 보안 경계와 기각한 대안 + +이 메타데이터와 Rust decision core는 artifact identity와 anti-replay 판단의 입력이지 독립적인 서명 권위가 아닙니다. Tauri의 `.sig`는 updater bundle을 검증하고, GitHub immutable-release attestation은 published release asset 집합을 검증합니다. `bandscope` JSON 필드만 보고 signature validity나 repository compromise resilience를 주장하지 않습니다. + +현재 `release/updater-policy.json`은 updater public key와 production endpoint가 provision되지 않아 `blocked`입니다. private key·public key·endpoint를 source에서 만들거나 추측하지 않습니다. `allowDowngrades` 또는 custom version comparator로 Tauri의 기본 forward version semantics를 약화하는 것도 채택하지 않았습니다. + +TUF가 정의하는 rollback/freeze 계열 공격까지 완전히 방어했다고 주장하지 않습니다. TUF의 freshness/rollback 모델은 서명된 metadata version과 expiry를 포함하는 더 강한 repository metadata protocol입니다. BandScope는 현재 Tauri signed-artifact updater 위에 product-specific immutable evidence와 local highest-seen policy를 추가하는 단계이며, TUF와 동등한 metadata security model을 구현한 상태가 아닙니다. + +## 남은 runtime integration + +Repository-owned 다음 단계는 승인된 updater authority가 provision되었을 때 Tauri runtime과 durable Distribution state를 이 pure core에 연결하는 것입니다. 그 acceptance는 최소한 다음을 요구합니다. + +- authenticated `Update.raw_json`에서 exact `bandscope` schema/target/artifact identity를 bounded parsing한 뒤 core에 전달 +- highest-seen release identity의 crash-safe app-owned persistence 및 reload - offline update-check 실패가 일반 startup을 막지 않음 -- failed/cancelled install 뒤 last-known-good installer와 project data를 보존 -- rollback target이 현재 on-disk project schema를 읽을 수 없으면 자동 downgrade 금지 +- truncated/partial download, disk-full, cancel, first-launch failure 뒤 current installation과 project data 보존 +- last-known-good installer retention 및 실제 rollback 전 project-schema compatibility 확인 +- 잘못된 key/signature/digest, unsupported target, replay/stale metadata에 대한 packaged Windows/macOS acceptance -`allowDowngrades` 또는 custom version comparator를 사용해 Tauri의 기본 버전 비교를 우회하는 방식은 recovery 설계가 끝나기 전에는 채택하지 않습니다. +Positive production signature acceptance는 organization-approved updater public key/endpoint가 provision된 뒤에만 수행합니다. ## Security Notes -Attack surface는 remote updater metadata, release receipts, updater bundle identity와 이후 runtime update decision입니다. Distribution이 manifest publication과 update trust를 소유하며 Active Player, MIR, Project Persistence는 해당 권위를 복제하지 않습니다. 입력은 fixed-path/bounded/stable-descriptor admission과 exact digest로 제한하고 오류는 release publication 실패로 처리합니다. 원본 audio/project payload는 manifest에 포함하거나 update endpoint로 전송하지 않습니다. +Attack surface는 remote updater metadata, release receipts, updater bundle identity, locally persisted freshness state와 recovery decision입니다. Distribution이 manifest publication과 update trust를 소유하며 Active Player, MIR, Project Persistence는 해당 권위를 복제하지 않습니다. 입력은 fixed-path/bounded/stable-descriptor admission과 exact digest로 제한하고 malformed authority는 fail closed 처리합니다. 원본 audio/project payload는 manifest나 updater state에 포함하거나 update endpoint로 전송하지 않습니다. ## 참고문헌 From d80a4775f5be719b39eab85a1e78094b8f43c160 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 01:16:20 +0900 Subject: [PATCH 083/308] chore(distribution): deny warnings and missing rustdoc --- apps/desktop/distribution-core/Cargo.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/distribution-core/Cargo.toml b/apps/desktop/distribution-core/Cargo.toml index 2fcbd09a9..4bad8e1c3 100644 --- a/apps/desktop/distribution-core/Cargo.toml +++ b/apps/desktop/distribution-core/Cargo.toml @@ -9,3 +9,5 @@ publish = false [lints.rust] unsafe_code = "forbid" +warnings = "deny" +missing_docs = "deny" From a0a9eecc977375cf540a3bc04cce82292c6a583c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 01:18:17 +0900 Subject: [PATCH 084/308] docs(architecture): register Distribution update decision core --- ARCHITECTURE.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca0df5ac4..4f29444fe 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,24 @@ 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 - `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, 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. +- The updater runtime must authenticate Tauri metadata and artifact signatures before projecting exact `version`, `sourceCommit`, updater SHA-256, target, and compatibility floor into the Rust decision core. +- 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 after metadata/signature admission, not only after installation. 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`, and `docs/traceability/updater-security-metadata.md`. + ## Product capability scope - BandScope is not only a shell around chord labels, stems, and ranges. @@ -96,10 +109,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 security-core Rust compilation denies warnings and missing public rustdoc; its standalone locked unit suite is 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. From 9df9e61592c006f7bfb94008c94427ef40a1bc0c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 01:18:54 +0900 Subject: [PATCH 085/308] docs(product): establish commercial technical gap baseline --- docs/product-technical-gap-baseline.md | 51 ++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 docs/product-technical-gap-baseline.md diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md new file mode 100644 index 000000000..7097ea1f7 --- /dev/null +++ b/docs/product-technical-gap-baseline.md @@ -0,0 +1,51 @@ +# BandScope product / technical gap baseline + +Status: commercial-development baseline, 2026-09-15. This document is a buyer-facing gap register, not a completion claim. Live protected branches, PR/Issue state, executable tests, release receipts and platform evidence remain authoritative when they are more specific. + +## Product truth + +BandScope is a local-first rehearsal decision tool. A buyer should be able to admit a real audio source, derive reproducible MIR evidence, turn it into section/role rehearsal decisions, rehearse against audible source material, save and recover the project safely, and install a verifiable update without losing project usability. A synthetic array, mock player, unsigned package, mutable model dependency, or documentation-only workflow cannot satisfy those claims. + +The product keeps BandScope-specific audio/rehearsal truth inside BandScope. Organization-wide identity, orchestration, graph, sandbox, egress, policy and other CWL foundation capabilities are consumed only through released contracts when needed; their source is not copied into this repository. + +## Bounded-context gap register + +| Bounded context | Current buyer truth | Commercial gap / acceptance boundary | +| --- | --- | --- | +| Audio Ingestion | Local file and YouTube intake have narrow validation paths; project bootstrap is local-first. | Rights-cleared real-audio fixtures must prove supported decode/admission on packaged Windows/macOS, including corrupt/truncated/oversized/link/path edge cases. | +| Resource Admission & Decode | Source identity and admission are separate from derived cache/persistence authority. | The protected integration must preserve one source-admission owner and prove decoder/license/provenance behavior on real files. | +| Signal / MIR Analysis | Rehearsal analysis and stem separation have scientific-generation/cache identity work in flight. | Rights-cleared real decoded audio, recognized MIR metrics, uncertainty boundaries, exact implementation/model generation, full model provenance/rights and reproducible CPU reference evidence remain release gates. | +| Rehearsal Insight | Section/role contracts carry rehearsal-facing cues, confidence and export semantics. | Buyer acceptance still needs real-audio evidence that recommendations remain directionally correct, explainable and stable across supported platforms. | +| Active Player | Desktop UI has a player surface but commercial acceptance is not complete. | Actual decoded audio must remain audible and synchronized across seek/range/section selection, reload and stale-source races; pointer/touch/keyboard and screen-reader alternatives require current-head E2E evidence. | +| Project Persistence | Project/cache integrity and scientific cache equivalence have dedicated owner work. | Crash/power-loss, disk-full, interrupted write/recovery, last-known-good project state and packaged-OS fault injection remain buyer gates. | +| Collaboration Handoff | Export/handoff belongs to BandScope without creating a second collaboration platform. | Only released, bounded artifacts should cross product boundaries; mutable shared DB or cross-service SQL is not accepted. | +| Diagnostics | Existing harness/security/build evidence is substantial. | Buyer-safe diagnostics must avoid audio/project/credential leakage and distinguish user cancel, provider/runtime failure, corrupt project and release/update failure. | +| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. The static manifest carries exact source commit, per-target updater digest/size and compatibility floor. A Rust Distribution core now defines forward-version, replay/equivocation, target and project-schema-aware rollback decisions. | Production updater authority is intentionally blocked until an organization-approved public key and production endpoint exist. Runtime wiring still needs crash-safe highest-seen state, offline-safe check behavior, partial/disk-full/cancel/first-launch recovery and packaged wrong-key/signature/digest/replay acceptance. Windows/macOS signing/notarization authority and commercial model rights are external prerequisites. | +| UI / Interaction | Rehearsal-first UI is the product surface; Anti-Slop and accessibility are acceptance criteria, not decoration. | Normal/loading/empty/error/permission/responsive states, KO/EN/JA/ZH/VI/ES/DE/FR expansion/fallback, keyboard/focus/contrast/state semantics and actual-audio E2E must be verified on the exact release candidate. | + +## Distribution/update decision boundary + +The Distribution updater path uses three different evidence classes and must not collapse them into one claim. + +1. Tauri updater signatures authenticate updater artifacts under an organization-approved updater key. +2. BandScope release receipts and `bandscope` updater metadata bind exact version, source commit, target, artifact byte size/full SHA-256 and minimum supported version. +3. GitHub immutable-release verification provides hosted publication evidence for the published asset set. + +The Rust `apps/desktop/distribution-core` is the deterministic decision layer after authentication. It rejects malformed stable versions, target mismatch, downgrade candidates, metadata older than the locally highest authenticated release, same-version release-identity equivocation and rollback to a build that cannot read the current project schema. It does not fetch, install, sign, notarize, parse arbitrary remote JSON, or write project data. + +Highest-seen update identity is Distribution state, not Project Persistence state. It should be persisted after authenticated metadata is observed even when installation is deferred, otherwise an attacker can make previously observed old metadata look fresh after restart. Project Persistence remains authoritative only for the project/schema evidence used by rollback compatibility checks. + +## Release gate + +A release candidate is not commercial-ready until all of the following are true on the exact protected head: required checks and independent review are terminal/qualifying; Windows artifacts are signed by the approved publisher and macOS artifacts are signed/notarized/stapled; updater authority is admitted without placeholder values; updater replay/rollback/recovery is exercised on packaged targets; SBOM/NOTICE/provenance agree with exact shipped bytes; model rights and exact model provenance are established; rights-cleared real-audio scientific acceptance is reproducible; the updater can recover to a compatible known-good build without losing project usability; and material UI passes actual-audio, responsive, locale and accessibility E2E. + +Until those conditions are satisfied, Draft/open PRs and blocked release policies are expected safety states rather than reasons to bypass gates. + +## Evidence links + +- Distribution admission: `docs/traceability/updater-release-admission.md` +- Release receipt/publication: `docs/traceability/release-artifact-receipt.md` +- Updater security metadata and replay/rollback model: `docs/traceability/updater-security-metadata.md` +- Security trust boundaries: `docs/security/app-security.md` +- Cross-platform release controls: `docs/security/cross-platform-build-policy.md` +- Architecture ownership: `ARCHITECTURE.md` From fa5690b53b776ef9d5b31b13b0285de384a53aaf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 02:00:52 +0900 Subject: [PATCH 086/308] test(distribution): require durable highest-seen state suite --- .../tests/test_distribution_update_core.py | 48 +++++++++++-------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/services/analysis-engine/tests/test_distribution_update_core.py b/services/analysis-engine/tests/test_distribution_update_core.py index 8ebe6446a..29ff1bb2d 100644 --- a/services/analysis-engine/tests/test_distribution_update_core.py +++ b/services/analysis-engine/tests/test_distribution_update_core.py @@ -1,4 +1,4 @@ -"""Native contract gate for the Distribution updater decision core.""" +"""Native contract gates for BandScope Distribution/update Rust boundaries.""" from __future__ import annotations @@ -6,25 +6,33 @@ from pathlib import Path _REPO_ROOT = Path(__file__).resolve().parents[3] -_MANIFEST = _REPO_ROOT / "apps" / "desktop" / "distribution-core" / "Cargo.toml" +_MANIFESTS = ( + _REPO_ROOT / "apps" / "desktop" / "distribution-core" / "Cargo.toml", + _REPO_ROOT / "apps" / "desktop" / "distribution-state" / "Cargo.toml", +) -def test_distribution_update_core_native_suite_is_green() -> None: - """Run the Rust anti-replay/rollback contract with its own locked graph.""" - completed = subprocess.run( - [ - "cargo", - "test", - "--manifest-path", - str(_MANIFEST), - "--locked", - "--all-targets", - ], - cwd=_REPO_ROOT, - text=True, - capture_output=True, - check=False, - timeout=60, - ) +def test_distribution_update_native_suites_are_green() -> None: + """Run the locked Rust decision and durable-state contracts independently.""" + 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, completed.stdout + completed.stderr + assert completed.returncode == 0, ( + f"{manifest.relative_to(_REPO_ROOT)} failed:\n" + + completed.stdout + + completed.stderr + ) From f07f35ea68a4eee2e3fd8d7c90af0e778716a9e9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 02:01:16 +0900 Subject: [PATCH 087/308] feat(distribution): establish highest-seen state crate --- apps/desktop/distribution-state/Cargo.toml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 apps/desktop/distribution-state/Cargo.toml 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" From 30159e004f7c8629ee47dcf89ae311cebf3fa1f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 02:01:23 +0900 Subject: [PATCH 088/308] build(distribution): lock highest-seen state graph --- apps/desktop/distribution-state/Cargo.lock | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 apps/desktop/distribution-state/Cargo.lock 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", +] From 95889b6a42ecb7452630f94dbff7a9b429e56bde Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 02:02:04 +0900 Subject: [PATCH 089/308] feat(distribution): persist replay authority as bounded append log --- apps/desktop/distribution-state/src/lib.rs | 489 +++++++++++++++++++++ 1 file changed, 489 insertions(+) create mode 100644 apps/desktop/distribution-state/src/lib.rs diff --git a/apps/desktop/distribution-state/src/lib.rs b/apps/desktop/distribution-state/src/lib.rs new file mode 100644 index 000000000..4a5612d51 --- /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); + file.by_ref() + .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)); + } +} From 05a8dc93a208b17451cecdf4a2c03d31f16331fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 02:03:16 +0900 Subject: [PATCH 090/308] docs(distribution): trace durable updater freshness state --- .../traceability/updater-security-metadata.md | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/traceability/updater-security-metadata.md b/docs/traceability/updater-security-metadata.md index 9d4234838..85b68431b 100644 --- a/docs/traceability/updater-security-metadata.md +++ b/docs/traceability/updater-security-metadata.md @@ -48,11 +48,25 @@ Runtime-core lineage: - Crate/lock foundation `1339cfd44aef17743a770449651ee9a233baf4e5` / `0fbb2e8a5396e5b5b123704537e0c4b9719a92e3`: 다른 desktop bounded context나 Tauri/WebView에 의존하지 않는 standalone Rust core를 만들었습니다. - Causal fix `42fdeed9a1ddf57d807889e61dee864b931b62a2`: version monotonicity, highest-seen replay/equivocation, target, compatibility floor와 project-schema-aware known-good rollback decision을 구현하고 hostile edge cases를 native unit test로 고정했습니다. -이 core는 아직 network check/install을 실행하지 않습니다. 현재 updater authority가 `blocked`인 상태에서 fake endpoint/key를 넣어 runtime을 강제로 활성화하는 것보다, pure decision contract를 먼저 고정하고 실제 authority provision 후 Tauri `Update.raw_json` + authenticated artifact flow에 연결하는 편이 신뢰 경계를 보존합니다. +## Highest-seen durable state + +Decision core만 있고 authenticated release identity를 restart 뒤 보존하지 않으면 replay 방어는 세션 경계에서 사라집니다. 이 state는 Project Persistence에 넣지 않고 Distribution 내부의 별도 `apps/desktop/distribution-state` crate가 소유합니다. `distribution-core`는 계속 filesystem-independent decision layer로 남고, state crate는 그 `ReleaseIdentity`만 소비합니다. + +State format은 bounded append-only log입니다. 정상 record는 `v1|MAJOR.MINOR.PATCH|<40-hex source>|<64-hex updater sha256>\n`이며 최대 64 KiB만 허용합니다. Loader는 regular non-link file만 읽고 committed record를 모두 재검증합니다. version이 감소하거나 같은 version이 다시 committed되면 local authority corruption으로 fail closed합니다. 마지막 append가 crash 중 끊어진 경우에만, trailing bytes가 정확히 valid record prefix일 때 이전 committed highest identity를 복구합니다. 다음 successful append 전에 그 validated partial tail을 잘라냅니다. + +`remember_highest_seen`은 lower release를 `Replay`, same-version/different-identity를 `Equivocation`으로 거부합니다. Exact same identity는 log를 늘리지 않는 idempotent no-op입니다. 새 record는 append 후 `sync_all()`이 성공하고 expected byte length가 확인되어야 성공으로 반환합니다. Unix에서는 최초 state-file 생성 시 parent directory도 동기화합니다. Windows에서 directory-entry power-loss semantics까지 source만으로 동일하게 주장하지 않으며, packaged fault-injection acceptance는 계속 남은 release gate입니다. + +Durable-state lineage: + +- RED `fa5690b53b776ef9d5b31b13b0285de384a53aaf`: repository validation이 별도의 locked `distribution-state` native suite를 요구하도록 확장했습니다. +- Foundation `f07f35ea68a4eee2e3fd8d7c90af0e778716a9e9` / `30159e004f7c8629ee47dcf89ae311cebf3fa1f7`: path-only dependency graph으로 state owner와 lockfile을 분리했습니다. +- Causal fix `95889b6a42ecb7452630f94dbff7a9b429e56bde`: bounded append/sync, monotonic/equivocation checks, recoverable torn-tail handling, regular-file/symlink/size admission과 native hostile-case tests를 구현했습니다. + +이 state crate는 아직 Tauri `Update.raw_json`을 parse하거나 update check를 실행하지 않습니다. `release/updater-policy.json`이 `blocked`인 동안 fake endpoint/key를 만들어 positive runtime을 흉내 내지 않습니다. 다음 연결은 authenticated `raw_json` admission과 app-owned state path wiring이며, production signature acceptance는 실제 updater authority가 생긴 뒤에만 가능합니다. ## 보안 경계와 기각한 대안 -이 메타데이터와 Rust decision core는 artifact identity와 anti-replay 판단의 입력이지 독립적인 서명 권위가 아닙니다. Tauri의 `.sig`는 updater bundle을 검증하고, GitHub immutable-release attestation은 published release asset 집합을 검증합니다. `bandscope` JSON 필드만 보고 signature validity나 repository compromise resilience를 주장하지 않습니다. +이 메타데이터와 Rust decision/state core는 artifact identity와 anti-replay 판단의 입력·local memory이지 독립적인 서명 권위가 아닙니다. Tauri의 `.sig`는 updater bundle을 검증하고, GitHub immutable-release attestation은 published release asset 집합을 검증합니다. `bandscope` JSON 필드나 local state만 보고 signature validity나 repository compromise resilience를 주장하지 않습니다. 현재 `release/updater-policy.json`은 updater public key와 production endpoint가 provision되지 않아 `blocked`입니다. private key·public key·endpoint를 source에서 만들거나 추측하지 않습니다. `allowDowngrades` 또는 custom version comparator로 Tauri의 기본 forward version semantics를 약화하는 것도 채택하지 않았습니다. @@ -63,7 +77,7 @@ TUF가 정의하는 rollback/freeze 계열 공격까지 완전히 방어했다 Repository-owned 다음 단계는 승인된 updater authority가 provision되었을 때 Tauri runtime과 durable Distribution state를 이 pure core에 연결하는 것입니다. 그 acceptance는 최소한 다음을 요구합니다. - authenticated `Update.raw_json`에서 exact `bandscope` schema/target/artifact identity를 bounded parsing한 뒤 core에 전달 -- highest-seen release identity의 crash-safe app-owned persistence 및 reload +- `distribution-state`를 app-owned 경로에 연결하고 실제 packaged restart/power-loss에서 highest-seen reload 검증 - offline update-check 실패가 일반 startup을 막지 않음 - truncated/partial download, disk-full, cancel, first-launch failure 뒤 current installation과 project data 보존 - last-known-good installer retention 및 실제 rollback 전 project-schema compatibility 확인 From 9a481ccf8ba8fdbcdaaf8eefc20c8191025a4886 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 02:03:34 +0900 Subject: [PATCH 091/308] docs(product): record durable updater-state progress --- docs/product-technical-gap-baseline.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 7097ea1f7..ae2dbca83 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -20,7 +20,7 @@ The product keeps BandScope-specific audio/rehearsal truth inside BandScope. Org | Project Persistence | Project/cache integrity and scientific cache equivalence have dedicated owner work. | Crash/power-loss, disk-full, interrupted write/recovery, last-known-good project state and packaged-OS fault injection remain buyer gates. | | Collaboration Handoff | Export/handoff belongs to BandScope without creating a second collaboration platform. | Only released, bounded artifacts should cross product boundaries; mutable shared DB or cross-service SQL is not accepted. | | Diagnostics | Existing harness/security/build evidence is substantial. | Buyer-safe diagnostics must avoid audio/project/credential leakage and distinguish user cancel, provider/runtime failure, corrupt project and release/update failure. | -| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. The static manifest carries exact source commit, per-target updater digest/size and compatibility floor. A Rust Distribution core now defines forward-version, replay/equivocation, target and project-schema-aware rollback decisions. | Production updater authority is intentionally blocked until an organization-approved public key and production endpoint exist. Runtime wiring still needs crash-safe highest-seen state, offline-safe check behavior, partial/disk-full/cancel/first-launch recovery and packaged wrong-key/signature/digest/replay acceptance. Windows/macOS signing/notarization authority and commercial model rights are external prerequisites. | +| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. The static manifest carries exact source commit, per-target updater digest/size and compatibility floor. Rust decision and state crates now define replay/equivocation/target/schema policy plus a bounded append/sync highest-seen log with torn-tail recovery. | Production updater authority is intentionally blocked until an organization-approved public key and production endpoint exist. Runtime still needs authenticated `Update.raw_json` admission, app-owned state-path wiring, packaged restart/power-loss acceptance, offline-safe checks, partial/disk-full/cancel/first-launch recovery and packaged wrong-key/signature/digest/replay acceptance. Windows/macOS signing/notarization authority and commercial model rights are external prerequisites. | | UI / Interaction | Rehearsal-first UI is the product surface; Anti-Slop and accessibility are acceptance criteria, not decoration. | Normal/loading/empty/error/permission/responsive states, KO/EN/JA/ZH/VI/ES/DE/FR expansion/fallback, keyboard/focus/contrast/state semantics and actual-audio E2E must be verified on the exact release candidate. | ## Distribution/update decision boundary @@ -33,7 +33,7 @@ The Distribution updater path uses three different evidence classes and must not The Rust `apps/desktop/distribution-core` is the deterministic decision layer after authentication. It rejects malformed stable versions, target mismatch, downgrade candidates, metadata older than the locally highest authenticated release, same-version release-identity equivocation and rollback to a build that cannot read the current project schema. It does not fetch, install, sign, notarize, parse arbitrary remote JSON, or write project data. -Highest-seen update identity is Distribution state, not Project Persistence state. It should be persisted after authenticated metadata is observed even when installation is deferred, otherwise an attacker can make previously observed old metadata look fresh after restart. Project Persistence remains authoritative only for the project/schema evidence used by rollback compatibility checks. +Highest-seen update identity is Distribution state, not Project Persistence state. `apps/desktop/distribution-state` now provides a separate bounded append-only Rust log that revalidates committed identities, rejects local version regression/equivocation, synchronizes successful appends and recovers only a syntactically valid torn final record prefix. It deliberately does not claim packaged power-loss equivalence across Windows/macOS until platform fault-injection evidence exists. Project Persistence remains authoritative only for project bytes and the project-schema evidence used by rollback compatibility checks. ## Release gate @@ -45,7 +45,7 @@ Until those conditions are satisfied, Draft/open PRs and blocked release policie - Distribution admission: `docs/traceability/updater-release-admission.md` - Release receipt/publication: `docs/traceability/release-artifact-receipt.md` -- Updater security metadata and replay/rollback model: `docs/traceability/updater-security-metadata.md` +- Updater security metadata, durable freshness state and replay/rollback model: `docs/traceability/updater-security-metadata.md` - Security trust boundaries: `docs/security/app-security.md` - Cross-platform release controls: `docs/security/cross-platform-build-policy.md` - Architecture ownership: `ARCHITECTURE.md` From fb1ac16603e3ac676857ded246207a0af2880f32 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 02:06:13 +0900 Subject: [PATCH 092/308] fix(distribution): disambiguate bounded state read --- apps/desktop/distribution-state/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/distribution-state/src/lib.rs b/apps/desktop/distribution-state/src/lib.rs index 4a5612d51..a8f5105e1 100644 --- a/apps/desktop/distribution-state/src/lib.rs +++ b/apps/desktop/distribution-state/src/lib.rs @@ -152,7 +152,7 @@ fn read_state_bytes(path: &Path) -> Result>, StateError> { } let mut bytes = Vec::with_capacity(opened.len() as usize); - file.by_ref() + Read::by_ref(&mut file) .take((MAX_STATE_BYTES + 1) as u64) .read_to_end(&mut bytes) .map_err(|_| StateError::Io)?; From 6826c925a4bbb6ca699a898e5c8f166f7038d5b8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 02:06:43 +0900 Subject: [PATCH 093/308] docs(architecture): register durable Distribution state owner --- ARCHITECTURE.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4f29444fe..09ae520c9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -59,6 +59,7 @@ Last updated: 2026-09-15 - `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-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 @@ -68,6 +69,7 @@ Last updated: 2026-09-15 - Distribution owns commercial release identity, native signing/notarization admission, updater policy, immutable publication evidence, 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-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. - The updater runtime must authenticate Tauri metadata and artifact signatures before projecting exact `version`, `sourceCommit`, updater SHA-256, target, and compatibility floor into the Rust decision core. - 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. @@ -113,7 +115,7 @@ Last updated: 2026-09-15 - 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 security-core Rust compilation denies warnings and missing public rustdoc; its standalone locked unit suite is invoked by the repository analysis test harness without adding Python production logic. +- Distribution `distribution-core` 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. From e525aa1fb4bb7d51cd33d2f1f410e339b1f71725 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 03:00:10 +0900 Subject: [PATCH 094/308] test(distribution): require authenticated runtime admission suite --- .../analysis-engine/tests/test_distribution_update_core.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/analysis-engine/tests/test_distribution_update_core.py b/services/analysis-engine/tests/test_distribution_update_core.py index 29ff1bb2d..ad8b29a31 100644 --- a/services/analysis-engine/tests/test_distribution_update_core.py +++ b/services/analysis-engine/tests/test_distribution_update_core.py @@ -9,11 +9,12 @@ _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", ) def test_distribution_update_native_suites_are_green() -> None: - """Run the locked Rust decision and durable-state contracts independently.""" + """Run the locked Rust decision, durable-state, and runtime-admission contracts.""" for manifest in _MANIFESTS: completed = subprocess.run( [ From 5c95912ffedbd69b1bb33773520b73cc68f9dc3c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 03:01:18 +0900 Subject: [PATCH 095/308] feat(distribution): add runtime admission crate manifest --- apps/desktop/distribution-runtime/Cargo.toml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 apps/desktop/distribution-runtime/Cargo.toml diff --git a/apps/desktop/distribution-runtime/Cargo.toml b/apps/desktop/distribution-runtime/Cargo.toml new file mode 100644 index 000000000..f15aa72fa --- /dev/null +++ b/apps/desktop/distribution-runtime/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "bandscope-distribution-runtime" +version = "0.1.0" +edition = "2021" +description = "Bounded updater-metadata admission and durable freshness orchestration for BandScope." +publish = false + +[dependencies] +bandscope-distribution-core = { path = "../distribution-core" } +bandscope-distribution-state = { path = "../distribution-state" } + +[workspace] + +[lints.rust] +unsafe_code = "forbid" +warnings = "deny" +missing_docs = "deny" From b9f72beb826d5a0dc01b2d82cffbc814c2f91e2a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 03:01:27 +0900 Subject: [PATCH 096/308] build(distribution): lock runtime path dependency graph --- apps/desktop/distribution-runtime/Cargo.lock | 22 ++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 apps/desktop/distribution-runtime/Cargo.lock diff --git a/apps/desktop/distribution-runtime/Cargo.lock b/apps/desktop/distribution-runtime/Cargo.lock new file mode 100644 index 000000000..ca4d20b17 --- /dev/null +++ b/apps/desktop/distribution-runtime/Cargo.lock @@ -0,0 +1,22 @@ +# 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", + "bandscope-distribution-state", +] + +[[package]] +name = "bandscope-distribution-state" +version = "0.1.0" +dependencies = [ + "bandscope-distribution-core", +] From 85db601ff0771ef59e0601d2c1c2296f827bc5d3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 03:03:43 +0900 Subject: [PATCH 097/308] feat(distribution): bound raw updater metadata as provisional input --- apps/desktop/distribution-runtime/src/lib.rs | 657 +++++++++++++++++++ 1 file changed, 657 insertions(+) create mode 100644 apps/desktop/distribution-runtime/src/lib.rs diff --git a/apps/desktop/distribution-runtime/src/lib.rs b/apps/desktop/distribution-runtime/src/lib.rs new file mode 100644 index 000000000..8b9d03fae --- /dev/null +++ b/apps/desktop/distribution-runtime/src/lib.rs @@ -0,0 +1,657 @@ +//! 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::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"; + +/// 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 empty, oversized, or contains a NUL byte. + InvalidSignature, + /// A platform URL is not a bounded HTTPS 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, Debug, Eq, PartialEq)] +pub struct ProvisionalUpdateMetadata { + candidate: UpdateCandidate, + artifact_size_bytes: u64, +} + +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 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, bounds signature/URL and +/// artifact-size fields, and delegates release-identity syntax to the pure +/// Distribution core. 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)?; + for target in SUPPORTED_TARGETS { + let platform = as_object(field(platforms, target)?)?; + require_exact_members(platform, &["signature", "url"])?; + validate_signature(as_string(field(platform, "signature")?)?)?; + validate_release_url(as_string(field(platform, "url")?)?, version)?; + } + + 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 candidate = validate_candidate_syntax( + version, + source_commit, + artifact_sha256, + expected_target, + minimum_supported_version, + )?; + + Ok(ProvisionalUpdateMetadata { + candidate, + artifact_size_bytes, + }) +} + +/// 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.is_empty() || value.len() > MAX_SIGNATURE_BYTES || value.as_bytes().contains(&0) { + return Err(MetadataError::InvalidSignature); + } + Ok(()) +} + +fn validate_release_url(value: &str, version: &str) -> Result<(), MetadataError> { + if value.is_empty() + || value.len() > MAX_URL_BYTES + || !value.starts_with("https://") + || value.bytes().any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace()) + { + return Err(MetadataError::InvalidUrl); + } + let tag_segment = format!("/releases/download/v{version}/"); + if !value.contains(&tag_segment) || value.contains("/releases/latest/") { + return Err(MetadataError::InvalidUrl); + } + Ok(()) +} + +#[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": "sig-win-x86\\n", "url": "https://github.com/ContextualWisdomLab/bandscope/releases/download/v{version}/win-x86.zip"}}, + "windows-aarch64": {{"signature": "sig-win-arm", "url": "https://github.com/ContextualWisdomLab/bandscope/releases/download/v{version}/win-arm.zip"}}, + "darwin-x86_64": {{"signature": "sig-mac-x86", "url": "https://github.com/ContextualWisdomLab/bandscope/releases/download/v{version}/mac-x86.tar.gz"}}, + "darwin-aarch64": {{"signature": "sig-mac-arm", "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 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 = manifest("2.0.0").replace("sig-win-arm", "sig-\\u2603"); + assert!(admit_untrusted_raw_json(escaped.as_bytes(), "windows-x86_64").is_ok()); + + let invalid = manifest("2.0.0").replace("sig-win-arm", "sig-\\uD800x"); + assert_eq!( + admit_untrusted_raw_json(invalid.as_bytes(), "windows-x86_64"), + Err(MetadataError::InvalidJson) + ); + } +} From def74eff06c1d80521fb43336d461e206937c438 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 03:03:52 +0900 Subject: [PATCH 098/308] refactor(distribution): keep provisional runtime admission stateless --- apps/desktop/distribution-runtime/Cargo.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/desktop/distribution-runtime/Cargo.toml b/apps/desktop/distribution-runtime/Cargo.toml index f15aa72fa..54135263d 100644 --- a/apps/desktop/distribution-runtime/Cargo.toml +++ b/apps/desktop/distribution-runtime/Cargo.toml @@ -2,12 +2,11 @@ name = "bandscope-distribution-runtime" version = "0.1.0" edition = "2021" -description = "Bounded updater-metadata admission and durable freshness orchestration for BandScope." +description = "Bounded provisional updater-metadata admission for BandScope Distribution." publish = false [dependencies] bandscope-distribution-core = { path = "../distribution-core" } -bandscope-distribution-state = { path = "../distribution-state" } [workspace] From 418c68d06c7ec2ba4bb2bc6f199ea11482c2f501 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 03:04:00 +0900 Subject: [PATCH 099/308] build(distribution): minimize provisional runtime lock graph --- apps/desktop/distribution-runtime/Cargo.lock | 8 -------- 1 file changed, 8 deletions(-) diff --git a/apps/desktop/distribution-runtime/Cargo.lock b/apps/desktop/distribution-runtime/Cargo.lock index ca4d20b17..66e324f28 100644 --- a/apps/desktop/distribution-runtime/Cargo.lock +++ b/apps/desktop/distribution-runtime/Cargo.lock @@ -9,14 +9,6 @@ version = "0.1.0" [[package]] name = "bandscope-distribution-runtime" version = "0.1.0" -dependencies = [ - "bandscope-distribution-core", - "bandscope-distribution-state", -] - -[[package]] -name = "bandscope-distribution-state" -version = "0.1.0" dependencies = [ "bandscope-distribution-core", ] From 1c52911acf0a8d4d7995d5f2aa1fbc6de2229de0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 03:04:45 +0900 Subject: [PATCH 100/308] docs(distribution): correct raw updater metadata trust boundary --- .../traceability/updater-security-metadata.md | 102 ++++++++---------- 1 file changed, 46 insertions(+), 56 deletions(-) diff --git a/docs/traceability/updater-security-metadata.md b/docs/traceability/updater-security-metadata.md index 85b68431b..bbe0e4d54 100644 --- a/docs/traceability/updater-security-metadata.md +++ b/docs/traceability/updater-security-metadata.md @@ -1,93 +1,81 @@ # Updater security metadata traceability -BandScope의 Distribution/update bounded context는 Tauri가 설치에 필요한 최소 정적 manifest와 BandScope가 재생·프로젝트 안전성 판단에 필요한 보안 메타데이터를 구분합니다. `latest.json`은 Tauri의 `version`, target별 `url`·`signature`를 유지하면서, exact release receipt에서 파생한 `bandscope` 확장 메타데이터를 함께 싣습니다. +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 오류와 수정 -기존 `build_updater_manifest.py`는 exact tag URL과 `.sig` 내용을 receipt에 묶었지만, #960이 요구하는 updater artifact digest와 `minimumSupportedVersion`은 manifest에 없었습니다. 따라서 desktop runtime이 Tauri의 `Update.raw_json`을 사용해 replay/rollback 또는 compatibility 결정을 추가하더라도, 어떤 source commit과 어떤 updater bundle bytes가 제시됐는지 manifest 자체에서 확인할 수 없었습니다. +기존 문서는 Tauri `Update.raw_json`을 향후 "authenticated metadata"처럼 연결할 수 있다고 적었습니다. current Tauri v2 계약과 구현을 다시 확인하면 이 표현은 부정확합니다. -이 문제는 Tauri signature 검증과 별개입니다. Tauri updater는 update artifact signature 검증을 비활성화할 수 없고, static JSON에는 `version`, target별 `url`, `signature`가 필요합니다. 현재 Tauri API는 updater response의 원본 JSON을 `Update.raw_json`으로 보존하므로 제품별 추가 필드를 별도 updater protocol을 만들지 않고 소비할 수 있습니다. +- 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 때문에 이번 slice는 state writer를 `raw_json`에 곧바로 연결하지 않았습니다. 먼저 `apps/desktop/distribution-runtime`을 추가해 remote JSON을 **provisional metadata**로만 admit합니다. 이 crate는 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 사이의 우발적 결합을 없앴습니다. ## Manifest evidence -`build_updater_manifest.py`는 release graph를 `select_release_assets.py`로 다시 admit한 뒤 각 target receipt의 updater entry에서 다음 값을 `bandscope` 객체에 기록합니다. +`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 각각의 exact updater bundle `sizeBytes`와 full SHA-256 - -정책 파일은 고정 repository-relative path에서 최대 64 KiB regular non-link file로 읽고, descriptor identity drift와 duplicate JSON member를 거부합니다. `minimumSupportedVersion`은 SemVer 형태를 다시 확인합니다. Receipt의 `sourceCommit`이 요청된 exact release commit과 다르면 manifest 생성을 중단합니다. 각 bundle size/digest도 positive integer와 full lowercase SHA-256 계약을 만족해야 합니다. - -Manifest TDD lineage: - -- RED `57ced89e61529a984010c8b351fef54cac543b6f`: static manifest가 exact source commit, policy version floor, target별 updater bundle size/full SHA-256을 노출해야 한다는 실행 계약을 추가했습니다. -- Fix `21ee4ff52789659cbf70db33c24d9e925fb8d4f1`: receipt/policy-derived `bandscope` metadata를 deterministic manifest에 결합하고 malformed policy·receipt identity를 fail closed 처리했습니다. -- Edge coverage `857e1e9324e29f896d5bad6216631bd723fd1f65`: duplicate policy authority와 non-canonical minimum version을 거부하도록 고정했습니다. +- Windows amd64/arm64, macOS amd64/arm64 각각의 updater bundle `sizeBytes`와 full SHA-256 -## Rust anti-replay / rollback decision core +정책 파일은 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가 되는 것은 아닙니다. -Updater key와 production endpoint가 아직 provision되지 않았다고 해서 replay/rollback 정책 자체를 미룰 이유는 없습니다. 네트워크·Tauri·installer I/O에서 분리된 Rust-first policy core를 `apps/desktop/distribution-core`에 두고, 외부 authority가 생긴 뒤 runtime이 이 계약을 소비하도록 했습니다. Python은 repository CI에서 독립 Rust suite를 실행하는 validation boundary만 담당합니다. +## Rust decision core와 durable state -`bandscope-distribution-core`는 다음을 fail closed로 결정합니다. +`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을 결정합니다. -- stable channel version은 canonical numeric `MAJOR.MINOR.PATCH`만 허용합니다. `v` prefix, leading zero, prerelease/build metadata, whitespace와 numeric overflow를 거부합니다. Beta/prerelease channel이 필요하면 full SemVer 구현을 임의로 확장하지 않고 별도 ADR과 canonical parser를 도입해야 합니다. -- release identity는 exact 40 lowercase-hex source commit과 exact 64 lowercase-hex updater SHA-256을 요구합니다. -- target token은 bounded safe ASCII로 제한하고 현재 desktop target과 exact match해야 합니다. -- 현재 설치 버전보다 낮은 candidate는 `Rollback`, locally persisted highest-seen release보다 낮은 candidate는 `Replay`로 거부합니다. -- 동일 version이 다른 source commit 또는 artifact digest로 다시 나타나면 `Equivocation`으로 거부합니다. -- 동일한 highest-seen release를 사용자가 이전에 설치하지 않았거나 연기했더라도 재제안은 허용하되, 새 release처럼 freshness를 다시 부여하지 않습니다. -- 현재 client가 release의 `minimumSupportedVersion`보다 낮으면 automatic path를 거부하여 별도 recovery/manual upgrade 경로로 보냅니다. -- last-known-good rollback은 target version이 현재 설치본보다 실제로 오래되고, 해당 build가 현재 on-disk project schema를 읽을 수 있는 경우에만 허용합니다. +`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와 분리됩니다. -Highest-seen identity는 설치 완료 시점이 아니라 signature/metadata admission이 성공해 release를 신뢰한 시점에 Distribution-owned durable state로 기록해야 합니다. 사용자가 설치를 미뤘다는 이유로 같은 공격자-controlled metadata가 다시 fresh해지면 replay 방어가 성립하지 않기 때문입니다. Project Persistence는 project bytes/schema truth만 제공하며 updater freshness state를 소유하지 않습니다. +중요한 순서는 다음과 같습니다. -Runtime-core lineage: +1. Remote updater JSON은 untrusted/provisional input으로 bounded parsing합니다. +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를 갱신할 수 있습니다. -- RED `4467a9e80b3fa7e7e7a95cb1ff7606749606b3d0`: repository CI가 독립 Rust Distribution suite를 `--locked --all-targets`로 실행하도록 요구했습니다. -- Crate/lock foundation `1339cfd44aef17743a770449651ee9a233baf4e5` / `0fbb2e8a5396e5b5b123704537e0c4b9719a92e3`: 다른 desktop bounded context나 Tauri/WebView에 의존하지 않는 standalone Rust core를 만들었습니다. -- Causal fix `42fdeed9a1ddf57d807889e61dee864b931b62a2`: version monotonicity, highest-seen replay/equivocation, target, compatibility floor와 project-schema-aware known-good rollback decision을 구현하고 hostile edge cases를 native unit test로 고정했습니다. +현재 2번이 없으므로 5번을 runtime에 연결하지 않는 것이 fail-closed 동작입니다. 설치를 미룬 release까지 pre-install highest-seen으로 기억하려면 metadata 자체의 authenticity가 필요합니다. 그것 없이 remote version만 먼저 저장하는 것은 freeze/replay 방어가 아니라 local state poisoning 경로가 될 수 있습니다. -## Highest-seen durable state +## Resource-admission gap -Decision core만 있고 authenticated release identity를 restart 뒤 보존하지 않으면 replay 방어는 세션 경계에서 사라집니다. 이 state는 Project Persistence에 넣지 않고 Distribution 내부의 별도 `apps/desktop/distribution-state` crate가 소유합니다. `distribution-core`는 계속 filesystem-independent decision layer로 남고, state crate는 그 `ReleaseIdentity`만 소비합니다. - -State format은 bounded append-only log입니다. 정상 record는 `v1|MAJOR.MINOR.PATCH|<40-hex source>|<64-hex updater sha256>\n`이며 최대 64 KiB만 허용합니다. Loader는 regular non-link file만 읽고 committed record를 모두 재검증합니다. version이 감소하거나 같은 version이 다시 committed되면 local authority corruption으로 fail closed합니다. 마지막 append가 crash 중 끊어진 경우에만, trailing bytes가 정확히 valid record prefix일 때 이전 committed highest identity를 복구합니다. 다음 successful append 전에 그 validated partial tail을 잘라냅니다. - -`remember_highest_seen`은 lower release를 `Replay`, same-version/different-identity를 `Equivocation`으로 거부합니다. Exact same identity는 log를 늘리지 않는 idempotent no-op입니다. 새 record는 append 후 `sync_all()`이 성공하고 expected byte length가 확인되어야 성공으로 반환합니다. Unix에서는 최초 state-file 생성 시 parent directory도 동기화합니다. Windows에서 directory-entry power-loss semantics까지 source만으로 동일하게 주장하지 않으며, packaged fault-injection acceptance는 계속 남은 release gate입니다. - -Durable-state lineage: - -- RED `fa5690b53b776ef9d5b31b13b0285de384a53aaf`: repository validation이 별도의 locked `distribution-state` native suite를 요구하도록 확장했습니다. -- Foundation `f07f35ea68a4eee2e3fd8d7c90af0e778716a9e9` / `30159e004f7c8629ee47dcf89ae311cebf3fa1f7`: path-only dependency graph으로 state owner와 lockfile을 분리했습니다. -- Causal fix `95889b6a42ecb7452630f94dbff7a9b429e56bde`: bounded append/sync, monotonic/equivocation checks, recoverable torn-tail handling, regular-file/symlink/size admission과 native hostile-case tests를 구현했습니다. - -이 state crate는 아직 Tauri `Update.raw_json`을 parse하거나 update check를 실행하지 않습니다. `release/updater-policy.json`이 `blocked`인 동안 fake endpoint/key를 만들어 positive runtime을 흉내 내지 않습니다. 다음 연결은 authenticated `raw_json` admission과 app-owned state path wiring이며, production signature acceptance는 실제 updater authority가 생긴 뒤에만 가능합니다. +Tauri current source의 `Update::download`는 HTTP body chunk를 `Vec`에 누적한 다음 signature를 검증합니다. BandScope manifest는 declared artifact size를 bounded field로 갖지만, remote server가 그 값을 지킨다는 보장은 signature verification 전에는 없습니다. 따라서 production updater를 켤 때는 declared size만 보는 것으로 resource admission을 완료했다고 주장할 수 없습니다. Bounded streaming/download behavior 또는 동등한 hard memory/disk admission evidence가 별도로 필요합니다. ## 보안 경계와 기각한 대안 -이 메타데이터와 Rust decision/state core는 artifact identity와 anti-replay 판단의 입력·local memory이지 독립적인 서명 권위가 아닙니다. Tauri의 `.sig`는 updater bundle을 검증하고, GitHub immutable-release attestation은 published release asset 집합을 검증합니다. `bandscope` JSON 필드나 local state만 보고 signature validity나 repository compromise resilience를 주장하지 않습니다. +`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입니다. -현재 `release/updater-policy.json`은 updater public key와 production endpoint가 provision되지 않아 `blocked`입니다. private key·public key·endpoint를 source에서 만들거나 추측하지 않습니다. `allowDowngrades` 또는 custom version comparator로 Tauri의 기본 forward version semantics를 약화하는 것도 채택하지 않았습니다. +`raw_json`을 "Tauri가 받았으므로 authenticated"라고 간주하는 방식은 기각합니다. artifact signature가 통과하기 전 remote JSON을 highest-seen state에 쓰는 방식도 기각합니다. Metadata signature 또는 TUF류 protocol을 도입한다면 BandScope release/update owner에서 versioned contract와 key lifecycle, rotation/recovery, expiry/freeze semantics까지 함께 설계해야 하며 다른 bounded context에 검증 로직을 복제하지 않습니다. -TUF가 정의하는 rollback/freeze 계열 공격까지 완전히 방어했다고 주장하지 않습니다. TUF의 freshness/rollback 모델은 서명된 metadata version과 expiry를 포함하는 더 강한 repository metadata protocol입니다. BandScope는 현재 Tauri signed-artifact updater 위에 product-specific immutable evidence와 local highest-seen policy를 추가하는 단계이며, TUF와 동등한 metadata security model을 구현한 상태가 아닙니다. +현재 `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 다음 단계는 승인된 updater authority가 provision되었을 때 Tauri runtime과 durable Distribution state를 이 pure core에 연결하는 것입니다. 그 acceptance는 최소한 다음을 요구합니다. +Repository-owned 다음 단계는 다음 순서가 맞습니다. -- authenticated `Update.raw_json`에서 exact `bandscope` schema/target/artifact identity를 bounded parsing한 뒤 core에 전달 -- `distribution-state`를 app-owned 경로에 연결하고 실제 packaged restart/power-loss에서 highest-seen reload 검증 -- offline update-check 실패가 일반 startup을 막지 않음 -- truncated/partial download, disk-full, cancel, first-launch failure 뒤 current installation과 project data 보존 -- last-known-good installer retention 및 실제 rollback 전 project-schema compatibility 확인 -- 잘못된 key/signature/digest, unsupported target, replay/stale metadata에 대한 packaged Windows/macOS acceptance +- `distribution-runtime` provisional parser를 current Tauri static manifest shape와 계속 동기화 +- remote metadata authenticity를 위한 canonical owner 계약과 verification path 결정 및 RED→GREEN 구현 +- authenticated metadata와 Tauri-verified artifact bytes의 digest/size binding +- 그 이후에만 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 public key/endpoint가 provision된 뒤에만 수행합니다. +Positive production signature acceptance는 organization-approved updater authority가 provision된 뒤에만 수행합니다. ## Security Notes -Attack surface는 remote updater metadata, release receipts, updater bundle identity, locally persisted freshness state와 recovery decision입니다. Distribution이 manifest publication과 update trust를 소유하며 Active Player, MIR, Project Persistence는 해당 권위를 복제하지 않습니다. 입력은 fixed-path/bounded/stable-descriptor admission과 exact digest로 제한하고 malformed authority는 fail closed 처리합니다. 원본 audio/project payload는 manifest나 updater state에 포함하거나 update endpoint로 전송하지 않습니다. +Attack surface는 remote updater metadata, 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를 통과해도 provisional이며, authenticated evidence가 생기기 전 local freshness state를 mutate하지 않습니다. 원본 audio/project payload는 update metadata나 state에 포함하거나 endpoint로 전송하지 않습니다. ## 참고문헌 @@ -95,4 +83,6 @@ Samuel, J., Mathewson, N., Cappos, J., & Dingledine, R. (2010). *Survivable key Tauri Contributors. (2026). *Updater*. Tauri v2 documentation. https://v2.tauri.app/plugin/updater/ +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/ From d0bdea077eeff3ee12b0eba2e651437ba207a2b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 03:05:03 +0900 Subject: [PATCH 101/308] docs(product): expose updater metadata-authentication gap --- docs/product-technical-gap-baseline.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index ae2dbca83..d620fb8b6 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -20,24 +20,29 @@ The product keeps BandScope-specific audio/rehearsal truth inside BandScope. Org | Project Persistence | Project/cache integrity and scientific cache equivalence have dedicated owner work. | Crash/power-loss, disk-full, interrupted write/recovery, last-known-good project state and packaged-OS fault injection remain buyer gates. | | Collaboration Handoff | Export/handoff belongs to BandScope without creating a second collaboration platform. | Only released, bounded artifacts should cross product boundaries; mutable shared DB or cross-service SQL is not accepted. | | Diagnostics | Existing harness/security/build evidence is substantial. | Buyer-safe diagnostics must avoid audio/project/credential leakage and distinguish user cancel, provider/runtime failure, corrupt project and release/update failure. | -| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. The static manifest carries exact source commit, per-target updater digest/size and compatibility floor. Rust decision and state crates now define replay/equivocation/target/schema policy plus a bounded append/sync highest-seen log with torn-tail recovery. | Production updater authority is intentionally blocked until an organization-approved public key and production endpoint exist. Runtime still needs authenticated `Update.raw_json` admission, app-owned state-path wiring, packaged restart/power-loss acceptance, offline-safe checks, partial/disk-full/cancel/first-launch recovery and packaged wrong-key/signature/digest/replay acceptance. Windows/macOS signing/notarization authority and commercial model rights are external prerequisites. | +| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. `distribution-core` and `distribution-state` define deterministic replay/rollback policy and durable highest-seen storage for already-authenticated release identity. `distribution-runtime` now bounded-parses the current static updater JSON only as provisional remote input and projects the fixed app-owned highest-seen path without writing it. | The current Tauri artifact signature verifies downloaded updater bytes, not the whole `raw_json` response. Therefore remote `version`/`sourceCommit`/digest metadata cannot yet be promoted into highest-seen authority. Commercial runtime needs an authenticated metadata binding, verified artifact digest/size binding, then app-owned state wiring. Production updater key/endpoint, packaged restart/power-loss/recovery, bounded download resource admission, Windows/macOS signing/notarization and commercial model rights remain gates. | | UI / Interaction | Rehearsal-first UI is the product surface; Anti-Slop and accessibility are acceptance criteria, not decoration. | Normal/loading/empty/error/permission/responsive states, KO/EN/JA/ZH/VI/ES/DE/FR expansion/fallback, keyboard/focus/contrast/state semantics and actual-audio E2E must be verified on the exact release candidate. | ## Distribution/update decision boundary -The Distribution updater path uses three different evidence classes and must not collapse them into one claim. +The updater path uses evidence classes with different trust semantics and must not collapse them into one claim. -1. Tauri updater signatures authenticate updater artifacts under an organization-approved updater key. -2. BandScope release receipts and `bandscope` updater metadata bind exact version, source commit, target, artifact byte size/full SHA-256 and minimum supported version. -3. GitHub immutable-release verification provides hosted publication evidence for the published asset set. +1. Tauri updater signatures authenticate the downloaded updater artifact bytes under an organization-approved updater key. +2. BandScope release receipts and `bandscope` static-manifest fields bind publication-time version, source commit, target, artifact byte size/full SHA-256 and minimum supported version. Once fetched remotely, those JSON fields are provisional until an independent metadata-authentication path binds them to trusted release authority. +3. GitHub immutable-release verification provides hosted publication evidence for the published asset set. It does not by itself authenticate a client's later `raw_json` response. +4. Distribution highest-seen state is local anti-replay authority only after the release identity entering it is authenticated. The Rust `apps/desktop/distribution-core` is the deterministic decision layer after authentication. It rejects malformed stable versions, target mismatch, downgrade candidates, metadata older than the locally highest authenticated release, same-version release-identity equivocation and rollback to a build that cannot read the current project schema. It does not fetch, install, sign, notarize, parse arbitrary remote JSON, or write project data. -Highest-seen update identity is Distribution state, not Project Persistence state. `apps/desktop/distribution-state` now provides a separate bounded append-only Rust log that revalidates committed identities, rejects local version regression/equivocation, synchronizes successful appends and recovers only a syntactically valid torn final record prefix. It deliberately does not claim packaged power-loss equivalence across Windows/macOS until platform fault-injection evidence exists. Project Persistence remains authoritative only for project bytes and the project-schema evidence used by rollback compatibility checks. +`apps/desktop/distribution-runtime` is now the narrow remote-input adapter. It accepts at most 256 KiB of UTF-8 JSON, rejects duplicate/unknown members, enforces the exact four desktop targets, bounds signature/URL/artifact-size fields, requires exact-tag HTTPS URLs and delegates release-identity syntax to `distribution-core`. Its result type is explicitly provisional. It has no durable-state dependency and cannot write highest-seen state from syntactically valid `raw_json` alone. + +Highest-seen update identity remains Distribution state, not Project Persistence state. `apps/desktop/distribution-state` provides a separate bounded append-only Rust log that revalidates committed identities, rejects local version regression/equivocation, synchronizes successful appends and recovers only a syntactically valid torn final record prefix. It deliberately does not claim packaged power-loss equivalence across Windows/macOS until platform fault-injection evidence exists. Project Persistence remains authoritative only for project bytes and the project-schema evidence used by rollback compatibility checks. + +Current Tauri source also buffers the full updater response body before artifact signature verification. A declared manifest size is therefore not sufficient evidence of adversarial resource admission. Production runtime must prove a hard bounded download path or an equivalent memory/disk bound before the updater is considered commercial-ready. ## Release gate -A release candidate is not commercial-ready until all of the following are true on the exact protected head: required checks and independent review are terminal/qualifying; Windows artifacts are signed by the approved publisher and macOS artifacts are signed/notarized/stapled; updater authority is admitted without placeholder values; updater replay/rollback/recovery is exercised on packaged targets; SBOM/NOTICE/provenance agree with exact shipped bytes; model rights and exact model provenance are established; rights-cleared real-audio scientific acceptance is reproducible; the updater can recover to a compatible known-good build without losing project usability; and material UI passes actual-audio, responsive, locale and accessibility E2E. +A release candidate is not commercial-ready until all of the following are true on the exact protected head: required checks and independent review are terminal/qualifying; Windows artifacts are signed by the approved publisher and macOS artifacts are signed/notarized/stapled; updater authority is admitted without placeholder values; remote updater metadata has an authenticated binding before it can mutate freshness state; updater artifact bytes are signature-verified and matched to authenticated digest/size evidence; updater replay/rollback/recovery is exercised on packaged targets; download resource use is bounded under hostile/truncated/oversized responses; SBOM/NOTICE/provenance agree with exact shipped bytes; model rights and exact model provenance are established; rights-cleared real-audio scientific acceptance is reproducible; the updater can recover to a compatible known-good build without losing project usability; and material UI passes actual-audio, responsive, locale and accessibility E2E. Until those conditions are satisfied, Draft/open PRs and blocked release policies are expected safety states rather than reasons to bypass gates. @@ -45,7 +50,7 @@ Until those conditions are satisfied, Draft/open PRs and blocked release policie - Distribution admission: `docs/traceability/updater-release-admission.md` - Release receipt/publication: `docs/traceability/release-artifact-receipt.md` -- Updater security metadata, durable freshness state and replay/rollback model: `docs/traceability/updater-security-metadata.md` +- Updater security metadata, provisional runtime admission, durable freshness state and replay/rollback model: `docs/traceability/updater-security-metadata.md` - Security trust boundaries: `docs/security/app-security.md` - Cross-platform release controls: `docs/security/cross-platform-build-policy.md` - Architecture ownership: `ARCHITECTURE.md` From 8533403e11f6d709127dab9a18d64e7c505ad40b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 03:06:29 +0900 Subject: [PATCH 102/308] docs(architecture): separate provisional updater metadata from authority --- ARCHITECTURE.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 09ae520c9..d70692297 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -59,6 +59,7 @@ Last updated: 2026-09-15 - `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; returns provisional metadata only and cannot mutate freshness state - `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 @@ -69,11 +70,13 @@ Last updated: 2026-09-15 - Distribution owns commercial release identity, native signing/notarization admission, updater policy, immutable publication evidence, 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 and invalid release-identity syntax, and it projects the fixed app-owned highest-seen path without creating or writing it. It deliberately has no `distribution-state` dependency. - `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. -- The updater runtime must authenticate Tauri metadata and artifact signatures before projecting exact `version`, `sourceCommit`, updater SHA-256, target, and compatibility floor into the Rust decision core. +- 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 after metadata/signature admission, not only after installation. Project Persistence remains owner of project bytes and project-schema truth. +- 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`, and `docs/traceability/updater-security-metadata.md`. @@ -115,7 +118,7 @@ Last updated: 2026-09-15 - 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` 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. +- Distribution `distribution-core`, `distribution-runtime`, 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. From 59bc8c8c772a75d95d806dc5161b4b9935bcc2f8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 04:08:57 +0900 Subject: [PATCH 103/308] fix(updater): pin provisional release download namespace --- apps/desktop/distribution-runtime/src/lib.rs | 88 ++++++++++++++++++-- 1 file changed, 80 insertions(+), 8 deletions(-) diff --git a/apps/desktop/distribution-runtime/src/lib.rs b/apps/desktop/distribution-runtime/src/lib.rs index 8b9d03fae..1f5b0c74c 100644 --- a/apps/desktop/distribution-runtime/src/lib.rs +++ b/apps/desktop/distribution-runtime/src/lib.rs @@ -33,6 +33,9 @@ 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"; /// Fail-closed reasons for provisional updater metadata admission. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -51,7 +54,7 @@ pub enum MetadataError { UnsupportedTarget, /// A platform signature field is empty, oversized, or contains a NUL byte. InvalidSignature, - /// A platform URL is not a bounded HTTPS exact-tag release URL. + /// 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, @@ -109,10 +112,10 @@ impl ProvisionalUpdateMetadata { /// 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, bounds signature/URL and -/// artifact-size fields, and delegates release-identity syntax to the pure -/// Distribution core. Success is deliberately *provisional* and must never be -/// persisted as highest-seen authority without a separate authenticated -/// metadata binding. +/// artifact-size fields, pins artifact URLs to BandScope's exact GitHub release +/// namespace, and delegates release-identity syntax to the pure Distribution +/// core. 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, @@ -238,18 +241,45 @@ fn validate_signature(value: &str) -> Result<(), MetadataError> { fn validate_release_url(value: &str, version: &str) -> Result<(), MetadataError> { if value.is_empty() || value.len() > MAX_URL_BYTES - || !value.starts_with("https://") || value.bytes().any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace()) + || value.contains(['?', '#', '\\']) { return Err(MetadataError::InvalidUrl); } - let tag_segment = format!("/releases/download/v{version}/"); - if !value.contains(&tag_segment) || value.contains("/releases/latest/") { + + 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(); + if segments.len() != 6 + || segments[0] != RELEASE_OWNER + || segments[1] != RELEASE_REPOSITORY + || segments[2] != "releases" + || segments[3] != "download" + || segments[4] != format!("v{version}") + || !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)>), @@ -607,6 +637,48 @@ mod tests { ); } + #[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!( From daad6e54e4b3f4735cf10cbe421dd018a70e145c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 04:10:04 +0900 Subject: [PATCH 104/308] fix(updater): keep URL admission Rust-stable --- apps/desktop/distribution-runtime/src/lib.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/desktop/distribution-runtime/src/lib.rs b/apps/desktop/distribution-runtime/src/lib.rs index 1f5b0c74c..24da212bf 100644 --- a/apps/desktop/distribution-runtime/src/lib.rs +++ b/apps/desktop/distribution-runtime/src/lib.rs @@ -242,7 +242,9 @@ 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('#') + || value.contains('\\') { return Err(MetadataError::InvalidUrl); } @@ -258,12 +260,13 @@ fn validate_release_url(value: &str, version: &str) -> Result<(), MetadataError> } 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] != format!("v{version}") + || segments[4] != expected_tag.as_str() || !is_safe_release_asset_name(segments[5]) { return Err(MetadataError::InvalidUrl); From e25c3f12fc4726bfafde4a3e211ddc6cc839f733 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 04:10:44 +0900 Subject: [PATCH 105/308] docs(updater): trace pinned release URL admission --- .../traceability/updater-security-metadata.md | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/docs/traceability/updater-security-metadata.md b/docs/traceability/updater-security-metadata.md index bbe0e4d54..c3bf7dd50 100644 --- a/docs/traceability/updater-security-metadata.md +++ b/docs/traceability/updater-security-metadata.md @@ -10,7 +10,7 @@ BandScope의 Distribution/update bounded context는 updater artifact 서명, rem - 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 때문에 이번 slice는 state writer를 `raw_json`에 곧바로 연결하지 않았습니다. 먼저 `apps/desktop/distribution-runtime`을 추가해 remote JSON을 **provisional metadata**로만 admit합니다. 이 crate는 state를 쓰거나 anti-replay core에 authenticated candidate를 반환하지 않습니다. +이 finding 때문에 runtime은 state writer를 `raw_json`에 곧바로 연결하지 않습니다. `apps/desktop/distribution-runtime`은 remote JSON을 **provisional metadata**로만 admit하며, state를 쓰거나 anti-replay core에 authenticated candidate를 반환하지 않습니다. Runtime-admission lineage: @@ -18,6 +18,15 @@ Runtime-admission lineage: - 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 비교로 정리했습니다. + +## 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 형태를 사용하는 입력도 거부합니다. + +이 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 @@ -38,7 +47,7 @@ Runtime-admission lineage: 중요한 순서는 다음과 같습니다. -1. Remote updater JSON은 untrusted/provisional input으로 bounded parsing합니다. +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가 일치해야 합니다. @@ -48,13 +57,15 @@ Runtime-admission lineage: ## Resource-admission gap -Tauri current source의 `Update::download`는 HTTP body chunk를 `Vec`에 누적한 다음 signature를 검증합니다. BandScope manifest는 declared artifact size를 bounded field로 갖지만, remote server가 그 값을 지킨다는 보장은 signature verification 전에는 없습니다. 따라서 production updater를 켤 때는 declared size만 보는 것으로 resource admission을 완료했다고 주장할 수 없습니다. Bounded streaming/download behavior 또는 동등한 hard memory/disk admission evidence가 별도로 필요합니다. +Tauri current source의 `Update::download`는 HTTP body chunk를 `Vec`에 누적한 다음 signature를 검증합니다. BandScope manifest는 declared artifact size를 bounded field로 갖지만, remote server가 그 값을 지킨다는 보장은 signature verification 전에는 없습니다. URL namespace pinning은 destination 선택 범위를 줄이지만 response byte 수를 제한하지 않습니다. 따라서 production updater를 켤 때는 declared size나 host pin만으로 resource admission을 완료했다고 주장할 수 없습니다. Bounded streaming/download behavior 또는 동등한 hard memory/disk admission evidence가 별도로 필요합니다. ## 보안 경계와 기각한 대안 `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에 쓰는 방식도 기각합니다. Metadata signature 또는 TUF류 protocol을 도입한다면 BandScope release/update owner에서 versioned contract와 key lifecycle, rotation/recovery, expiry/freeze semantics까지 함께 설계해야 하며 다른 bounded context에 검증 로직을 복제하지 않습니다. +`raw_json`을 "Tauri가 받았으므로 authenticated"라고 간주하는 방식은 기각합니다. artifact signature가 통과하기 전 remote JSON을 highest-seen state에 쓰는 방식도 기각합니다. URL 안에 exact-tag path 조각이 포함되기만 하면 임의 host를 허용하는 방식도 기각합니다. 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입니다. @@ -62,9 +73,10 @@ Tauri current source의 `Update::download`는 HTTP body chunk를 `Vec`에 누적 Repository-owned 다음 단계는 다음 순서가 맞습니다. -- `distribution-runtime` provisional parser를 current Tauri static manifest shape와 계속 동기화 +- `distribution-runtime` provisional parser를 current Tauri static manifest shape와 publication namespace에 계속 동기화 - remote metadata authenticity를 위한 canonical owner 계약과 verification path 결정 및 RED→GREEN 구현 - authenticated metadata와 Tauri-verified artifact bytes의 digest/size binding +- bounded streaming/download 또는 동등한 hard memory/disk admission path - 그 이후에만 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 보존 @@ -75,7 +87,7 @@ Positive production signature acceptance는 organization-approved updater author ## Security Notes -Attack surface는 remote updater metadata, 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를 통과해도 provisional이며, authenticated evidence가 생기기 전 local freshness state를 mutate하지 않습니다. 원본 audio/project payload는 update metadata나 state에 포함하거나 endpoint로 전송하지 않습니다. +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로 전송하지 않습니다. ## 참고문헌 @@ -83,6 +95,8 @@ Samuel, J., Mathewson, N., Cappos, J., & Dingledine, R. (2010). *Survivable key 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/ +The Update Framework. (2026). *The Update Framework specification and security model*. https://theupdateframework.github.io/specification/draft/ From 1e1f1c2e867caacbedc1975c4b475f2a07c28abd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 05:02:19 +0900 Subject: [PATCH 106/308] test(distribution): require bounded updater download contract --- .../analysis-engine/tests/test_distribution_update_core.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/analysis-engine/tests/test_distribution_update_core.py b/services/analysis-engine/tests/test_distribution_update_core.py index ad8b29a31..5193bd535 100644 --- a/services/analysis-engine/tests/test_distribution_update_core.py +++ b/services/analysis-engine/tests/test_distribution_update_core.py @@ -10,11 +10,12 @@ _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", ) def test_distribution_update_native_suites_are_green() -> None: - """Run the locked Rust decision, durable-state, and runtime-admission contracts.""" + """Run the locked decision, state, metadata, and bounded-download Rust contracts.""" for manifest in _MANIFESTS: completed = subprocess.run( [ From f183c0ca2a16d0324b0c33341dfc575503568e53 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 05:03:08 +0900 Subject: [PATCH 107/308] feat(distribution): add bounded updater artifact streaming --- apps/desktop/distribution-download/Cargo.lock | 7 + apps/desktop/distribution-download/Cargo.toml | 13 + apps/desktop/distribution-download/src/lib.rs | 272 ++++++++++++++++++ 3 files changed, 292 insertions(+) create mode 100644 apps/desktop/distribution-download/Cargo.lock create mode 100644 apps/desktop/distribution-download/Cargo.toml create mode 100644 apps/desktop/distribution-download/src/lib.rs 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..b7778f08d --- /dev/null +++ b/apps/desktop/distribution-download/src/lib.rs @@ -0,0 +1,272 @@ +//! 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 before it can claim bounded hostile-response handling. +//! This crate owns only byte-count admission into a caller-provided sink. It +//! does not perform HTTP, metadata authentication, signature verification, +//! digest verification, installation, rollback, or project persistence. + +#![forbid(unsafe_code)] + +use std::io::{ErrorKind, Write}; + +/// 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; + +/// 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, +} + +/// 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 durably handed to the sink by successful chunk writes. + 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, + }) + } +} + +#[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 + ); + } +} From 19c5983d3c683c1aeb7cd576f96d9564a49d80bb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 05:04:24 +0900 Subject: [PATCH 108/308] docs(distribution): trace bounded updater streaming boundary --- docs/traceability/updater-bounded-download.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 docs/traceability/updater-bounded-download.md diff --git a/docs/traceability/updater-bounded-download.md b/docs/traceability/updater-bounded-download.md new file mode 100644 index 000000000..a3d53abad --- /dev/null +++ b/docs/traceability/updater-bounded-download.md @@ -0,0 +1,52 @@ +# 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 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을 검사합니다. + +## 실행 계약 + +`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`가 생성됩니다. + +Unit 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를 다룹니다. 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 요구를 만족하지 않습니다. + +## Claim boundary + +현재 crate는 **network-independent streaming primitive**입니다. 실제 production updater가 아직 이 crate를 통해 HTTP body를 수신하지 않으므로 end-to-end bounded download가 완료됐다고 주장하지 않습니다. 또한 이 crate는 SHA-256, updater signature, metadata authenticity, installer trust를 검증하지 않습니다. + +다음 repository-owned 단계는 production network adapter가 full-response buffering 없이 bounded chunks를 이 primitive에 전달하고, 임시 artifact sink의 disk-full/cancel/cleanup을 fail closed하게 처리하도록 연결하는 것입니다. 그 뒤 organization-approved updater key가 provision되면 verified artifact bytes의 signature와 digest/size를 authenticated release identity에 묶고, 그 시점에만 `distribution-core`와 `distribution-state`로 freshness authority를 넘깁니다. + +## Security Notes + +Attack surface는 updater HTTP response body, transport length metadata, temporary artifact sink와 cancellation/error paths입니다. Remote response는 canonical release namespace를 통과해도 untrusted입니다. Byte admission failure는 installer 실행이나 highest-seen state mutation으로 승격되지 않아야 하며, sink path는 Distribution-owned app storage로 제한해야 합니다. 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 From 21f3beeef1195fda407597034d62b6964d332011 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 05:04:43 +0900 Subject: [PATCH 109/308] docs(product): make bounded updater download gap code-current --- docs/product-technical-gap-baseline.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index d620fb8b6..f9f5e38eb 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -20,7 +20,7 @@ The product keeps BandScope-specific audio/rehearsal truth inside BandScope. Org | Project Persistence | Project/cache integrity and scientific cache equivalence have dedicated owner work. | Crash/power-loss, disk-full, interrupted write/recovery, last-known-good project state and packaged-OS fault injection remain buyer gates. | | Collaboration Handoff | Export/handoff belongs to BandScope without creating a second collaboration platform. | Only released, bounded artifacts should cross product boundaries; mutable shared DB or cross-service SQL is not accepted. | | Diagnostics | Existing harness/security/build evidence is substantial. | Buyer-safe diagnostics must avoid audio/project/credential leakage and distinguish user cancel, provider/runtime failure, corrupt project and release/update failure. | -| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. `distribution-core` and `distribution-state` define deterministic replay/rollback policy and durable highest-seen storage for already-authenticated release identity. `distribution-runtime` now bounded-parses the current static updater JSON only as provisional remote input and projects the fixed app-owned highest-seen path without writing it. | The current Tauri artifact signature verifies downloaded updater bytes, not the whole `raw_json` response. Therefore remote `version`/`sourceCommit`/digest metadata cannot yet be promoted into highest-seen authority. Commercial runtime needs an authenticated metadata binding, verified artifact digest/size binding, then app-owned state wiring. Production updater key/endpoint, packaged restart/power-loss/recovery, bounded download resource admission, Windows/macOS signing/notarization and commercial model rights remain gates. | +| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. `distribution-core` and `distribution-state` define deterministic replay/rollback policy and durable highest-seen storage for already-authenticated release identity. `distribution-runtime` bounded-parses static updater JSON only as provisional remote input. `distribution-download` now provides a dependency-free Rust byte-admission primitive that enforces expected size, optional `Content-Length`, per-chunk and cumulative ceilings, sink-failure poisoning and exact-completion receipts. | The current Tauri artifact signature verifies downloaded updater bytes, not the whole `raw_json` response. Remote release identity therefore still needs independent metadata authentication. The new download primitive is not yet wired to a production HTTP adapter, so end-to-end hostile-response memory/disk bounds, temporary-file cleanup, disk-full/cancel recovery and verified artifact digest/signature binding remain gates. Production updater key/endpoint, packaged restart/power-loss/recovery, Windows/macOS signing/notarization and commercial model rights also remain open. | | UI / Interaction | Rehearsal-first UI is the product surface; Anti-Slop and accessibility are acceptance criteria, not decoration. | Normal/loading/empty/error/permission/responsive states, KO/EN/JA/ZH/VI/ES/DE/FR expansion/fallback, keyboard/focus/contrast/state semantics and actual-audio E2E must be verified on the exact release candidate. | ## Distribution/update decision boundary @@ -34,15 +34,17 @@ The updater path uses evidence classes with different trust semantics and must n The Rust `apps/desktop/distribution-core` is the deterministic decision layer after authentication. It rejects malformed stable versions, target mismatch, downgrade candidates, metadata older than the locally highest authenticated release, same-version release-identity equivocation and rollback to a build that cannot read the current project schema. It does not fetch, install, sign, notarize, parse arbitrary remote JSON, or write project data. -`apps/desktop/distribution-runtime` is now the narrow remote-input adapter. It accepts at most 256 KiB of UTF-8 JSON, rejects duplicate/unknown members, enforces the exact four desktop targets, bounds signature/URL/artifact-size fields, requires exact-tag HTTPS URLs and delegates release-identity syntax to `distribution-core`. Its result type is explicitly provisional. It has no durable-state dependency and cannot write highest-seen state from syntactically valid `raw_json` alone. +`apps/desktop/distribution-runtime` is the narrow remote-metadata adapter. It accepts at most 256 KiB of UTF-8 JSON, rejects duplicate/unknown members, enforces the exact four desktop targets, bounds signature/URL/artifact-size fields, pins exact-tag URLs to the current BandScope GitHub release namespace and delegates release-identity syntax to `distribution-core`. Its result type is explicitly provisional. It has no durable-state dependency and cannot write highest-seen state from syntactically valid `raw_json` alone. + +`apps/desktop/distribution-download` is a separate network-library-independent streaming boundary. It rejects zero/over-ceiling expected sizes, optional `Content-Length` mismatch, chunks larger than 1 MiB, cumulative overrun before the offending bytes reach the sink, truncated completion and sink-write failure. A failed attempt is poisoned so later chunks cannot manufacture a success receipt. This closes the pure byte-admission primitive gap but does not claim that the current Tauri updater path routes its HTTP body through the primitive. Highest-seen update identity remains Distribution state, not Project Persistence state. `apps/desktop/distribution-state` provides a separate bounded append-only Rust log that revalidates committed identities, rejects local version regression/equivocation, synchronizes successful appends and recovers only a syntactically valid torn final record prefix. It deliberately does not claim packaged power-loss equivalence across Windows/macOS until platform fault-injection evidence exists. Project Persistence remains authoritative only for project bytes and the project-schema evidence used by rollback compatibility checks. -Current Tauri source also buffers the full updater response body before artifact signature verification. A declared manifest size is therefore not sufficient evidence of adversarial resource admission. Production runtime must prove a hard bounded download path or an equivalent memory/disk bound before the updater is considered commercial-ready. +Current Tauri updater APIs still materialize a verified update as in-memory bytes. The repository now owns a stricter streaming byte-admission primitive, but commercial readiness requires a production network adapter that actually streams bounded response chunks into that boundary and a bounded temporary sink. Counting progress callbacks or checking the fully buffered `Vec` after download is not equivalent evidence. ## Release gate -A release candidate is not commercial-ready until all of the following are true on the exact protected head: required checks and independent review are terminal/qualifying; Windows artifacts are signed by the approved publisher and macOS artifacts are signed/notarized/stapled; updater authority is admitted without placeholder values; remote updater metadata has an authenticated binding before it can mutate freshness state; updater artifact bytes are signature-verified and matched to authenticated digest/size evidence; updater replay/rollback/recovery is exercised on packaged targets; download resource use is bounded under hostile/truncated/oversized responses; SBOM/NOTICE/provenance agree with exact shipped bytes; model rights and exact model provenance are established; rights-cleared real-audio scientific acceptance is reproducible; the updater can recover to a compatible known-good build without losing project usability; and material UI passes actual-audio, responsive, locale and accessibility E2E. +A release candidate is not commercial-ready until all of the following are true on the exact protected head: required checks and independent review are terminal/qualifying; Windows artifacts are signed by the approved publisher and macOS artifacts are signed/notarized/stapled; updater authority is admitted without placeholder values; remote updater metadata has an authenticated binding before it can mutate freshness state; updater artifact bytes are signature-verified and matched to authenticated digest/size evidence; updater replay/rollback/recovery is exercised on packaged targets; production HTTP download actually passes through bounded streaming/resource admission and survives hostile/truncated/oversized/disk-full/cancel cases; SBOM/NOTICE/provenance agree with exact shipped bytes; model rights and exact model provenance are established; rights-cleared real-audio scientific acceptance is reproducible; the updater can recover to a compatible known-good build without losing project usability; and material UI passes actual-audio, responsive, locale and accessibility E2E. Until those conditions are satisfied, Draft/open PRs and blocked release policies are expected safety states rather than reasons to bypass gates. @@ -51,6 +53,7 @@ Until those conditions are satisfied, Draft/open PRs and blocked release policie - Distribution admission: `docs/traceability/updater-release-admission.md` - Release receipt/publication: `docs/traceability/release-artifact-receipt.md` - Updater security metadata, provisional runtime admission, durable freshness state and replay/rollback model: `docs/traceability/updater-security-metadata.md` +- Bounded updater artifact streaming: `docs/traceability/updater-bounded-download.md` - Security trust boundaries: `docs/security/app-security.md` - Cross-platform release controls: `docs/security/cross-platform-build-policy.md` - Architecture ownership: `ARCHITECTURE.md` From 539fbe2482112dbc427016c86a5a8cd5d7cc182f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 05:05:09 +0900 Subject: [PATCH 110/308] docs(architecture): add bounded updater download owner --- ARCHITECTURE.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d70692297..1baaff6cc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -60,6 +60,7 @@ Last updated: 2026-09-15 - `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; returns provisional metadata only and cannot mutate freshness state +- `apps/desktop/distribution-download` - network-library-independent Rust streaming byte-admission boundary for updater artifacts; owns expected-size/content-length/chunk/cumulative limits and fail-closed sink error semantics, 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 @@ -68,9 +69,10 @@ Last updated: 2026-09-15 ## Distribution/update bounded context -- Distribution owns commercial release identity, native signing/notarization admission, updater policy, immutable publication evidence, highest-seen update freshness state, and last-known-good installer recovery decisions. +- 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 and invalid release-identity syntax, and it projects the fixed app-owned highest-seen path without creating or writing it. It deliberately has no `distribution-state` dependency. +- `apps/desktop/distribution-download` owns the pure streaming byte-admission 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 and exact-length completion. 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 and temporary sink to route actual response bytes through this boundary instead of 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. @@ -79,7 +81,7 @@ Last updated: 2026-09-15 - 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`, and `docs/traceability/updater-security-metadata.md`. +- Traceability and claim boundaries live in `docs/traceability/updater-release-admission.md`, `docs/traceability/release-artifact-receipt.md`, `docs/traceability/updater-security-metadata.md`, and `docs/traceability/updater-bounded-download.md`. ## Product capability scope @@ -118,7 +120,7 @@ Last updated: 2026-09-15 - 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`, 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. +- Distribution `distribution-core`, `distribution-runtime`, `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. From dcc04b78b7d51c5e79f39594ac4f02090930792e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 05:08:33 +0900 Subject: [PATCH 111/308] test(distribution): require crash-safe updater staging sink --- .../tests/staged_artifact.rs | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 apps/desktop/distribution-download/tests/staged_artifact.rs 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..b4d74e58e --- /dev/null +++ b/apps/desktop/distribution-download/tests/staged_artifact.rs @@ -0,0 +1,87 @@ +use bandscope_distribution_download::{ + ArtifactDownloadAdmission, StagedArtifactFile, StagingArtifactError, +}; +use std::fs; +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 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()); + fs::remove_dir(directory).expect("remove staging directory"); +} + +#[test] +fn admitted_exact_artifact_can_be_sealed_and_retained() { + 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.is_file()); + fs::remove_file(path).expect("remove sealed fixture"); + fs::remove_dir(directory).expect("remove staging 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()); + fs::remove_dir(directory).expect("remove staging directory"); +} + +#[test] +fn preexisting_destination_and_path_like_names_fail_closed() { + let directory = scratch_dir("exclusive"); + fs::write(directory.join("update.bin"), b"existing").expect("write existing file"); + + assert_eq!( + StagedArtifactFile::create(&directory, "update.bin").unwrap_err(), + StagingArtifactError::DestinationExists + ); + assert_eq!( + StagedArtifactFile::create(&directory, "../escape.bin").unwrap_err(), + StagingArtifactError::InvalidArtifactName + ); + + fs::remove_file(directory.join("update.bin")).expect("remove existing file"); + fs::remove_dir(directory).expect("remove staging directory"); +} From ed079fdc4b6150515a1352e892307d6b24bedf6e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 05:09:21 +0900 Subject: [PATCH 112/308] feat(distribution): add exclusive updater staging sink --- apps/desktop/distribution-download/src/lib.rs | 222 +++++++++++++++++- 1 file changed, 217 insertions(+), 5 deletions(-) diff --git a/apps/desktop/distribution-download/src/lib.rs b/apps/desktop/distribution-download/src/lib.rs index b7778f08d..a673a6601 100644 --- a/apps/desktop/distribution-download/src/lib.rs +++ b/apps/desktop/distribution-download/src/lib.rs @@ -2,19 +2,24 @@ //! //! The current Tauri updater returns verified artifacts as an in-memory byte //! vector. BandScope's commercial Distribution boundary needs an independent -//! streaming primitive before it can claim bounded hostile-response handling. -//! This crate owns only byte-count admission into a caller-provided sink. It -//! does not perform HTTP, metadata authentication, signature verification, -//! digest verification, installation, rollback, or project persistence. +//! 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}; use std::io::{ErrorKind, 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; /// Fail-closed reasons for bounded updater-artifact admission. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -35,6 +40,31 @@ pub enum DownloadAdmissionError { 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, + /// The exact staging destination already exists. + DestinationExists, + /// Exclusive staging-file creation 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 { @@ -124,7 +154,7 @@ impl ArtifactDownloadAdmission { Ok(()) } - /// Return bytes durably handed to the sink by successful chunk writes. + /// Return bytes handed successfully to the current sink. pub const fn received_size_bytes(&self) -> u64 { self.received_size_bytes } @@ -143,6 +173,178 @@ impl ArtifactDownloadAdmission { } } +/// Exclusive temporary artifact owned by the Distribution staging directory. +/// +/// Creation accepts one portable basename under an already-existing app-owned +/// non-symlink directory. The file is removed on drop unless `seal` succeeds. +/// Callers cannot write the descriptor directly; response bytes must pass +/// through `ArtifactDownloadAdmission` via `admit_chunk`. +#[derive(Debug)] +pub struct StagedArtifactFile { + file: Option, + path: PathBuf, + retain_on_drop: bool, +} + +impl StagedArtifactFile { + /// Create one new staging artifact without overwriting any existing path. + 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 path = staging_directory.join(artifact_name); + 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), + 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 prevents cleanup-on-drop and returns the still-open + /// descriptor so later digest/signature verification can remain bound to + /// the exact staged bytes rather than reopening an attacker-selected path. + 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"); + Ok(SealedArtifactFile { + file: sealed_file, + 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); + } +} + +/// Synchronized staging artifact kept open for later identity verification. +#[derive(Debug)] +pub struct SealedArtifactFile { + file: File, + path: PathBuf, + bytes_written: u64, +} + +impl SealedArtifactFile { + /// Return the synchronized staging path retained after a successful seal. + 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 + } + + /// Borrow the still-open descriptor for digest or signature verification. + pub const fn file(&self) -> &File { + &self.file + } +} + +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::*; @@ -269,4 +471,14 @@ mod tests { 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")); + } } From 762024843218a86567c855ee1474a10549a3032a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 05:09:59 +0900 Subject: [PATCH 113/308] test(distribution): cover updater staging failure cleanup --- .../tests/staged_artifact.rs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/apps/desktop/distribution-download/tests/staged_artifact.rs b/apps/desktop/distribution-download/tests/staged_artifact.rs index b4d74e58e..1bcbc0fdb 100644 --- a/apps/desktop/distribution-download/tests/staged_artifact.rs +++ b/apps/desktop/distribution-download/tests/staged_artifact.rs @@ -2,6 +2,7 @@ use bandscope_distribution_download::{ ArtifactDownloadAdmission, StagedArtifactFile, StagingArtifactError, }; use std::fs; +use std::io::ErrorKind; use std::time::{SystemTime, UNIX_EPOCH}; fn scratch_dir(label: &str) -> std::path::PathBuf { @@ -42,6 +43,7 @@ fn admitted_exact_artifact_can_be_sealed_and_retained() { let sealed = staged.seal(receipt).expect("sync and seal exact artifact"); assert_eq!(sealed.bytes_written(), 4); + assert_eq!(sealed.file().metadata().expect("descriptor metadata").len(), 4); assert_eq!(fs::metadata(sealed.path()).expect("sealed metadata").len(), 4); let path = sealed.path().to_path_buf(); drop(sealed); @@ -68,6 +70,23 @@ fn failed_admission_removes_partial_staging_file() { fs::remove_dir(directory).expect("remove staging 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()); + fs::remove_dir(directory).expect("remove staging directory"); +} + #[test] fn preexisting_destination_and_path_like_names_fail_closed() { let directory = scratch_dir("exclusive"); @@ -85,3 +104,44 @@ fn preexisting_destination_and_path_like_names_fail_closed() { fs::remove_file(directory.join("update.bin")).expect("remove existing file"); fs::remove_dir(directory).expect("remove staging 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"); + fs::remove_dir(directory).expect("remove staging 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"); + fs::remove_dir(directory).expect("remove staging directory"); +} From 00825bef3365d3dd3530c6a43c94912f3e04f4a3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 05:10:46 +0900 Subject: [PATCH 114/308] docs(distribution): trace exclusive updater staging lifecycle --- docs/traceability/updater-bounded-download.md | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/docs/traceability/updater-bounded-download.md b/docs/traceability/updater-bounded-download.md index a3d53abad..52c33d5c6 100644 --- a/docs/traceability/updater-bounded-download.md +++ b/docs/traceability/updater-bounded-download.md @@ -6,12 +6,15 @@ BandScope의 Distribution/update 경계는 updater artifact를 신뢰하기 전 `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 primitive를 Rust로 분리합니다. +이 단계에서는 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까지 추가했습니다. ## 실행 계약 @@ -25,7 +28,17 @@ BandScope의 Distribution/update 경계는 updater artifact를 신뢰하기 전 - response가 expected size보다 짧게 끝나면 `finish()`은 `Incomplete`를 반환합니다. - exact byte count를 모두 기록했을 때만 `DownloadReceipt`가 생성됩니다. -Unit 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를 다룹니다. Python production logic은 추가하지 않았고 repository harness는 locked Rust suite를 validation boundary로 호출합니다. +`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을 허용하지 않습니다. +- destination은 `create_new`로만 만들며 기존 file/symlink를 overwrite하지 않습니다. +- 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를 계속 열어 두므로 후속 digest/signature verification이 path reopen보다 exact staged bytes에 결합될 수 있습니다. + +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/retention, failed-admission cleanup, receipt mismatch, existing destination, path-like name, invalid staging root와 Unix symlink root를 다룹니다. Python production logic은 추가하지 않았고 repository harness는 locked Rust suite를 validation boundary로 호출합니다. ## 기각한 대안 @@ -33,17 +46,19 @@ Tauri의 기존 `download()` callback에서 누적 `chunk_length`만 세는 방 Declared `sizeBytes`와 `Content-Length`를 동일시하는 방식도 기각합니다. `Content-Length`는 transport metadata라서 없거나 거짓일 수 있으며, cumulative byte admission이 별도로 필요합니다. -전체 artifact를 먼저 `Vec`로 받은 뒤 길이를 검사하는 방식도 기각합니다. resource exhaustion이 일어난 뒤 검사하는 것이므로 commercial resource-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처럼 남길 수 있습니다. ## Claim boundary -현재 crate는 **network-independent streaming primitive**입니다. 실제 production updater가 아직 이 crate를 통해 HTTP body를 수신하지 않으므로 end-to-end bounded download가 완료됐다고 주장하지 않습니다. 또한 이 crate는 SHA-256, updater signature, metadata authenticity, installer trust를 검증하지 않습니다. +현재 crate는 **network-library-independent streaming + staging primitive**입니다. 실제 production updater가 아직 이 crate를 통해 HTTP body를 수신하지 않으므로 end-to-end bounded download가 완료됐다고 주장하지 않습니다. 또한 `sync_all()`과 cleanup tests를 packaged Windows/macOS power-loss durability와 동일시하지 않습니다. 이 crate는 SHA-256, updater signature, metadata authenticity, installer trust도 검증하지 않습니다. -다음 repository-owned 단계는 production network adapter가 full-response buffering 없이 bounded chunks를 이 primitive에 전달하고, 임시 artifact sink의 disk-full/cancel/cleanup을 fail closed하게 처리하도록 연결하는 것입니다. 그 뒤 organization-approved updater key가 provision되면 verified artifact bytes의 signature와 digest/size를 authenticated release identity에 묶고, 그 시점에만 `distribution-core`와 `distribution-state`로 freshness authority를 넘깁니다. +다음 repository-owned 단계는 production network adapter가 full-response buffering 없이 bounded chunks를 이 primitive에 전달하도록 연결하는 것입니다. 그 adapter는 canonical release origin/redirect 정책을 보존하고, cancel/network error/disk-full을 staged-file cleanup으로 귀결시켜야 합니다. 그 뒤 organization-approved updater key가 provision되면 still-open sealed descriptor의 signature와 digest/size를 authenticated release identity에 묶고, 그 시점에만 `distribution-core`와 `distribution-state`로 freshness authority를 넘깁니다. ## Security Notes -Attack surface는 updater HTTP response body, transport length metadata, temporary artifact sink와 cancellation/error paths입니다. Remote response는 canonical release namespace를 통과해도 untrusted입니다. Byte admission failure는 installer 실행이나 highest-seen state mutation으로 승격되지 않아야 하며, sink path는 Distribution-owned app storage로 제한해야 합니다. Audio/project bytes나 paths는 updater request/receipt에 포함하지 않습니다. +Attack surface는 updater HTTP response body, transport length metadata, temporary artifact directory/path, 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로 제한해야 합니다. Cleanup은 app-owned non-symlink directory라는 전제 안에서만 pathname removal을 수행합니다. Audio/project bytes나 paths는 updater request/receipt에 포함하지 않습니다. ## References From e485b3a626aaf15001831f602a6c45a53aab96a6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 05:11:06 +0900 Subject: [PATCH 115/308] docs(product): record updater staging boundary --- docs/product-technical-gap-baseline.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index f9f5e38eb..02605761d 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -20,7 +20,7 @@ The product keeps BandScope-specific audio/rehearsal truth inside BandScope. Org | Project Persistence | Project/cache integrity and scientific cache equivalence have dedicated owner work. | Crash/power-loss, disk-full, interrupted write/recovery, last-known-good project state and packaged-OS fault injection remain buyer gates. | | Collaboration Handoff | Export/handoff belongs to BandScope without creating a second collaboration platform. | Only released, bounded artifacts should cross product boundaries; mutable shared DB or cross-service SQL is not accepted. | | Diagnostics | Existing harness/security/build evidence is substantial. | Buyer-safe diagnostics must avoid audio/project/credential leakage and distinguish user cancel, provider/runtime failure, corrupt project and release/update failure. | -| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. `distribution-core` and `distribution-state` define deterministic replay/rollback policy and durable highest-seen storage for already-authenticated release identity. `distribution-runtime` bounded-parses static updater JSON only as provisional remote input. `distribution-download` now provides a dependency-free Rust byte-admission primitive that enforces expected size, optional `Content-Length`, per-chunk and cumulative ceilings, sink-failure poisoning and exact-completion receipts. | The current Tauri artifact signature verifies downloaded updater bytes, not the whole `raw_json` response. Remote release identity therefore still needs independent metadata authentication. The new download primitive is not yet wired to a production HTTP adapter, so end-to-end hostile-response memory/disk bounds, temporary-file cleanup, disk-full/cancel recovery and verified artifact digest/signature binding remain gates. Production updater key/endpoint, packaged restart/power-loss/recovery, Windows/macOS signing/notarization and commercial model rights also remain open. | +| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. `distribution-core` and `distribution-state` define deterministic replay/rollback policy and durable highest-seen storage for already-authenticated release identity. `distribution-runtime` bounded-parses static updater JSON only as provisional remote input. `distribution-download` now owns dependency-free Rust byte admission plus an exclusive app-owned staging lifecycle: bounded expected/content-length/chunk/cumulative sizes, sink-failure poisoning, cancel/error cleanup, `create_new` destination admission, flush/`sync_all`, descriptor size verification and still-open sealed descriptors. | The current Tauri artifact signature verifies downloaded updater bytes, not the whole `raw_json` response. Remote release identity therefore still needs independent metadata authentication. The new download/staging primitives are not yet wired to a production HTTP adapter, so end-to-end hostile-response bounds, redirect/origin behavior, real disk-full/cancel/network-error cleanup and verified artifact digest/signature binding remain gates. Packaged power-loss evidence, production updater key/endpoint, Windows/macOS signing/notarization and commercial model rights also remain open. | | UI / Interaction | Rehearsal-first UI is the product surface; Anti-Slop and accessibility are acceptance criteria, not decoration. | Normal/loading/empty/error/permission/responsive states, KO/EN/JA/ZH/VI/ES/DE/FR expansion/fallback, keyboard/focus/contrast/state semantics and actual-audio E2E must be verified on the exact release candidate. | ## Distribution/update decision boundary @@ -36,15 +36,15 @@ The Rust `apps/desktop/distribution-core` is the deterministic decision layer af `apps/desktop/distribution-runtime` is the narrow remote-metadata adapter. It accepts at most 256 KiB of UTF-8 JSON, rejects duplicate/unknown members, enforces the exact four desktop targets, bounds signature/URL/artifact-size fields, pins exact-tag URLs to the current BandScope GitHub release namespace and delegates release-identity syntax to `distribution-core`. Its result type is explicitly provisional. It has no durable-state dependency and cannot write highest-seen state from syntactically valid `raw_json` alone. -`apps/desktop/distribution-download` is a separate network-library-independent streaming boundary. It rejects zero/over-ceiling expected sizes, optional `Content-Length` mismatch, chunks larger than 1 MiB, cumulative overrun before the offending bytes reach the sink, truncated completion and sink-write failure. A failed attempt is poisoned so later chunks cannot manufacture a success receipt. This closes the pure byte-admission primitive gap but does not claim that the current Tauri updater path routes its HTTP body through the primitive. +`apps/desktop/distribution-download` is a separate network-library-independent streaming/staging boundary. It rejects zero/over-ceiling expected sizes, optional `Content-Length` mismatch, chunks larger than 1 MiB, cumulative overrun before the offending bytes reach the sink, truncated completion and sink-write failure. A failed attempt is poisoned so later chunks cannot manufacture a success receipt. Its staging file uses a bounded portable basename under an existing non-symlink app-owned directory, exclusive `create_new`, cleanup on cancel/error, and flush/`sync_all` plus descriptor size verification before returning a still-open sealed artifact. This closes the pure byte and local staging primitive gaps but does not claim that the current Tauri updater path routes its HTTP body through them. Highest-seen update identity remains Distribution state, not Project Persistence state. `apps/desktop/distribution-state` provides a separate bounded append-only Rust log that revalidates committed identities, rejects local version regression/equivocation, synchronizes successful appends and recovers only a syntactically valid torn final record prefix. It deliberately does not claim packaged power-loss equivalence across Windows/macOS until platform fault-injection evidence exists. Project Persistence remains authoritative only for project bytes and the project-schema evidence used by rollback compatibility checks. -Current Tauri updater APIs still materialize a verified update as in-memory bytes. The repository now owns a stricter streaming byte-admission primitive, but commercial readiness requires a production network adapter that actually streams bounded response chunks into that boundary and a bounded temporary sink. Counting progress callbacks or checking the fully buffered `Vec` after download is not equivalent evidence. +Current Tauri updater APIs still materialize a verified update as in-memory bytes. The repository now owns stricter streaming and staging primitives, but commercial readiness requires a production network adapter that actually streams bounded response chunks into that boundary while preserving canonical origin/redirect policy. Counting progress callbacks or checking the fully buffered `Vec` after download is not equivalent evidence. `sync_all()` in a unit/integration test is also not packaged Windows/macOS power-loss proof. ## Release gate -A release candidate is not commercial-ready until all of the following are true on the exact protected head: required checks and independent review are terminal/qualifying; Windows artifacts are signed by the approved publisher and macOS artifacts are signed/notarized/stapled; updater authority is admitted without placeholder values; remote updater metadata has an authenticated binding before it can mutate freshness state; updater artifact bytes are signature-verified and matched to authenticated digest/size evidence; updater replay/rollback/recovery is exercised on packaged targets; production HTTP download actually passes through bounded streaming/resource admission and survives hostile/truncated/oversized/disk-full/cancel cases; SBOM/NOTICE/provenance agree with exact shipped bytes; model rights and exact model provenance are established; rights-cleared real-audio scientific acceptance is reproducible; the updater can recover to a compatible known-good build without losing project usability; and material UI passes actual-audio, responsive, locale and accessibility E2E. +A release candidate is not commercial-ready until all of the following are true on the exact protected head: required checks and independent review are terminal/qualifying; Windows artifacts are signed by the approved publisher and macOS artifacts are signed/notarized/stapled; updater authority is admitted without placeholder values; remote updater metadata has an authenticated binding before it can mutate freshness state; updater artifact bytes are signature-verified and matched to authenticated digest/size evidence; updater replay/rollback/recovery is exercised on packaged targets; production HTTP download actually passes through bounded streaming/staging admission and survives hostile/truncated/oversized/disk-full/cancel/network-error cases; SBOM/NOTICE/provenance agree with exact shipped bytes; model rights and exact model provenance are established; rights-cleared real-audio scientific acceptance is reproducible; the updater can recover to a compatible known-good build without losing project usability; and material UI passes actual-audio, responsive, locale and accessibility E2E. Until those conditions are satisfied, Draft/open PRs and blocked release policies are expected safety states rather than reasons to bypass gates. @@ -53,7 +53,7 @@ Until those conditions are satisfied, Draft/open PRs and blocked release policie - Distribution admission: `docs/traceability/updater-release-admission.md` - Release receipt/publication: `docs/traceability/release-artifact-receipt.md` - Updater security metadata, provisional runtime admission, durable freshness state and replay/rollback model: `docs/traceability/updater-security-metadata.md` -- Bounded updater artifact streaming: `docs/traceability/updater-bounded-download.md` +- Bounded updater artifact streaming/staging: `docs/traceability/updater-bounded-download.md` - Security trust boundaries: `docs/security/app-security.md` - Cross-platform release controls: `docs/security/cross-platform-build-policy.md` - Architecture ownership: `ARCHITECTURE.md` From a956bcfab7670aa7a461c8929c75cda8b79ba118 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 06:04:36 +0900 Subject: [PATCH 116/308] test(distribution): remove unverified sealed artifacts on drop --- apps/desktop/distribution-download/tests/staged_artifact.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/desktop/distribution-download/tests/staged_artifact.rs b/apps/desktop/distribution-download/tests/staged_artifact.rs index 1bcbc0fdb..4d17c3f65 100644 --- a/apps/desktop/distribution-download/tests/staged_artifact.rs +++ b/apps/desktop/distribution-download/tests/staged_artifact.rs @@ -32,7 +32,7 @@ fn cancelled_staging_file_is_removed_on_drop() { } #[test] -fn admitted_exact_artifact_can_be_sealed_and_retained() { +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"); @@ -48,8 +48,7 @@ fn admitted_exact_artifact_can_be_sealed_and_retained() { let path = sealed.path().to_path_buf(); drop(sealed); - assert!(path.is_file()); - fs::remove_file(path).expect("remove sealed fixture"); + assert!(!path.exists()); fs::remove_dir(directory).expect("remove staging directory"); } From e76abddb0c40293901cd8672919172d47a93b5b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 06:06:24 +0900 Subject: [PATCH 117/308] fix(distribution): clean sealed artifacts until trust promotion --- apps/desktop/distribution-download/src/lib.rs | 43 +++++++++++++------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/apps/desktop/distribution-download/src/lib.rs b/apps/desktop/distribution-download/src/lib.rs index a673a6601..e2bf88652 100644 --- a/apps/desktop/distribution-download/src/lib.rs +++ b/apps/desktop/distribution-download/src/lib.rs @@ -176,9 +176,10 @@ impl ArtifactDownloadAdmission { /// Exclusive temporary artifact owned by the Distribution staging directory. /// /// Creation accepts one portable basename under an already-existing app-owned -/// non-symlink directory. The file is removed on drop unless `seal` succeeds. -/// Callers cannot write the descriptor directly; response bytes must pass -/// through `ArtifactDownloadAdmission` via `admit_chunk`. +/// non-symlink directory. The file is removed on drop unless `seal` transfers +/// cleanup 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, @@ -242,9 +243,11 @@ impl StagedArtifactFile { /// Flush, synchronize, and descriptor-check an exactly downloaded artifact. /// - /// A successful seal prevents cleanup-on-drop and returns the still-open - /// descriptor so later digest/signature verification can remain bound to - /// the exact staged bytes rather than reopening an attacker-selected path. + /// A successful seal transfers cleanup responsibility 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, @@ -273,7 +276,7 @@ impl StagedArtifactFile { .take() .expect("staged artifact descriptor remains present after validation"); Ok(SealedArtifactFile { - file: sealed_file, + file: Some(sealed_file), path: self.path.clone(), bytes_written: receipt.bytes_written(), }) @@ -292,16 +295,21 @@ impl Drop for StagedArtifactFile { } } -/// Synchronized staging artifact kept open for later identity verification. +/// Synchronized but still unverified staging artifact. +/// +/// The descriptor stays 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. A later trust-promotion +/// type, not this byte-count boundary, must explicitly retain verified bytes. #[derive(Debug)] pub struct SealedArtifactFile { - file: File, + file: Option, path: PathBuf, bytes_written: u64, } impl SealedArtifactFile { - /// Return the synchronized staging path retained after a successful seal. + /// Return the synchronized staging path held for identity verification. pub fn path(&self) -> &Path { &self.path } @@ -312,8 +320,19 @@ impl SealedArtifactFile { } /// Borrow the still-open descriptor for digest or signature verification. - pub const fn file(&self) -> &File { - &self.file + pub fn file(&self) -> &File { + self.file + .as_ref() + .expect("sealed artifact descriptor remains present before drop") + } +} + +impl Drop for SealedArtifactFile { + fn drop(&mut self) { + if let Some(file) = self.file.take() { + drop(file); + } + let _ = fs::remove_file(&self.path); } } From 290913305c739268f2aebd977e3bb64a2aa0e73e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 06:07:08 +0900 Subject: [PATCH 118/308] docs(distribution): trace sealed-artifact cleanup before trust promotion --- docs/traceability/updater-bounded-download.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/traceability/updater-bounded-download.md b/docs/traceability/updater-bounded-download.md index 52c33d5c6..dbc2a90c3 100644 --- a/docs/traceability/updater-bounded-download.md +++ b/docs/traceability/updater-bounded-download.md @@ -15,6 +15,8 @@ BandScope의 Distribution/update 경계는 updater artifact를 신뢰하기 전 - 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 경로도 없습니다. ## 실행 계약 @@ -37,8 +39,9 @@ BandScope의 Distribution/update 경계는 updater artifact를 신뢰하기 전 - cancel, overrun, sink failure 또는 seal failure 상태로 drop되면 partial staging path를 유지하지 않습니다. - seal은 userspace flush와 descriptor `sync_all()` 이후 descriptor가 regular file인지, exact receipt size와 같은지 다시 확인합니다. - 성공한 `SealedArtifactFile`은 descriptor를 계속 열어 두므로 후속 digest/signature verification이 path reopen보다 exact staged bytes에 결합될 수 있습니다. +- exact-size seal은 신뢰 승격이 아닙니다. `SealedArtifactFile` 자체는 cleanup-on-drop이며 descriptor를 먼저 닫은 다음 staging path를 제거합니다. 후속 digest/signature/authenticated-metadata 결합이 성공하기 전에는 unverified bytes가 정상 종료 경로에서 남지 않습니다. -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/retention, failed-admission cleanup, receipt mismatch, existing destination, path-like name, invalid staging root와 Unix symlink root를 다룹니다. Python production logic은 추가하지 않았고 repository harness는 locked Rust suite를 validation boundary로 호출합니다. +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, failed-admission cleanup, receipt mismatch, existing destination, path-like name, invalid staging root와 Unix symlink root를 다룹니다. Python production logic은 추가하지 않았고 repository harness는 locked Rust suite를 validation boundary로 호출합니다. ## 기각한 대안 @@ -50,11 +53,13 @@ Declared `sizeBytes`와 `Content-Length`를 동일시하는 방식도 기각합 Generic temporary pathname에 overwrite-open하고 나중에 검사하는 방식도 기각합니다. Existing file/symlink를 교체하거나 path-like name이 app-owned staging root를 벗어날 수 있고, cancel/error 뒤 partial artifact를 성공 candidate처럼 남길 수 있습니다. +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에 잔존할 수 있습니다. + ## Claim boundary 현재 crate는 **network-library-independent streaming + staging primitive**입니다. 실제 production updater가 아직 이 crate를 통해 HTTP body를 수신하지 않으므로 end-to-end bounded download가 완료됐다고 주장하지 않습니다. 또한 `sync_all()`과 cleanup tests를 packaged Windows/macOS power-loss 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 정책을 보존하고, cancel/network error/disk-full을 staged-file cleanup으로 귀결시켜야 합니다. 그 뒤 organization-approved updater key가 provision되면 still-open sealed descriptor의 signature와 digest/size를 authenticated release identity에 묶고, 그 시점에만 `distribution-core`와 `distribution-state`로 freshness authority를 넘깁니다. +다음 repository-owned 단계는 production network adapter가 full-response buffering 없이 bounded chunks를 이 primitive에 전달하도록 연결하는 것입니다. 그 adapter는 canonical release origin/redirect 정책을 보존하고, 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 From a065cebce80130271e867150d4348b2a82c5758c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 06:07:26 +0900 Subject: [PATCH 119/308] docs(product): keep sealed updater bytes provisional --- docs/product-technical-gap-baseline.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 02605761d..25e8c8692 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -20,7 +20,7 @@ The product keeps BandScope-specific audio/rehearsal truth inside BandScope. Org | Project Persistence | Project/cache integrity and scientific cache equivalence have dedicated owner work. | Crash/power-loss, disk-full, interrupted write/recovery, last-known-good project state and packaged-OS fault injection remain buyer gates. | | Collaboration Handoff | Export/handoff belongs to BandScope without creating a second collaboration platform. | Only released, bounded artifacts should cross product boundaries; mutable shared DB or cross-service SQL is not accepted. | | Diagnostics | Existing harness/security/build evidence is substantial. | Buyer-safe diagnostics must avoid audio/project/credential leakage and distinguish user cancel, provider/runtime failure, corrupt project and release/update failure. | -| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. `distribution-core` and `distribution-state` define deterministic replay/rollback policy and durable highest-seen storage for already-authenticated release identity. `distribution-runtime` bounded-parses static updater JSON only as provisional remote input. `distribution-download` now owns dependency-free Rust byte admission plus an exclusive app-owned staging lifecycle: bounded expected/content-length/chunk/cumulative sizes, sink-failure poisoning, cancel/error cleanup, `create_new` destination admission, flush/`sync_all`, descriptor size verification and still-open sealed descriptors. | The current Tauri artifact signature verifies downloaded updater bytes, not the whole `raw_json` response. Remote release identity therefore still needs independent metadata authentication. The new download/staging primitives are not yet wired to a production HTTP adapter, so end-to-end hostile-response bounds, redirect/origin behavior, real disk-full/cancel/network-error cleanup and verified artifact digest/signature binding remain gates. Packaged power-loss evidence, production updater key/endpoint, Windows/macOS signing/notarization and commercial model rights also remain open. | +| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. `distribution-core` and `distribution-state` define deterministic replay/rollback policy and durable highest-seen storage for already-authenticated release identity. `distribution-runtime` bounded-parses static updater JSON only as provisional remote input. `distribution-download` owns dependency-free Rust byte admission plus an exclusive app-owned staging lifecycle: bounded expected/content-length/chunk/cumulative sizes, sink-failure poisoning, cancel/error cleanup, `create_new` destination admission, flush/`sync_all`, descriptor size verification and still-open sealed descriptors. Exact-size seal is explicitly not trust promotion; sealed-but-unverified artifacts now remain cleanup-on-drop until a later verified-artifact boundary exists. | The current Tauri artifact signature verifies downloaded updater bytes, not the whole `raw_json` response. Remote release identity therefore still needs independent metadata authentication. The download/staging primitives are not yet wired to a production HTTP adapter, so end-to-end hostile-response bounds, redirect/origin behavior, real disk-full/cancel/network-error cleanup and verified artifact digest/signature binding remain gates. A verified-artifact promotion type/path must be added so only successfully authenticated bytes can outlive the verification scope. Packaged power-loss evidence, production updater key/endpoint, Windows/macOS signing/notarization and commercial model rights also remain open. | | UI / Interaction | Rehearsal-first UI is the product surface; Anti-Slop and accessibility are acceptance criteria, not decoration. | Normal/loading/empty/error/permission/responsive states, KO/EN/JA/ZH/VI/ES/DE/FR expansion/fallback, keyboard/focus/contrast/state semantics and actual-audio E2E must be verified on the exact release candidate. | ## Distribution/update decision boundary @@ -36,7 +36,7 @@ The Rust `apps/desktop/distribution-core` is the deterministic decision layer af `apps/desktop/distribution-runtime` is the narrow remote-metadata adapter. It accepts at most 256 KiB of UTF-8 JSON, rejects duplicate/unknown members, enforces the exact four desktop targets, bounds signature/URL/artifact-size fields, pins exact-tag URLs to the current BandScope GitHub release namespace and delegates release-identity syntax to `distribution-core`. Its result type is explicitly provisional. It has no durable-state dependency and cannot write highest-seen state from syntactically valid `raw_json` alone. -`apps/desktop/distribution-download` is a separate network-library-independent streaming/staging boundary. It rejects zero/over-ceiling expected sizes, optional `Content-Length` mismatch, chunks larger than 1 MiB, cumulative overrun before the offending bytes reach the sink, truncated completion and sink-write failure. A failed attempt is poisoned so later chunks cannot manufacture a success receipt. Its staging file uses a bounded portable basename under an existing non-symlink app-owned directory, exclusive `create_new`, cleanup on cancel/error, and flush/`sync_all` plus descriptor size verification before returning a still-open sealed artifact. This closes the pure byte and local staging primitive gaps but does not claim that the current Tauri updater path routes its HTTP body through them. +`apps/desktop/distribution-download` is a separate network-library-independent streaming/staging boundary. It rejects zero/over-ceiling expected sizes, optional `Content-Length` mismatch, chunks larger than 1 MiB, cumulative overrun before the offending bytes reach the sink, truncated completion and sink-write failure. A failed attempt is poisoned so later chunks cannot manufacture a success receipt. Its staging file uses a bounded portable basename under an existing non-symlink app-owned directory, exclusive `create_new`, cleanup on cancel/error, and flush/`sync_all` plus descriptor size verification before returning a still-open sealed artifact. The sealed artifact remains cleanup-on-drop because exact byte count and `sync_all()` do not establish digest, signature or metadata authenticity. This closes the pure byte/local-staging primitive gap but does not claim that the current Tauri updater path routes its HTTP body through them or that any unverified staged bytes are safe to retain. Highest-seen update identity remains Distribution state, not Project Persistence state. `apps/desktop/distribution-state` provides a separate bounded append-only Rust log that revalidates committed identities, rejects local version regression/equivocation, synchronizes successful appends and recovers only a syntactically valid torn final record prefix. It deliberately does not claim packaged power-loss equivalence across Windows/macOS until platform fault-injection evidence exists. Project Persistence remains authoritative only for project bytes and the project-schema evidence used by rollback compatibility checks. @@ -44,7 +44,7 @@ Current Tauri updater APIs still materialize a verified update as in-memory byte ## Release gate -A release candidate is not commercial-ready until all of the following are true on the exact protected head: required checks and independent review are terminal/qualifying; Windows artifacts are signed by the approved publisher and macOS artifacts are signed/notarized/stapled; updater authority is admitted without placeholder values; remote updater metadata has an authenticated binding before it can mutate freshness state; updater artifact bytes are signature-verified and matched to authenticated digest/size evidence; updater replay/rollback/recovery is exercised on packaged targets; production HTTP download actually passes through bounded streaming/staging admission and survives hostile/truncated/oversized/disk-full/cancel/network-error cases; SBOM/NOTICE/provenance agree with exact shipped bytes; model rights and exact model provenance are established; rights-cleared real-audio scientific acceptance is reproducible; the updater can recover to a compatible known-good build without losing project usability; and material UI passes actual-audio, responsive, locale and accessibility E2E. +A release candidate is not commercial-ready until all of the following are true on the exact protected head: required checks and independent review are terminal/qualifying; Windows artifacts are signed by the approved publisher and macOS artifacts are signed/notarized/stapled; updater authority is admitted without placeholder values; remote updater metadata has an authenticated binding before it can mutate freshness state; updater artifact bytes are signature-verified and matched to authenticated digest/size evidence; only verified bytes can be explicitly promoted beyond sealed cleanup scope; updater replay/rollback/recovery is exercised on packaged targets; production HTTP download actually passes through bounded streaming/staging admission and survives hostile/truncated/oversized/disk-full/cancel/network-error cases; SBOM/NOTICE/provenance agree with exact shipped bytes; model rights and exact model provenance are established; rights-cleared real-audio scientific acceptance is reproducible; the updater can recover to a compatible known-good build without losing project usability; and material UI passes actual-audio, responsive, locale and accessibility E2E. Until those conditions are satisfied, Draft/open PRs and blocked release policies are expected safety states rather than reasons to bypass gates. From 56aa7467a43299500e79d2e26469b252ae9519c0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 07:01:15 +0900 Subject: [PATCH 120/308] test(distribution): require sealed read-only descriptor stream --- .../tests/sealed_reader.rs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 apps/desktop/distribution-download/tests/sealed_reader.rs 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..b6e10310a --- /dev/null +++ b/apps/desktop/distribution-download/tests/sealed_reader.rs @@ -0,0 +1,43 @@ +use bandscope_distribution_download::{ArtifactDownloadAdmission, StagedArtifactFile}; +use std::fs; +use std::io::Read; +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"); +} From 13ca9b7862f59c06f5dcc0337c846c050a3c7199 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 07:01:38 +0900 Subject: [PATCH 121/308] test(distribution): stop depending on writable sealed handle --- apps/desktop/distribution-download/tests/staged_artifact.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/desktop/distribution-download/tests/staged_artifact.rs b/apps/desktop/distribution-download/tests/staged_artifact.rs index 4d17c3f65..57914e758 100644 --- a/apps/desktop/distribution-download/tests/staged_artifact.rs +++ b/apps/desktop/distribution-download/tests/staged_artifact.rs @@ -43,7 +43,6 @@ fn sealed_but_unverified_artifact_is_removed_on_drop() { let sealed = staged.seal(receipt).expect("sync and seal exact artifact"); assert_eq!(sealed.bytes_written(), 4); - assert_eq!(sealed.file().metadata().expect("descriptor metadata").len(), 4); assert_eq!(fs::metadata(sealed.path()).expect("sealed metadata").len(), 4); let path = sealed.path().to_path_buf(); drop(sealed); From 6144302ed807367742f87247b353742f213dbedb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 07:03:02 +0900 Subject: [PATCH 122/308] fix(distribution): withhold writable sealed artifact handle --- apps/desktop/distribution-download/src/lib.rs | 69 +++++++++++++++++-- 1 file changed, 63 insertions(+), 6 deletions(-) diff --git a/apps/desktop/distribution-download/src/lib.rs b/apps/desktop/distribution-download/src/lib.rs index e2bf88652..80db9d6a0 100644 --- a/apps/desktop/distribution-download/src/lib.rs +++ b/apps/desktop/distribution-download/src/lib.rs @@ -11,7 +11,7 @@ #![forbid(unsafe_code)] use std::fs::{self, File, OpenOptions}; -use std::io::{ErrorKind, Write}; +use std::io::{ErrorKind, Read, Write}; use std::path::{Path, PathBuf}; /// Hard ceiling for one updater artifact accepted by the Distribution boundary. @@ -308,6 +308,35 @@ pub struct SealedArtifactFile { 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 wrapper intentionally implements `Read` only: callers cannot use +/// it to recover the underlying write-capable staging descriptor. +#[derive(Debug)] +pub struct SealedArtifactReader<'a> { + file: &'a File, + offset: u64, +} + +impl Read for SealedArtifactReader<'_> { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + if buffer.is_empty() { + return Ok(0); + } + let read = descriptor_read_at(self.file, buffer, self.offset)?; + self.offset = self + .offset + .checked_add(u64::try_from(read).map_err(|_| { + std::io::Error::new(ErrorKind::InvalidData, "sealed read length overflow") + })?) + .ok_or_else(|| { + std::io::Error::new(ErrorKind::InvalidData, "sealed reader offset overflow") + })?; + Ok(read) + } +} + impl SealedArtifactFile { /// Return the synchronized staging path held for identity verification. pub fn path(&self) -> &Path { @@ -319,11 +348,19 @@ impl SealedArtifactFile { self.bytes_written } - /// Borrow the still-open descriptor for digest or signature verification. - pub fn file(&self) -> &File { - self.file - .as_ref() - .expect("sealed artifact descriptor remains present before drop") + /// Open a read-only positional stream over the exact sealed descriptor. + /// + /// The stream starts at byte zero and never reopens the staging path. This + /// preserves descriptor binding while withholding 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, + } } } @@ -336,6 +373,26 @@ impl Drop for SealedArtifactFile { } } +#[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; From fdd97058a783a2473f3ee07cc1a3c212b0e7db0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 07:04:30 +0900 Subject: [PATCH 123/308] docs(traceability): bind sealed updater reads to descriptor --- docs/traceability/updater-bounded-download.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/traceability/updater-bounded-download.md b/docs/traceability/updater-bounded-download.md index dbc2a90c3..7c9b312fd 100644 --- a/docs/traceability/updater-bounded-download.md +++ b/docs/traceability/updater-bounded-download.md @@ -17,6 +17,9 @@ BandScope의 Distribution/update 경계는 updater artifact를 신뢰하기 전 - 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가 없습니다. ## 실행 계약 @@ -39,9 +42,10 @@ BandScope의 Distribution/update 경계는 updater artifact를 신뢰하기 전 - cancel, overrun, sink failure 또는 seal failure 상태로 drop되면 partial staging path를 유지하지 않습니다. - seal은 userspace flush와 descriptor `sync_all()` 이후 descriptor가 regular file인지, exact receipt size와 같은지 다시 확인합니다. - 성공한 `SealedArtifactFile`은 descriptor를 계속 열어 두므로 후속 digest/signature verification이 path reopen보다 exact staged bytes에 결합될 수 있습니다. +- 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를 얻지 않습니다. - exact-size seal은 신뢰 승격이 아닙니다. `SealedArtifactFile` 자체는 cleanup-on-drop이며 descriptor를 먼저 닫은 다음 staging path를 제거합니다. 후속 digest/signature/authenticated-metadata 결합이 성공하기 전에는 unverified bytes가 정상 종료 경로에서 남지 않습니다. -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, failed-admission cleanup, receipt mismatch, existing destination, path-like name, invalid staging root와 Unix symlink root를 다룹니다. Python production logic은 추가하지 않았고 repository harness는 locked Rust suite를 validation boundary로 호출합니다. +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, failed-admission cleanup, receipt mismatch, existing destination, path-like name, invalid staging root와 Unix symlink root를 다룹니다. Python production logic은 추가하지 않았고 repository harness는 locked Rust suite를 validation boundary로 호출합니다. ## 기각한 대안 @@ -55,6 +59,8 @@ Generic temporary pathname에 overwrite-open하고 나중에 검사하는 방식 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를 사용합니다. + ## Claim boundary 현재 crate는 **network-library-independent streaming + staging primitive**입니다. 실제 production updater가 아직 이 crate를 통해 HTTP body를 수신하지 않으므로 end-to-end bounded download가 완료됐다고 주장하지 않습니다. 또한 `sync_all()`과 cleanup tests를 packaged Windows/macOS power-loss durability와 동일시하지 않습니다. 이 crate는 SHA-256, updater signature, metadata authenticity, installer trust도 검증하지 않습니다. @@ -63,10 +69,16 @@ Exact-size seal을 곧바로 artifact retention으로 취급하는 방식도 기 ## Security Notes -Attack surface는 updater HTTP response body, transport length metadata, temporary artifact directory/path, 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로 제한해야 합니다. Cleanup은 app-owned non-symlink directory라는 전제 안에서만 pathname removal을 수행합니다. Audio/project bytes나 paths는 updater request/receipt에 포함하지 않습니다. +Attack surface는 updater HTTP response body, transport length metadata, temporary artifact directory/path, 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로 제한해야 합니다. Cleanup은 app-owned non-symlink directory라는 전제 안에서만 pathname removal을 수행합니다. Sealed descriptor의 raw write capability는 verifier에 노출하지 않으며, 후속 검증은 descriptor-bound read-only stream을 사용해야 합니다. 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). *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 From 5568e22a485be05c75bfb8a50ab7e0da116d1a84 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 07:04:49 +0900 Subject: [PATCH 124/308] docs(product): keep sealed updater descriptor read-only --- docs/product-technical-gap-baseline.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 25e8c8692..8b209ad08 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -20,7 +20,7 @@ The product keeps BandScope-specific audio/rehearsal truth inside BandScope. Org | Project Persistence | Project/cache integrity and scientific cache equivalence have dedicated owner work. | Crash/power-loss, disk-full, interrupted write/recovery, last-known-good project state and packaged-OS fault injection remain buyer gates. | | Collaboration Handoff | Export/handoff belongs to BandScope without creating a second collaboration platform. | Only released, bounded artifacts should cross product boundaries; mutable shared DB or cross-service SQL is not accepted. | | Diagnostics | Existing harness/security/build evidence is substantial. | Buyer-safe diagnostics must avoid audio/project/credential leakage and distinguish user cancel, provider/runtime failure, corrupt project and release/update failure. | -| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. `distribution-core` and `distribution-state` define deterministic replay/rollback policy and durable highest-seen storage for already-authenticated release identity. `distribution-runtime` bounded-parses static updater JSON only as provisional remote input. `distribution-download` owns dependency-free Rust byte admission plus an exclusive app-owned staging lifecycle: bounded expected/content-length/chunk/cumulative sizes, sink-failure poisoning, cancel/error cleanup, `create_new` destination admission, flush/`sync_all`, descriptor size verification and still-open sealed descriptors. Exact-size seal is explicitly not trust promotion; sealed-but-unverified artifacts now remain cleanup-on-drop until a later verified-artifact boundary exists. | The current Tauri artifact signature verifies downloaded updater bytes, not the whole `raw_json` response. Remote release identity therefore still needs independent metadata authentication. The download/staging primitives are not yet wired to a production HTTP adapter, so end-to-end hostile-response bounds, redirect/origin behavior, real disk-full/cancel/network-error cleanup and verified artifact digest/signature binding remain gates. A verified-artifact promotion type/path must be added so only successfully authenticated bytes can outlive the verification scope. Packaged power-loss evidence, production updater key/endpoint, Windows/macOS signing/notarization and commercial model rights also remain open. | +| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. `distribution-core` and `distribution-state` define deterministic replay/rollback policy and durable highest-seen storage for already-authenticated release identity. `distribution-runtime` bounded-parses static updater JSON only as provisional remote input. `distribution-download` owns dependency-free Rust byte admission plus an exclusive app-owned staging lifecycle: bounded expected/content-length/chunk/cumulative sizes, sink-failure poisoning, cancel/error cleanup, `create_new` destination admission, flush/`sync_all`, descriptor size verification and still-open sealed descriptors. Exact-size seal is explicitly not trust promotion; sealed-but-unverified artifacts remain cleanup-on-drop and verifier access is now a descriptor-bound positional `Read` stream rather than the underlying write-capable `File`. | The current Tauri artifact signature verifies downloaded updater bytes, not the whole `raw_json` response. Remote release identity therefore still needs independent metadata authentication. The download/staging primitives are not yet wired to a production HTTP adapter, so end-to-end hostile-response bounds, redirect/origin behavior, real disk-full/cancel/network-error cleanup and verified artifact digest/signature binding remain gates. A verified-artifact promotion type/path must be added so only successfully authenticated bytes can outlive the verification scope. Packaged power-loss evidence, production updater key/endpoint, Windows/macOS signing/notarization and commercial model rights also remain open. | | UI / Interaction | Rehearsal-first UI is the product surface; Anti-Slop and accessibility are acceptance criteria, not decoration. | Normal/loading/empty/error/permission/responsive states, KO/EN/JA/ZH/VI/ES/DE/FR expansion/fallback, keyboard/focus/contrast/state semantics and actual-audio E2E must be verified on the exact release candidate. | ## Distribution/update decision boundary @@ -36,7 +36,7 @@ The Rust `apps/desktop/distribution-core` is the deterministic decision layer af `apps/desktop/distribution-runtime` is the narrow remote-metadata adapter. It accepts at most 256 KiB of UTF-8 JSON, rejects duplicate/unknown members, enforces the exact four desktop targets, bounds signature/URL/artifact-size fields, pins exact-tag URLs to the current BandScope GitHub release namespace and delegates release-identity syntax to `distribution-core`. Its result type is explicitly provisional. It has no durable-state dependency and cannot write highest-seen state from syntactically valid `raw_json` alone. -`apps/desktop/distribution-download` is a separate network-library-independent streaming/staging boundary. It rejects zero/over-ceiling expected sizes, optional `Content-Length` mismatch, chunks larger than 1 MiB, cumulative overrun before the offending bytes reach the sink, truncated completion and sink-write failure. A failed attempt is poisoned so later chunks cannot manufacture a success receipt. Its staging file uses a bounded portable basename under an existing non-symlink app-owned directory, exclusive `create_new`, cleanup on cancel/error, and flush/`sync_all` plus descriptor size verification before returning a still-open sealed artifact. The sealed artifact remains cleanup-on-drop because exact byte count and `sync_all()` do not establish digest, signature or metadata authenticity. This closes the pure byte/local-staging primitive gap but does not claim that the current Tauri updater path routes its HTTP body through them or that any unverified staged bytes are safe to retain. +`apps/desktop/distribution-download` is a separate network-library-independent streaming/staging boundary. It rejects zero/over-ceiling expected sizes, optional `Content-Length` mismatch, chunks larger than 1 MiB, cumulative overrun before the offending bytes reach the sink, truncated completion and sink-write failure. A failed attempt is poisoned so later chunks cannot manufacture a success receipt. Its staging file uses a bounded portable basename under an existing non-symlink app-owned directory, exclusive `create_new`, cleanup on cancel/error, and flush/`sync_all` plus descriptor size verification before returning a still-open sealed artifact. The sealed artifact remains cleanup-on-drop because exact byte count and `sync_all()` do not establish digest, signature or metadata authenticity. Downstream verification gets a positional read-only wrapper over that exact open descriptor; the write-enabled staging `File` itself is no longer exposed. This closes the pure byte/local-staging primitive gap but does not claim that the current Tauri updater path routes its HTTP body through them or that any unverified staged bytes are safe to retain. Highest-seen update identity remains Distribution state, not Project Persistence state. `apps/desktop/distribution-state` provides a separate bounded append-only Rust log that revalidates committed identities, rejects local version regression/equivocation, synchronizes successful appends and recovers only a syntactically valid torn final record prefix. It deliberately does not claim packaged power-loss equivalence across Windows/macOS until platform fault-injection evidence exists. Project Persistence remains authoritative only for project bytes and the project-schema evidence used by rollback compatibility checks. @@ -44,7 +44,7 @@ Current Tauri updater APIs still materialize a verified update as in-memory byte ## Release gate -A release candidate is not commercial-ready until all of the following are true on the exact protected head: required checks and independent review are terminal/qualifying; Windows artifacts are signed by the approved publisher and macOS artifacts are signed/notarized/stapled; updater authority is admitted without placeholder values; remote updater metadata has an authenticated binding before it can mutate freshness state; updater artifact bytes are signature-verified and matched to authenticated digest/size evidence; only verified bytes can be explicitly promoted beyond sealed cleanup scope; updater replay/rollback/recovery is exercised on packaged targets; production HTTP download actually passes through bounded streaming/staging admission and survives hostile/truncated/oversized/disk-full/cancel/network-error cases; SBOM/NOTICE/provenance agree with exact shipped bytes; model rights and exact model provenance are established; rights-cleared real-audio scientific acceptance is reproducible; the updater can recover to a compatible known-good build without losing project usability; and material UI passes actual-audio, responsive, locale and accessibility E2E. +A release candidate is not commercial-ready until all of the following are true on the exact protected head: required checks and independent review are terminal/qualifying; Windows artifacts are signed by the approved publisher and macOS artifacts are signed/notarized/stapled; updater authority is admitted without placeholder values; remote updater metadata has an authenticated binding before it can mutate freshness state; updater artifact bytes are signature-verified and matched to authenticated digest/size evidence using the exact sealed descriptor; only verified bytes can be explicitly promoted beyond sealed cleanup scope; updater replay/rollback/recovery is exercised on packaged targets; production HTTP download actually passes through bounded streaming/staging admission and survives hostile/truncated/oversized/disk-full/cancel/network-error cases; SBOM/NOTICE/provenance agree with exact shipped bytes; model rights and exact model provenance are established; rights-cleared real-audio scientific acceptance is reproducible; the updater can recover to a compatible known-good build without losing project usability; and material UI passes actual-audio, responsive, locale and accessibility E2E. Until those conditions are satisfied, Draft/open PRs and blocked release policies are expected safety states rather than reasons to bypass gates. From f6723cf006beb7b84fa995e41e5f0b685b00dd89 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 07:06:09 +0900 Subject: [PATCH 125/308] docs(architecture): withhold sealed updater write capability --- ARCHITECTURE.md | 105 ++++++++---------------------------------------- 1 file changed, 17 insertions(+), 88 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1baaff6cc..11b133432 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -4,54 +4,28 @@ Last updated: 2026-09-15 ## Brand source -- Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`. -- Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth. +BandScope's product framing and UX hierarchy are governed by `docs/brand-story.md` and `docs/prd/bandscope-prd.md`. Architecture exists to preserve that rehearsal-first product truth rather than to surface implementation capabilities for their own sake. -## Security source +## Architectural direction -- App security rules, trust boundaries, and required `Security Notes` behavior live in `docs/security/app-security.md`. -- Future work that touches files, URLs, subprocesses, IPC, WebView, model downloads, updates, or cache/export behavior should reference that document before implementation. -- Code Security and SBOM retention baselines live in `docs/security/code-security.md` and `docs/security/sbom-policy.md`. +BandScope is a local-first rehearsal decision tool whose scientific and operational claims must remain traceable to actual decoded audio, explicit provenance, deterministic contracts, and release evidence. -## Supply-chain source +The system favors narrow bounded contexts, explicit ownership, typed contracts, fail-closed resource admission, and durable local state. Cross-context sharing should use released contracts rather than source copies, direct database access, or implicit mutable state. -- Dependency and SBOM policy lives in `docs/security/dependency-policy.md`. -- Intended required checks for `main` and `develop` live in `docs/security/github-required-checks.md`. +## Product-level invariants -## Engineering acceptance and workflow source +- Actual audio is the source of rehearsal truth. Synthetic data is unit-test evidence only. +- Source admission, decode, scientific analysis, rehearsal insight, playback, project state, distribution/update state, and UI interaction have distinct ownership. +- A derived artifact must carry enough generation and provenance identity to decide whether it remains equivalent to the current source and implementation. +- Local project state must survive process interruption and recover without silently presenting stale or incompatible analysis as current. +- A packaged release is not commercial-ready until exact source, package, signing/notarization, model rights, SBOM/provenance, update and recovery evidence agree. +- Distribution/update trust is promoted in stages; remote JSON, downloaded bytes, a synchronized file, a signature, publication evidence, and local freshness state are not interchangeable evidence classes. -- Repository completion criteria live in `docs/engineering/acceptance-criteria.md`. -- Harness/runtime verification guidance lives in `docs/engineering/harness-engineering.md`. -- Canonical delivery flow lives in `docs/workflow/one-day-delivery-plan.md`. -- PR canonicalization and duplicate-handling policy lives in `docs/workflow/pr-continuity.md`. +## Security posture -## Agent and review operations source - -- Agent/subagent/skill usage baseline lives in `docs/agents/README.md`. -- CodeRabbit command and review handling baseline lives in `docs/coderabbit/review-commands.md`. - -## Deployment and runtime verification source - -- Deployment/release/runtime verification runbook lives in `docs/operations/deploy-runbook.md`. - -## Cross-platform build source - -- Windows and macOS build security policy lives in `docs/security/cross-platform-build-policy.md`. -- Target-OS builds are merge gates and release-validation controls, not optional compatibility checks. -- Windows amd64 + arm64 and macOS amd64 + arm64 are all part of the protected-branch and release-validation build baseline. -- Windows build runners should verify antivirus protection before native packaging begins. - -## GitHub bootstrap source - -- GitHub bootstrap execution policy lives in `docs/workflow/github-bootstrap-execution-policy.md`. -- Repository governance and Gitflow execution details live in `docs/repository/governance.md`, `docs/repository/bootstrap-plan.md`, and `docs/repository/gitflow.md`. -- The harness should treat missing local git state or missing GitHub repo state as bootstrap work when the task requires GitHub execution. - -## Cross-cutting security constraints - -- Treat files, URLs, metadata, project files, model artifacts, exports, and remote responses as untrusted. -- Keep security-sensitive capabilities narrow and allowlisted rather than generic. -- Prefer local processing, predictable storage locations, and minimal network use. +- Treat local and remote paths, links, media, updater metadata, model bytes, archives and subprocess boundaries as untrusted until admitted by their owning context. +- Avoid privilege expansion caused by generic filesystem handles, generic process execution, mutable release references, unbounded response buffering or cross-context state mutation. +- Keep write authority narrow. A read-side verification interface must not accidentally expose a writable underlying object. - Split privilege where feasible across UI, analysis workers, subprocesses, model delivery, and updater behavior. - Fail safely when a link, file, artifact, or boundary cannot be validated. @@ -60,7 +34,7 @@ Last updated: 2026-09-15 - `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; returns provisional metadata only and cannot mutate freshness state -- `apps/desktop/distribution-download` - network-library-independent Rust streaming byte-admission boundary for updater artifacts; owns expected-size/content-length/chunk/cumulative limits and fail-closed sink error semantics, but not HTTP, signatures, digests 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 @@ -72,7 +46,7 @@ Last updated: 2026-09-15 - 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 and invalid release-identity syntax, and it projects the fixed app-owned highest-seen path without creating or writing it. It deliberately has no `distribution-state` dependency. -- `apps/desktop/distribution-download` owns the pure streaming byte-admission 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 and exact-length completion. 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 and temporary sink to route actual response bytes through this boundary instead of relying on Tauri's full-response buffering. +- `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. This context 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 route actual response bytes through this boundary instead of 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. @@ -103,48 +77,3 @@ Last updated: 2026-09-15 - section roadmap with entries, dropouts, pickups, stops, tags, and handoffs - groove and timing cues relevant to locking the band together - playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span and the next instrument check - - simplification, transposition, capo, tuning, or setup cues where applicable - - role-specific rehearsal priorities and confidence flags - - cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form - -## Confidence, edits, and provenance - -- Confidence must be representable at the section and role level. -- Automatic analysis should remain editable without losing provenance of what was model-generated versus user-confirmed. -- Future shared contracts should preserve manual overrides, confidence markers, and export-safe summaries of those states. - -## Harness decisions - -- The harness uses `npm` workspaces for JavaScript/TypeScript and `uv` for Python. -- 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-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. -- Dependency review, audit, supply-chain inventory validation, and SBOM generation are expected protected-branch gates for both `develop` and `main`. -- Cross-platform Windows and macOS build coverage is part of the bootstrap security architecture. -- Release artifacts, checksums, and manifests should encode both OS and architecture so packaged binaries remain traceable. -- Exact Windows 10 and macOS 24/25 GitHub-hosted coverage is a platform-capability constraint today; the current hosted baseline uses the closest published explicit runner labels and must move to self-hosted or larger runners if exact-version enforcement becomes mandatory. -- GitHub-facing setup is staged: no-git -> local-git -> GitHub-connected -> protected-branches with required checks. -- Shared contracts live in `packages/shared-types` so the UI can evolve without importing Python internals. -- Shared contracts should ultimately model section, role, cue, confidence, and export artifacts explicitly enough that desktop UI and analysis outputs do not invent their own parallel schemas. -- The current shared-types baseline includes a rehearsal-domain fixture that exercises section, role, cue, confidence, provenance, and export-summary fields in the desktop shell before the full analysis pipeline lands. -- Local analysis orchestration uses typed Tauri IPC commands and a Python subprocess over stdin/stdout rather than a loopback HTTP listener. -- Local audio intake bootstraps a project by validating a user-selected file in Rust, creating app-owned temp/cache/project roots, and referencing the original source file rather than copying it in this phase. -- Those bootstrap roots should resolve from app-owned Tauri data/cache paths instead of the shared system temp namespace. -- Product and UX decisions should prefer rehearsal-first simplicity while still maintaining high analytical accuracy. -- Security decisions should prefer allowlisted narrow capabilities over generic convenience APIs. - -## Verification model - -- `scripts/harness/quickcheck.sh` is the primary local verification entrypoint. -- `scripts/checks/check_rust.sh` is an opt-in local Rust/Tauri gate used when the host has the native desktop toolchain ready. -- CI mirrors the default sequence for JS and Python, and adds dedicated Windows/macOS native build coverage for both amd64 and arm64 runners. -- Smoke-grade app verification is currently the React shell render plus Python engine health report. -- Security docs and checks are part of the default quickcheck path so design drift is caught early. -- Supply-chain docs, workflow pinning, and lockfile verification are part of the default quickcheck path so dependency drift is caught early. -- Quickcheck and CI are expected to verify dependency review, audit, supplemental inventory, and SBOM baseline presence as part of bootstrap. -- Cross-platform build workflow presence and trigger coverage are part of the default supply-chain verification path. From 1c8d83d4b62bb1aea2b9db16db39eed347edd672 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 07:06:51 +0900 Subject: [PATCH 126/308] docs(architecture): restore sources and narrow sealed reader capability --- ARCHITECTURE.md | 103 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 87 insertions(+), 16 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 11b133432..7a922e2d4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -4,28 +4,54 @@ Last updated: 2026-09-15 ## Brand source -BandScope's product framing and UX hierarchy are governed by `docs/brand-story.md` and `docs/prd/bandscope-prd.md`. Architecture exists to preserve that rehearsal-first product truth rather than to surface implementation capabilities for their own sake. +- Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`. +- Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth. -## Architectural direction +## Security source -BandScope is a local-first rehearsal decision tool whose scientific and operational claims must remain traceable to actual decoded audio, explicit provenance, deterministic contracts, and release evidence. +- App security rules, trust boundaries, and required `Security Notes` behavior live in `docs/security/app-security.md`. +- Future work that touches files, URLs, subprocesses, IPC, WebView, model downloads, updates, or cache/export behavior should reference that document before implementation. +- Code Security and SBOM retention baselines live in `docs/security/code-security.md` and `docs/security/sbom-policy.md`. -The system favors narrow bounded contexts, explicit ownership, typed contracts, fail-closed resource admission, and durable local state. Cross-context sharing should use released contracts rather than source copies, direct database access, or implicit mutable state. +## Supply-chain source -## Product-level invariants +- Dependency and SBOM policy lives in `docs/security/dependency-policy.md`. +- Intended required checks for `main` and `develop` live in `docs/security/github-required-checks.md`. -- Actual audio is the source of rehearsal truth. Synthetic data is unit-test evidence only. -- Source admission, decode, scientific analysis, rehearsal insight, playback, project state, distribution/update state, and UI interaction have distinct ownership. -- A derived artifact must carry enough generation and provenance identity to decide whether it remains equivalent to the current source and implementation. -- Local project state must survive process interruption and recover without silently presenting stale or incompatible analysis as current. -- A packaged release is not commercial-ready until exact source, package, signing/notarization, model rights, SBOM/provenance, update and recovery evidence agree. -- Distribution/update trust is promoted in stages; remote JSON, downloaded bytes, a synchronized file, a signature, publication evidence, and local freshness state are not interchangeable evidence classes. +## Engineering acceptance and workflow source -## Security posture +- Repository completion criteria live in `docs/engineering/acceptance-criteria.md`. +- Harness/runtime verification guidance lives in `docs/engineering/harness-engineering.md`. +- Canonical delivery flow lives in `docs/workflow/one-day-delivery-plan.md`. +- PR canonicalization and duplicate-handling policy lives in `docs/workflow/pr-continuity.md`. -- Treat local and remote paths, links, media, updater metadata, model bytes, archives and subprocess boundaries as untrusted until admitted by their owning context. -- Avoid privilege expansion caused by generic filesystem handles, generic process execution, mutable release references, unbounded response buffering or cross-context state mutation. -- Keep write authority narrow. A read-side verification interface must not accidentally expose a writable underlying object. +## Agent and review operations source + +- Agent/subagent/skill usage baseline lives in `docs/agents/README.md`. +- CodeRabbit command and review handling baseline lives in `docs/coderabbit/review-commands.md`. + +## Deployment and runtime verification source + +- Deployment/release/runtime verification runbook lives in `docs/operations/deploy-runbook.md`. + +## Cross-platform build source + +- Windows and macOS build security policy lives in `docs/security/cross-platform-build-policy.md`. +- Target-OS builds are merge gates and release-validation controls, not optional compatibility checks. +- Windows amd64 + arm64 and macOS amd64 + arm64 are all part of the protected-branch and release-validation build baseline. +- Windows build runners should verify antivirus protection before native packaging begins. + +## GitHub bootstrap source + +- GitHub bootstrap execution policy lives in `docs/workflow/github-bootstrap-execution-policy.md`. +- Repository governance and Gitflow execution details live in `docs/repository/governance.md`, `docs/repository/bootstrap-plan.md`, and `docs/repository/gitflow.md`. +- The harness should treat missing local git state or missing GitHub repo state as bootstrap work when the task requires GitHub execution. + +## Cross-cutting security constraints + +- Treat files, URLs, metadata, project files, model artifacts, exports, and remote responses as untrusted. +- Keep security-sensitive capabilities narrow and allowlisted rather than generic. +- Prefer local processing, predictable storage locations, and minimal network use. - Split privilege where feasible across UI, analysis workers, subprocesses, model delivery, and updater behavior. - Fail safely when a link, file, artifact, or boundary cannot be validated. @@ -46,7 +72,7 @@ The system favors narrow bounded contexts, explicit ownership, typed contracts, - 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 and invalid release-identity syntax, and it projects the fixed app-owned highest-seen path without creating or writing it. It deliberately has no `distribution-state` dependency. -- `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. This context 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 route actual response bytes through this boundary instead of relying on Tauri's full-response buffering. +- `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 route actual response bytes through this boundary instead of 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. @@ -77,3 +103,48 @@ The system favors narrow bounded contexts, explicit ownership, typed contracts, - section roadmap with entries, dropouts, pickups, stops, tags, and handoffs - groove and timing cues relevant to locking the band together - playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span and the next instrument check + - simplification, transposition, capo, tuning, or setup cues where applicable + - role-specific rehearsal priorities and confidence flags + - cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form + +## Confidence, edits, and provenance + +- Confidence must be representable at the section and role level. +- Automatic analysis should remain editable without losing provenance of what was model-generated versus user-confirmed. +- Future shared contracts should preserve manual overrides, confidence markers, and export-safe summaries of those states. + +## Harness decisions + +- The harness uses `npm` workspaces for JavaScript/TypeScript and `uv` for Python. +- 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-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. +- Dependency review, audit, supply-chain inventory validation, and SBOM generation are expected protected-branch gates for both `develop` and `main`. +- Cross-platform Windows and macOS build coverage is part of the bootstrap security architecture. +- Release artifacts, checksums, and manifests should encode both OS and architecture so packaged binaries remain traceable. +- Exact Windows 10 and macOS 24/25 GitHub-hosted coverage is a platform-capability constraint today; the current hosted baseline uses the closest published explicit runner labels and must move to self-hosted or larger runners if exact-version enforcement becomes mandatory. +- GitHub-facing setup is staged: no-git -> local-git -> GitHub-connected -> protected-branches with required checks. +- Shared contracts live in `packages/shared-types` so the UI can evolve without importing Python internals. +- Shared contracts should ultimately model section, role, cue, confidence, and export artifacts explicitly enough that desktop UI and analysis outputs do not invent their own parallel schemas. +- The current shared-types baseline includes a rehearsal-domain fixture that exercises section, role, cue, confidence, provenance, and export-summary fields in the desktop shell before the full analysis pipeline lands. +- Local analysis orchestration uses typed Tauri IPC commands and a Python subprocess over stdin/stdout rather than a loopback HTTP listener. +- Local audio intake bootstraps a project by validating a user-selected file in Rust, creating app-owned temp/cache/project roots, and referencing the original source file rather than copying it in this phase. +- Those bootstrap roots should resolve from app-owned Tauri data/cache paths instead of the shared system temp namespace. +- Product and UX decisions should prefer rehearsal-first simplicity while still maintaining high analytical accuracy. +- Security decisions should prefer allowlisted narrow capabilities over generic convenience APIs. + +## Verification model + +- `scripts/harness/quickcheck.sh` is the primary local verification entrypoint. +- `scripts/checks/check_rust.sh` is an opt-in local Rust/Tauri gate used when the host has the native desktop toolchain ready. +- CI mirrors the default sequence for JS and Python, and adds dedicated Windows/macOS native build coverage for both amd64 and arm64 runners. +- Smoke-grade app verification is currently the React shell render plus Python engine health report. +- Security docs and checks are part of the default quickcheck path so design drift is caught early. +- Supply-chain docs, workflow pinning, and lockfile verification are part of the default quickcheck path so dependency drift is caught early. +- Quickcheck and CI are expected to verify dependency review, audit, supplemental inventory, and SBOM baseline presence as part of bootstrap. +- Cross-platform build workflow presence and trigger coverage are part of the default supply-chain verification path so dependency drift is caught early. From 57136898475382d9f082d40900b98a8a96003029 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 07:07:23 +0900 Subject: [PATCH 127/308] docs(architecture): restore exact verification wording --- ARCHITECTURE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7a922e2d4..2d9bd66dc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -147,4 +147,4 @@ Last updated: 2026-09-15 - Security docs and checks are part of the default quickcheck path so design drift is caught early. - Supply-chain docs, workflow pinning, and lockfile verification are part of the default quickcheck path so dependency drift is caught early. - Quickcheck and CI are expected to verify dependency review, audit, supplemental inventory, and SBOM baseline presence as part of bootstrap. -- Cross-platform build workflow presence and trigger coverage are part of the default supply-chain verification path so dependency drift is caught early. +- Cross-platform build workflow presence and trigger coverage are part of the default supply-chain verification path. From e1274bee951b2eb1bb58d3dbbb59d21289434384 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 08:03:23 +0900 Subject: [PATCH 128/308] test(distribution): RED bound sealed reads to admitted bytes --- .../tests/sealed_reader.rs | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/apps/desktop/distribution-download/tests/sealed_reader.rs b/apps/desktop/distribution-download/tests/sealed_reader.rs index b6e10310a..22842c244 100644 --- a/apps/desktop/distribution-download/tests/sealed_reader.rs +++ b/apps/desktop/distribution-download/tests/sealed_reader.rs @@ -1,6 +1,6 @@ use bandscope_distribution_download::{ArtifactDownloadAdmission, StagedArtifactFile}; -use std::fs; -use std::io::Read; +use std::fs::{self, OpenOptions}; +use std::io::{Read, Write}; use std::time::{SystemTime, UNIX_EPOCH}; fn scratch_dir(label: &str) -> std::path::PathBuf { @@ -41,3 +41,39 @@ fn sealed_artifact_exposes_descriptor_bound_read_only_stream() { 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"); +} From c4510966b874778a67f3c50f09acaf858fe7c70c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 08:05:16 +0900 Subject: [PATCH 129/308] fix(distribution): cap sealed verifier reads at admitted bytes --- apps/desktop/distribution-download/src/lib.rs | 46 +++++++++++++------ 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/apps/desktop/distribution-download/src/lib.rs b/apps/desktop/distribution-download/src/lib.rs index 80db9d6a0..719c8203a 100644 --- a/apps/desktop/distribution-download/src/lib.rs +++ b/apps/desktop/distribution-download/src/lib.rs @@ -311,28 +311,42 @@ pub struct SealedArtifactFile { /// 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 wrapper intentionally implements `Read` only: callers cannot use -/// it to recover the underlying write-capable staging descriptor. +/// 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() { + if buffer.is_empty() || self.remaining_bytes == 0 { return Ok(0); } - let read = descriptor_read_at(self.file, buffer, self.offset)?; - self.offset = self - .offset - .checked_add(u64::try_from(read).map_err(|_| { - std::io::Error::new(ErrorKind::InvalidData, "sealed read length overflow") - })?) - .ok_or_else(|| { - std::io::Error::new(ErrorKind::InvalidData, "sealed reader offset overflow") - })?; + 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) } } @@ -350,9 +364,10 @@ impl SealedArtifactFile { /// Open a read-only positional stream over the exact sealed descriptor. /// - /// The stream starts at byte zero and never reopens the staging path. This - /// preserves descriptor binding while withholding the underlying - /// write-capable `File` from downstream digest/signature code. + /// 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 @@ -360,6 +375,7 @@ impl SealedArtifactFile { .as_ref() .expect("sealed artifact descriptor remains present before drop"), offset: 0, + remaining_bytes: self.bytes_written, } } } From e294147e3d93757b7a6115222fb78317152ecc74 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 08:05:52 +0900 Subject: [PATCH 130/308] test(distribution): reject sealed descriptor truncation --- .../tests/sealed_reader.rs | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/desktop/distribution-download/tests/sealed_reader.rs b/apps/desktop/distribution-download/tests/sealed_reader.rs index 22842c244..a85983dba 100644 --- a/apps/desktop/distribution-download/tests/sealed_reader.rs +++ b/apps/desktop/distribution-download/tests/sealed_reader.rs @@ -1,6 +1,6 @@ use bandscope_distribution_download::{ArtifactDownloadAdmission, StagedArtifactFile}; use std::fs::{self, OpenOptions}; -use std::io::{Read, Write}; +use std::io::{ErrorKind, Read, Write}; use std::time::{SystemTime, UNIX_EPOCH}; fn scratch_dir(label: &str) -> std::path::PathBuf { @@ -77,3 +77,37 @@ fn sealed_reader_never_crosses_the_admitted_byte_boundary_after_external_growth( 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"); +} From f6b376ab5a6740af1807d81c2115e2ae5d7e26c1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 08:06:32 +0900 Subject: [PATCH 131/308] docs(distribution): trace sealed reader byte-bound repair --- docs/traceability/updater-bounded-download.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/traceability/updater-bounded-download.md b/docs/traceability/updater-bounded-download.md index 7c9b312fd..4d0fabd90 100644 --- a/docs/traceability/updater-bounded-download.md +++ b/docs/traceability/updater-bounded-download.md @@ -20,6 +20,9 @@ BandScope의 Distribution/update 경계는 updater artifact를 신뢰하기 전 - 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`를 반환하는 회귀 테스트를 추가했습니다. ## 실행 계약 @@ -43,9 +46,10 @@ BandScope의 Distribution/update 경계는 updater artifact를 신뢰하기 전 - seal은 userspace flush와 descriptor `sync_all()` 이후 descriptor가 regular file인지, exact receipt size와 같은지 다시 확인합니다. - 성공한 `SealedArtifactFile`은 descriptor를 계속 열어 두므로 후속 digest/signature verification이 path reopen보다 exact staged bytes에 결합될 수 있습니다. - 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를 먼저 닫은 다음 staging path를 제거합니다. 후속 digest/signature/authenticated-metadata 결합이 성공하기 전에는 unverified bytes가 정상 종료 경로에서 남지 않습니다. -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, failed-admission cleanup, receipt mismatch, existing destination, path-like name, invalid staging root와 Unix symlink root를 다룹니다. Python production logic은 추가하지 않았고 repository harness는 locked Rust suite를 validation boundary로 호출합니다. +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, existing destination, path-like name, invalid staging root와 Unix symlink root를 다룹니다. Python production logic은 추가하지 않았고 repository harness는 locked Rust suite를 validation boundary로 호출합니다. ## 기각한 대안 @@ -61,6 +65,8 @@ Exact-size seal을 곧바로 artifact retention으로 취급하는 방식도 기 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가 완료됐다고 주장하지 않습니다. 또한 `sync_all()`과 cleanup tests를 packaged Windows/macOS power-loss durability와 동일시하지 않습니다. 이 crate는 SHA-256, updater signature, metadata authenticity, installer trust도 검증하지 않습니다. @@ -69,7 +75,7 @@ Sealed artifact에서 raw `&File`을 verifier에 넘기는 방식도 기각합 ## Security Notes -Attack surface는 updater HTTP response body, transport length metadata, temporary artifact directory/path, 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로 제한해야 합니다. Cleanup은 app-owned non-symlink directory라는 전제 안에서만 pathname removal을 수행합니다. Sealed descriptor의 raw write capability는 verifier에 노출하지 않으며, 후속 검증은 descriptor-bound read-only stream을 사용해야 합니다. Audio/project bytes나 paths는 updater request/receipt에 포함하지 않습니다. +Attack surface는 updater HTTP response body, transport length metadata, temporary artifact directory/path, 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로 제한해야 합니다. Cleanup은 app-owned non-symlink directory라는 전제 안에서만 pathname removal을 수행합니다. 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 @@ -77,6 +83,8 @@ Tauri Contributors. (2026). *Updater*. Tauri v2 documentation. https://v2.tauri. 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). *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 From 56d215eb759b209514cbc5e434f99c16e5c143bb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 08:07:06 +0900 Subject: [PATCH 132/308] docs(product): keep sealed verifier resource bound code-current --- docs/product-technical-gap-baseline.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 8b209ad08..1dd3c9f0e 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -20,7 +20,7 @@ The product keeps BandScope-specific audio/rehearsal truth inside BandScope. Org | Project Persistence | Project/cache integrity and scientific cache equivalence have dedicated owner work. | Crash/power-loss, disk-full, interrupted write/recovery, last-known-good project state and packaged-OS fault injection remain buyer gates. | | Collaboration Handoff | Export/handoff belongs to BandScope without creating a second collaboration platform. | Only released, bounded artifacts should cross product boundaries; mutable shared DB or cross-service SQL is not accepted. | | Diagnostics | Existing harness/security/build evidence is substantial. | Buyer-safe diagnostics must avoid audio/project/credential leakage and distinguish user cancel, provider/runtime failure, corrupt project and release/update failure. | -| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. `distribution-core` and `distribution-state` define deterministic replay/rollback policy and durable highest-seen storage for already-authenticated release identity. `distribution-runtime` bounded-parses static updater JSON only as provisional remote input. `distribution-download` owns dependency-free Rust byte admission plus an exclusive app-owned staging lifecycle: bounded expected/content-length/chunk/cumulative sizes, sink-failure poisoning, cancel/error cleanup, `create_new` destination admission, flush/`sync_all`, descriptor size verification and still-open sealed descriptors. Exact-size seal is explicitly not trust promotion; sealed-but-unverified artifacts remain cleanup-on-drop and verifier access is now a descriptor-bound positional `Read` stream rather than the underlying write-capable `File`. | The current Tauri artifact signature verifies downloaded updater bytes, not the whole `raw_json` response. Remote release identity therefore still needs independent metadata authentication. The download/staging primitives are not yet wired to a production HTTP adapter, so end-to-end hostile-response bounds, redirect/origin behavior, real disk-full/cancel/network-error cleanup and verified artifact digest/signature binding remain gates. A verified-artifact promotion type/path must be added so only successfully authenticated bytes can outlive the verification scope. Packaged power-loss evidence, production updater key/endpoint, Windows/macOS signing/notarization and commercial model rights also remain open. | +| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. `distribution-core` and `distribution-state` define deterministic replay/rollback policy and durable highest-seen storage for already-authenticated release identity. `distribution-runtime` bounded-parses static updater JSON only as provisional remote input. `distribution-download` owns dependency-free Rust byte admission plus an exclusive app-owned staging lifecycle: bounded expected/content-length/chunk/cumulative sizes, sink-failure poisoning, cancel/error cleanup, `create_new` destination admission, flush/`sync_all`, descriptor size verification and still-open sealed descriptors. Exact-size seal is explicitly not trust promotion; sealed-but-unverified artifacts remain cleanup-on-drop. Verifier access is a descriptor-bound positional `Read` stream, not the underlying write-capable `File`, and the reader itself is capped at the seal-time admitted byte count so post-seal file growth cannot reopen the resource bound; truncation below that bound fails closed. | The current Tauri artifact signature verifies downloaded updater bytes, not the whole `raw_json` response. Remote release identity therefore still needs independent metadata authentication. The download/staging primitives are not yet wired to a production HTTP adapter, so end-to-end hostile-response bounds, redirect/origin behavior, real disk-full/cancel/network-error cleanup and verified artifact digest/signature binding remain gates. A verified-artifact promotion type/path must be added so only successfully authenticated bytes can outlive the verification scope. Packaged power-loss evidence, production updater key/endpoint, Windows/macOS signing/notarization and commercial model rights also remain open. | | UI / Interaction | Rehearsal-first UI is the product surface; Anti-Slop and accessibility are acceptance criteria, not decoration. | Normal/loading/empty/error/permission/responsive states, KO/EN/JA/ZH/VI/ES/DE/FR expansion/fallback, keyboard/focus/contrast/state semantics and actual-audio E2E must be verified on the exact release candidate. | ## Distribution/update decision boundary @@ -36,7 +36,7 @@ The Rust `apps/desktop/distribution-core` is the deterministic decision layer af `apps/desktop/distribution-runtime` is the narrow remote-metadata adapter. It accepts at most 256 KiB of UTF-8 JSON, rejects duplicate/unknown members, enforces the exact four desktop targets, bounds signature/URL/artifact-size fields, pins exact-tag URLs to the current BandScope GitHub release namespace and delegates release-identity syntax to `distribution-core`. Its result type is explicitly provisional. It has no durable-state dependency and cannot write highest-seen state from syntactically valid `raw_json` alone. -`apps/desktop/distribution-download` is a separate network-library-independent streaming/staging boundary. It rejects zero/over-ceiling expected sizes, optional `Content-Length` mismatch, chunks larger than 1 MiB, cumulative overrun before the offending bytes reach the sink, truncated completion and sink-write failure. A failed attempt is poisoned so later chunks cannot manufacture a success receipt. Its staging file uses a bounded portable basename under an existing non-symlink app-owned directory, exclusive `create_new`, cleanup on cancel/error, and flush/`sync_all` plus descriptor size verification before returning a still-open sealed artifact. The sealed artifact remains cleanup-on-drop because exact byte count and `sync_all()` do not establish digest, signature or metadata authenticity. Downstream verification gets a positional read-only wrapper over that exact open descriptor; the write-enabled staging `File` itself is no longer exposed. This closes the pure byte/local-staging primitive gap but does not claim that the current Tauri updater path routes its HTTP body through them or that any unverified staged bytes are safe to retain. +`apps/desktop/distribution-download` is a separate network-library-independent streaming/staging boundary. It rejects zero/over-ceiling expected sizes, optional `Content-Length` mismatch, chunks larger than 1 MiB, cumulative overrun before the offending bytes reach the sink, truncated completion and sink-write failure. A failed attempt is poisoned so later chunks cannot manufacture a success receipt. Its staging file uses a bounded portable basename under an existing non-symlink app-owned directory, exclusive `create_new`, cleanup on cancel/error, and flush/`sync_all` plus descriptor size verification before returning a still-open sealed artifact. The sealed artifact remains cleanup-on-drop because exact byte count and `sync_all()` do not establish digest, signature or metadata authenticity. Downstream verification gets a positional read-only wrapper over that exact open descriptor; the write-enabled staging `File` itself is no longer exposed. The reader owns the original admitted length as a hard upper bound, ignores any later appended tail, and reports early EOF if the descriptor is truncated below that length. This closes the pure byte/local-staging primitive gap without claiming that the current Tauri updater path routes its HTTP body through them or that any unverified staged bytes are safe to retain. Highest-seen update identity remains Distribution state, not Project Persistence state. `apps/desktop/distribution-state` provides a separate bounded append-only Rust log that revalidates committed identities, rejects local version regression/equivocation, synchronizes successful appends and recovers only a syntactically valid torn final record prefix. It deliberately does not claim packaged power-loss equivalence across Windows/macOS until platform fault-injection evidence exists. Project Persistence remains authoritative only for project bytes and the project-schema evidence used by rollback compatibility checks. From 9f7bddd3c5660739f4c4e1a060b6e6a3cd807824 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 09:00:33 +0900 Subject: [PATCH 133/308] test(distribution): require admitted artifact transport binding --- .../tests/provisional_artifact.rs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 apps/desktop/distribution-runtime/tests/provisional_artifact.rs 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..9dfe8a208 --- /dev/null +++ b/apps/desktop/distribution-runtime/tests/provisional_artifact.rs @@ -0,0 +1,25 @@ +use bandscope_distribution_runtime::admit_untrusted_raw_json; + +const SOURCE_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; +const DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +fn updater_document() -> Vec { + format!( + r#"{{"version":"1.2.3","platforms":{{"windows-x86_64":{{"signature":"sig-win-x64","url":"https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-windows-x86_64.zip"}},"windows-aarch64":{{"signature":"sig-win-arm64","url":"https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-windows-aarch64.zip"}},"darwin-x86_64":{{"signature":"sig-mac-x64","url":"https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-darwin-x86_64.tar.gz"}},"darwin-aarch64":{{"signature":"sig-mac-arm64","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(), "sig-mac-arm64"); +} From 663affcbc940269928a4eb95eb329d99b6de57c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 09:04:30 +0900 Subject: [PATCH 134/308] fix(distribution): bind admitted artifact transport fields --- apps/desktop/distribution-runtime/src/lib.rs | 36 +++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/apps/desktop/distribution-runtime/src/lib.rs b/apps/desktop/distribution-runtime/src/lib.rs index 24da212bf..9b038f155 100644 --- a/apps/desktop/distribution-runtime/src/lib.rs +++ b/apps/desktop/distribution-runtime/src/lib.rs @@ -73,6 +73,8 @@ pub enum MetadataError { pub struct ProvisionalUpdateMetadata { candidate: UpdateCandidate, artifact_size_bytes: u64, + artifact_url: String, + artifact_signature: String, } impl ProvisionalUpdateMetadata { @@ -101,6 +103,16 @@ impl ProvisionalUpdateMetadata { 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() @@ -114,7 +126,9 @@ impl ProvisionalUpdateMetadata { /// unknown members, enforces all four release targets, bounds signature/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. Success is deliberately *provisional* and must never be persisted as +/// 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], @@ -134,11 +148,19 @@ pub fn admit_untrusted_raw_json( 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"])?; - validate_signature(as_string(field(platform, "signature")?)?)?; - validate_release_url(as_string(field(platform, "url")?)?, version)?; + 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")?)?; @@ -186,6 +208,8 @@ pub fn admit_untrusted_raw_json( 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, @@ -197,6 +221,8 @@ pub fn admit_untrusted_raw_json( Ok(ProvisionalUpdateMetadata { candidate, artifact_size_bytes, + artifact_url, + artifact_signature, }) } @@ -241,7 +267,9 @@ fn validate_signature(value: &str) -> Result<(), MetadataError> { 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 + .bytes() + .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace()) || value.contains('?') || value.contains('#') || value.contains('\\') From ed7426fb4af12d07ec9840527d667c3327a12bc6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 09:05:20 +0900 Subject: [PATCH 135/308] docs(distribution): trace admitted transport binding --- .../traceability/updater-security-metadata.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/traceability/updater-security-metadata.md b/docs/traceability/updater-security-metadata.md index c3bf7dd50..59f26bea5 100644 --- a/docs/traceability/updater-security-metadata.md +++ b/docs/traceability/updater-security-metadata.md @@ -19,6 +19,8 @@ Runtime-admission lineage: - 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 @@ -26,6 +28,8 @@ Runtime-admission lineage: 현재 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 @@ -55,15 +59,17 @@ Runtime-admission lineage: 현재 2번이 없으므로 5번을 runtime에 연결하지 않는 것이 fail-closed 동작입니다. 설치를 미룬 release까지 pre-install highest-seen으로 기억하려면 metadata 자체의 authenticity가 필요합니다. 그것 없이 remote version만 먼저 저장하는 것은 freeze/replay 방어가 아니라 local state poisoning 경로가 될 수 있습니다. -## Resource-admission gap +## 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이 아닙니다. -Tauri current source의 `Update::download`는 HTTP body chunk를 `Vec`에 누적한 다음 signature를 검증합니다. BandScope manifest는 declared artifact size를 bounded field로 갖지만, remote server가 그 값을 지킨다는 보장은 signature verification 전에는 없습니다. URL namespace pinning은 destination 선택 범위를 줄이지만 response byte 수를 제한하지 않습니다. 따라서 production updater를 켤 때는 declared size나 host pin만으로 resource admission을 완료했다고 주장할 수 없습니다. Bounded streaming/download behavior 또는 동등한 hard memory/disk admission evidence가 별도로 필요합니다. +남은 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를 허용하는 방식도 기각합니다. Metadata signature 또는 TUF류 protocol을 도입한다면 BandScope release/update owner에서 versioned contract와 key lifecycle, rotation/recovery, expiry/freeze semantics까지 함께 설계해야 하며 다른 bounded context에 검증 로직을 복제하지 않습니다. +`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 준수를 주장하지 않습니다. @@ -73,10 +79,11 @@ TUF는 metadata 자체를 threshold signature로 인증하고 version rollback Repository-owned 다음 단계는 다음 순서가 맞습니다. -- `distribution-runtime` provisional parser를 current Tauri static manifest shape와 publication namespace에 계속 동기화 +- 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와 Tauri-verified artifact bytes의 digest/size binding -- bounded streaming/download 또는 동등한 hard memory/disk admission path +- 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 보존 From 9c95c715aa4580d248c13f574be9e939b3d2850f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 09:05:44 +0900 Subject: [PATCH 136/308] docs(product): align updater transport gap baseline --- docs/product-technical-gap-baseline.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 1dd3c9f0e..a0a0a7db1 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -20,7 +20,7 @@ The product keeps BandScope-specific audio/rehearsal truth inside BandScope. Org | Project Persistence | Project/cache integrity and scientific cache equivalence have dedicated owner work. | Crash/power-loss, disk-full, interrupted write/recovery, last-known-good project state and packaged-OS fault injection remain buyer gates. | | Collaboration Handoff | Export/handoff belongs to BandScope without creating a second collaboration platform. | Only released, bounded artifacts should cross product boundaries; mutable shared DB or cross-service SQL is not accepted. | | Diagnostics | Existing harness/security/build evidence is substantial. | Buyer-safe diagnostics must avoid audio/project/credential leakage and distinguish user cancel, provider/runtime failure, corrupt project and release/update failure. | -| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. `distribution-core` and `distribution-state` define deterministic replay/rollback policy and durable highest-seen storage for already-authenticated release identity. `distribution-runtime` bounded-parses static updater JSON only as provisional remote input. `distribution-download` owns dependency-free Rust byte admission plus an exclusive app-owned staging lifecycle: bounded expected/content-length/chunk/cumulative sizes, sink-failure poisoning, cancel/error cleanup, `create_new` destination admission, flush/`sync_all`, descriptor size verification and still-open sealed descriptors. Exact-size seal is explicitly not trust promotion; sealed-but-unverified artifacts remain cleanup-on-drop. Verifier access is a descriptor-bound positional `Read` stream, not the underlying write-capable `File`, and the reader itself is capped at the seal-time admitted byte count so post-seal file growth cannot reopen the resource bound; truncation below that bound fails closed. | The current Tauri artifact signature verifies downloaded updater bytes, not the whole `raw_json` response. Remote release identity therefore still needs independent metadata authentication. The download/staging primitives are not yet wired to a production HTTP adapter, so end-to-end hostile-response bounds, redirect/origin behavior, real disk-full/cancel/network-error cleanup and verified artifact digest/signature binding remain gates. A verified-artifact promotion type/path must be added so only successfully authenticated bytes can outlive the verification scope. Packaged power-loss evidence, production updater key/endpoint, Windows/macOS signing/notarization and commercial model rights also remain open. | +| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. `distribution-core` and `distribution-state` define deterministic replay/rollback policy and durable highest-seen storage for already-authenticated release identity. `distribution-runtime` bounded-parses static updater JSON only as provisional remote input and now retains the selected target's exact admitted URL/signature with the same strict parse, preventing a later transport layer from needing a second looser JSON interpretation. `distribution-download` owns dependency-free Rust byte admission plus an exclusive app-owned staging lifecycle: bounded expected/content-length/chunk/cumulative sizes, sink-failure poisoning, cancel/error cleanup, `create_new` destination admission, flush/`sync_all`, descriptor size verification and still-open sealed descriptors. Exact-size seal is explicitly not trust promotion; sealed-but-unverified artifacts remain cleanup-on-drop. Verifier access is a descriptor-bound positional `Read` stream, not the underlying write-capable `File`, and the reader itself is capped at the seal-time admitted byte count so post-seal file growth cannot reopen the resource bound; truncation below that bound fails closed. | The current Tauri artifact signature verifies downloaded updater bytes, not the whole `raw_json` response. Remote release identity therefore still needs independent metadata authentication. The selected URL/signature binding is parser-consistency evidence only, not authenticity. The download/staging primitives are not yet wired to a production HTTP adapter, so end-to-end hostile-response bounds, redirect/origin behavior, real disk-full/cancel/network-error cleanup and verified artifact digest/signature binding remain gates. A verified-artifact promotion type/path must be added so only successfully authenticated bytes can outlive the verification scope. Packaged power-loss evidence, production updater key/endpoint, Windows/macOS signing/notarization and commercial model rights also remain open. | | UI / Interaction | Rehearsal-first UI is the product surface; Anti-Slop and accessibility are acceptance criteria, not decoration. | Normal/loading/empty/error/permission/responsive states, KO/EN/JA/ZH/VI/ES/DE/FR expansion/fallback, keyboard/focus/contrast/state semantics and actual-audio E2E must be verified on the exact release candidate. | ## Distribution/update decision boundary @@ -34,17 +34,17 @@ The updater path uses evidence classes with different trust semantics and must n The Rust `apps/desktop/distribution-core` is the deterministic decision layer after authentication. It rejects malformed stable versions, target mismatch, downgrade candidates, metadata older than the locally highest authenticated release, same-version release-identity equivocation and rollback to a build that cannot read the current project schema. It does not fetch, install, sign, notarize, parse arbitrary remote JSON, or write project data. -`apps/desktop/distribution-runtime` is the narrow remote-metadata adapter. It accepts at most 256 KiB of UTF-8 JSON, rejects duplicate/unknown members, enforces the exact four desktop targets, bounds signature/URL/artifact-size fields, pins exact-tag URLs to the current BandScope GitHub release namespace and delegates release-identity syntax to `distribution-core`. Its result type is explicitly provisional. It has no durable-state dependency and cannot write highest-seen state from syntactically valid `raw_json` alone. +`apps/desktop/distribution-runtime` is the narrow remote-metadata adapter. It accepts at most 256 KiB of UTF-8 JSON, rejects duplicate/unknown members, enforces the exact four desktop targets, bounds signature/URL/artifact-size fields, pins exact-tag URLs to the current BandScope GitHub release namespace and delegates release-identity syntax to `distribution-core`. Its result type is explicitly provisional. The selected target URL/signature are preserved from this same admitted document via `artifact_url()` and `artifact_signature()` so later transport code does not reparse `raw_json`. It has no durable-state dependency and cannot write highest-seen state from syntactically valid `raw_json` alone. `apps/desktop/distribution-download` is a separate network-library-independent streaming/staging boundary. It rejects zero/over-ceiling expected sizes, optional `Content-Length` mismatch, chunks larger than 1 MiB, cumulative overrun before the offending bytes reach the sink, truncated completion and sink-write failure. A failed attempt is poisoned so later chunks cannot manufacture a success receipt. Its staging file uses a bounded portable basename under an existing non-symlink app-owned directory, exclusive `create_new`, cleanup on cancel/error, and flush/`sync_all` plus descriptor size verification before returning a still-open sealed artifact. The sealed artifact remains cleanup-on-drop because exact byte count and `sync_all()` do not establish digest, signature or metadata authenticity. Downstream verification gets a positional read-only wrapper over that exact open descriptor; the write-enabled staging `File` itself is no longer exposed. The reader owns the original admitted length as a hard upper bound, ignores any later appended tail, and reports early EOF if the descriptor is truncated below that length. This closes the pure byte/local-staging primitive gap without claiming that the current Tauri updater path routes its HTTP body through them or that any unverified staged bytes are safe to retain. Highest-seen update identity remains Distribution state, not Project Persistence state. `apps/desktop/distribution-state` provides a separate bounded append-only Rust log that revalidates committed identities, rejects local version regression/equivocation, synchronizes successful appends and recovers only a syntactically valid torn final record prefix. It deliberately does not claim packaged power-loss equivalence across Windows/macOS until platform fault-injection evidence exists. Project Persistence remains authoritative only for project bytes and the project-schema evidence used by rollback compatibility checks. -Current Tauri updater APIs still materialize a verified update as in-memory bytes. The repository now owns stricter streaming and staging primitives, but commercial readiness requires a production network adapter that actually streams bounded response chunks into that boundary while preserving canonical origin/redirect policy. Counting progress callbacks or checking the fully buffered `Vec` after download is not equivalent evidence. `sync_all()` in a unit/integration test is also not packaged Windows/macOS power-loss proof. +Current Tauri updater APIs still materialize a verified update as in-memory bytes. The repository now owns stricter streaming and staging primitives plus a single strict source of selected transport metadata, but commercial readiness requires a production network adapter that actually consumes `artifact_url()`/`artifact_signature()` and streams bounded response chunks into `distribution-download` while preserving canonical origin/redirect policy. Counting progress callbacks or checking the fully buffered `Vec` after download is not equivalent evidence. `sync_all()` in a unit/integration test is also not packaged Windows/macOS power-loss proof. ## Release gate -A release candidate is not commercial-ready until all of the following are true on the exact protected head: required checks and independent review are terminal/qualifying; Windows artifacts are signed by the approved publisher and macOS artifacts are signed/notarized/stapled; updater authority is admitted without placeholder values; remote updater metadata has an authenticated binding before it can mutate freshness state; updater artifact bytes are signature-verified and matched to authenticated digest/size evidence using the exact sealed descriptor; only verified bytes can be explicitly promoted beyond sealed cleanup scope; updater replay/rollback/recovery is exercised on packaged targets; production HTTP download actually passes through bounded streaming/staging admission and survives hostile/truncated/oversized/disk-full/cancel/network-error cases; SBOM/NOTICE/provenance agree with exact shipped bytes; model rights and exact model provenance are established; rights-cleared real-audio scientific acceptance is reproducible; the updater can recover to a compatible known-good build without losing project usability; and material UI passes actual-audio, responsive, locale and accessibility E2E. +A release candidate is not commercial-ready until all of the following are true on the exact protected head: required checks and independent review are terminal/qualifying; Windows artifacts are signed by the approved publisher and macOS artifacts are signed/notarized/stapled; updater authority is admitted without placeholder values; remote updater metadata has an authenticated binding before it can mutate freshness state; updater artifact bytes are signature-verified and matched to authenticated digest/size evidence using the exact sealed descriptor; only verified bytes can be explicitly promoted beyond sealed cleanup scope; updater replay/rollback/recovery is exercised on packaged targets; production HTTP download actually consumes the strict admitted transport fields, passes through bounded streaming/staging admission and survives hostile/truncated/oversized/disk-full/cancel/network-error cases; SBOM/NOTICE/provenance agree with exact shipped bytes; model rights and exact model provenance are established; rights-cleared real-audio scientific acceptance is reproducible; the updater can recover to a compatible known-good build without losing project usability; and material UI passes actual-audio, responsive, locale and accessibility E2E. Until those conditions are satisfied, Draft/open PRs and blocked release policies are expected safety states rather than reasons to bypass gates. From 46c4cba47e3946513cc466e6b5ef726f85bf08ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 09:06:25 +0900 Subject: [PATCH 137/308] docs(architecture): bind updater transport metadata owner --- ARCHITECTURE.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2d9bd66dc..f505dfb28 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -59,7 +59,7 @@ Last updated: 2026-09-15 - `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; returns provisional metadata only and cannot mutate freshness state +- `apps/desktop/distribution-runtime` - stateless Rust admission boundary for untrusted Tauri updater JSON; returns provisional metadata with the selected target's exact admitted URL/signature from the same strict parse and cannot mutate freshness state - `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 @@ -71,8 +71,8 @@ Last updated: 2026-09-15 - 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 and invalid release-identity syntax, and it projects the fixed app-owned highest-seen path without creating or writing it. It deliberately has no `distribution-state` dependency. -- `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 route actual response bytes through this boundary instead of relying on Tauri's full-response buffering. +- `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 and invalid release-identity syntax; 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-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 consume the admitted transport fields and route actual response bytes through this boundary instead of reparsing raw JSON or 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. From 9d1dc2f43e4149df9bcef8afa8859d872b663600 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 10:03:12 +0900 Subject: [PATCH 138/308] test(release): reject runtime-incompatible release versions --- .../tests/test_release_version_identity.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/services/analysis-engine/tests/test_release_version_identity.py b/services/analysis-engine/tests/test_release_version_identity.py index 41e80eca8..1bc2641d8 100644 --- a/services/analysis-engine/tests/test_release_version_identity.py +++ b/services/analysis-engine/tests/test_release_version_identity.py @@ -113,6 +113,31 @@ def test_release_identity_guard_rejects_wrong_tag(tmp_path: Path) -> None: 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", + ], +) +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() From e268c9bbb0dd9a0e977a5b757c8426fe8d2112be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 10:03:32 +0900 Subject: [PATCH 139/308] fix(release): align release version grammar with updater core --- scripts/checks/verify_release_identity.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/scripts/checks/verify_release_identity.py b/scripts/checks/verify_release_identity.py index 3d834cfb6..6fcc91a01 100644 --- a/scripts/checks/verify_release_identity.py +++ b/scripts/checks/verify_release_identity.py @@ -10,6 +10,9 @@ 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 + as the native Distribution/update policy core. Prerelease/build forms 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. """ @@ -19,12 +22,16 @@ import importlib.util import json import os +import re 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]*)$" +) def _read_json_object(metadata_path: Path) -> dict[str, Any]: @@ -114,6 +121,8 @@ def verify_release_identity( ): raise ValueError("VERSION must contain exactly one non-empty version line") release_version = version_lines[0] + if _STABLE_VERSION_RE.fullmatch(release_version) is None: + raise ValueError("VERSION must be canonical stable MAJOR.MINOR.PATCH") package_document = _read_json_object(repository_root / "package.json") tauri_document = _read_json_object( From 97f9c2a075a266c4152f6824c3858be1af3321fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 10:04:31 +0900 Subject: [PATCH 140/308] docs(traceability): bind stable release version grammar --- docs/traceability/release-version-identity.md | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/traceability/release-version-identity.md diff --git a/docs/traceability/release-version-identity.md b/docs/traceability/release-version-identity.md new file mode 100644 index 000000000..8e1fda10f --- /dev/null +++ b/docs/traceability/release-version-identity.md @@ -0,0 +1,43 @@ +# Release version identity traceability + +## Problem + +BandScope's release preflight previously 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 already accepts only canonical stable `MAJOR.MINOR.PATCH` values with no leading zeros, prerelease suffix, or build metadata. + +That mismatch allowed a future stable-channel source such as `1.2.3-rc.1`, `1.2.3+build.7`, or `01.2.3` to pass repository release identity and reach tag packaging even though the runtime updater would later reject the same release identity. This is a Distribution release-truth defect: publication and consumption must use the same stable-channel grammar before any artifact write begins. + +## Decision + +`verify_release_identity.py` is the release-pipeline grammar gate because `package_desktop_artifact.py` invokes release preflight before creating release artifacts. Stable-channel `VERSION` now must match exact numeric `MAJOR.MINOR.PATCH` with each component either `0` or a non-zero digit followed by digits. + +The rule intentionally matches `apps/desktop/distribution-core::StableVersion`. It does not broaden the runtime to prerelease/build SemVer. A future beta/prerelease channel requires a separate explicit release decision and one canonical ordering implementation rather than letting publication and runtime evolve independently. + +## 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 accepts those values when all projections agree, so the new contract fails there. +- Causal fix `e268c9bbb0dd9a0e977a5b757c8426fe8d2112be` adds the canonical stable-version gate to `verify_release_identity.py` before package/Tauri/tag projection comparison. +- The checked-in current authority remains `0.1.3`; this repair changes 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. + +### 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 only that repository-controlled stable release preflight and the native updater decision core agree on version grammar. It does not 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 grammar agreement does not substitute for Windows/macOS signing, updater-key authority, immutable release publication, or rights-cleared real-audio scientific acceptance. + +## References + +Preston-Werner, T. (n.d.). *Semantic Versioning 2.0.0*. https://semver.org/spec/v2.0.0.html From 6cee7a66e9027ab09d0f3938442ff111e68db0f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 10:04:59 +0900 Subject: [PATCH 141/308] docs(product): record stable release grammar invariant --- docs/product-technical-gap-baseline.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index a0a0a7db1..097170281 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -20,7 +20,7 @@ The product keeps BandScope-specific audio/rehearsal truth inside BandScope. Org | Project Persistence | Project/cache integrity and scientific cache equivalence have dedicated owner work. | Crash/power-loss, disk-full, interrupted write/recovery, last-known-good project state and packaged-OS fault injection remain buyer gates. | | Collaboration Handoff | Export/handoff belongs to BandScope without creating a second collaboration platform. | Only released, bounded artifacts should cross product boundaries; mutable shared DB or cross-service SQL is not accepted. | | Diagnostics | Existing harness/security/build evidence is substantial. | Buyer-safe diagnostics must avoid audio/project/credential leakage and distinguish user cancel, provider/runtime failure, corrupt project and release/update failure. | -| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. `distribution-core` and `distribution-state` define deterministic replay/rollback policy and durable highest-seen storage for already-authenticated release identity. `distribution-runtime` bounded-parses static updater JSON only as provisional remote input and now retains the selected target's exact admitted URL/signature with the same strict parse, preventing a later transport layer from needing a second looser JSON interpretation. `distribution-download` owns dependency-free Rust byte admission plus an exclusive app-owned staging lifecycle: bounded expected/content-length/chunk/cumulative sizes, sink-failure poisoning, cancel/error cleanup, `create_new` destination admission, flush/`sync_all`, descriptor size verification and still-open sealed descriptors. Exact-size seal is explicitly not trust promotion; sealed-but-unverified artifacts remain cleanup-on-drop. Verifier access is a descriptor-bound positional `Read` stream, not the underlying write-capable `File`, and the reader itself is capped at the seal-time admitted byte count so post-seal file growth cannot reopen the resource bound; truncation below that bound fails closed. | The current Tauri artifact signature verifies downloaded updater bytes, not the whole `raw_json` response. Remote release identity therefore still needs independent metadata authentication. The selected URL/signature binding is parser-consistency evidence only, not authenticity. The download/staging primitives are not yet wired to a production HTTP adapter, so end-to-end hostile-response bounds, redirect/origin behavior, real disk-full/cancel/network-error cleanup and verified artifact digest/signature binding remain gates. A verified-artifact promotion type/path must be added so only successfully authenticated bytes can outlive the verification scope. Packaged power-loss evidence, production updater key/endpoint, Windows/macOS signing/notarization and commercial model rights also remain open. | +| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. Release preflight now enforces the same canonical numeric `MAJOR.MINOR.PATCH` stable-version grammar as the native updater decision core, so prerelease/build/leading-zero identities cannot enter packaging and later be rejected by the runtime. `distribution-core` and `distribution-state` define deterministic replay/rollback policy and durable highest-seen storage for already-authenticated release identity. `distribution-runtime` bounded-parses static updater JSON only as provisional remote input and now retains the selected target's exact admitted URL/signature with the same strict parse, preventing a later transport layer from needing a second looser JSON interpretation. `distribution-download` owns dependency-free Rust byte admission plus an exclusive app-owned staging lifecycle: bounded expected/content-length/chunk/cumulative sizes, sink-failure poisoning, cancel/error cleanup, `create_new` destination admission, flush/`sync_all`, descriptor size verification and still-open sealed descriptors. Exact-size seal is explicitly not trust promotion; sealed-but-unverified artifacts remain cleanup-on-drop. Verifier access is a descriptor-bound positional `Read` stream, not the underlying write-capable `File`, and the reader itself is capped at the seal-time admitted byte count so post-seal file growth cannot reopen the resource bound; truncation below that bound fails closed. | The current Tauri artifact signature verifies downloaded updater bytes, not the whole `raw_json` response. Remote release identity therefore still needs independent metadata authentication. The selected URL/signature binding is parser-consistency evidence only, not authenticity. The download/staging primitives are not yet wired to a production HTTP adapter, so end-to-end hostile-response bounds, redirect/origin behavior, real disk-full/cancel/network-error cleanup and verified artifact digest/signature binding remain gates. A verified-artifact promotion type/path must be added so only successfully authenticated bytes can outlive the verification scope. Packaged power-loss evidence, production updater key/endpoint, Windows/macOS signing/notarization and commercial model rights also remain open. | | UI / Interaction | Rehearsal-first UI is the product surface; Anti-Slop and accessibility are acceptance criteria, not decoration. | Normal/loading/empty/error/permission/responsive states, KO/EN/JA/ZH/VI/ES/DE/FR expansion/fallback, keyboard/focus/contrast/state semantics and actual-audio E2E must be verified on the exact release candidate. | ## Distribution/update decision boundary @@ -32,6 +32,8 @@ The updater path uses evidence classes with different trust semantics and must n 3. GitHub immutable-release verification provides hosted publication evidence for the published asset set. It does not by itself authenticate a client's later `raw_json` response. 4. Distribution highest-seen state is local anti-replay authority only after the release identity entering it is authenticated. +Stable-channel version identity is deliberately narrower than full SemVer. `scripts/checks/verify_release_identity.py` and `apps/desktop/distribution-core::StableVersion` both accept only canonical numeric `MAJOR.MINOR.PATCH` with no leading zeros, prerelease suffix or build metadata. A future beta channel must introduce one explicit ordering/rollback/replay contract instead of allowing release publication and updater consumption to interpret versions differently. + The Rust `apps/desktop/distribution-core` is the deterministic decision layer after authentication. It rejects malformed stable versions, target mismatch, downgrade candidates, metadata older than the locally highest authenticated release, same-version release-identity equivocation and rollback to a build that cannot read the current project schema. It does not fetch, install, sign, notarize, parse arbitrary remote JSON, or write project data. `apps/desktop/distribution-runtime` is the narrow remote-metadata adapter. It accepts at most 256 KiB of UTF-8 JSON, rejects duplicate/unknown members, enforces the exact four desktop targets, bounds signature/URL/artifact-size fields, pins exact-tag URLs to the current BandScope GitHub release namespace and delegates release-identity syntax to `distribution-core`. Its result type is explicitly provisional. The selected target URL/signature are preserved from this same admitted document via `artifact_url()` and `artifact_signature()` so later transport code does not reparse `raw_json`. It has no durable-state dependency and cannot write highest-seen state from syntactically valid `raw_json` alone. @@ -50,6 +52,7 @@ Until those conditions are satisfied, Draft/open PRs and blocked release policie ## Evidence links +- Stable release version identity: `docs/traceability/release-version-identity.md` - Distribution admission: `docs/traceability/updater-release-admission.md` - Release receipt/publication: `docs/traceability/release-artifact-receipt.md` - Updater security metadata, provisional runtime admission, durable freshness state and replay/rollback model: `docs/traceability/updater-security-metadata.md` From 1cf96561008d11f6afc06f1c3ca1eff85fd7bd03 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 10:11:29 +0900 Subject: [PATCH 142/308] test(release): reject version components beyond u64 --- .../analysis-engine/tests/test_release_version_identity.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/services/analysis-engine/tests/test_release_version_identity.py b/services/analysis-engine/tests/test_release_version_identity.py index 1bc2641d8..240e55420 100644 --- a/services/analysis-engine/tests/test_release_version_identity.py +++ b/services/analysis-engine/tests/test_release_version_identity.py @@ -123,6 +123,9 @@ def test_release_identity_guard_rejects_wrong_tag(tmp_path: Path) -> None: "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( From 1c44f25790ef27691a9ae86484f67e87c725c16f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 10:12:00 +0900 Subject: [PATCH 143/308] fix(release): align stable version range with runtime --- scripts/checks/verify_release_identity.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/scripts/checks/verify_release_identity.py b/scripts/checks/verify_release_identity.py index 6fcc91a01..e3f619ce7 100644 --- a/scripts/checks/verify_release_identity.py +++ b/scripts/checks/verify_release_identity.py @@ -11,7 +11,8 @@ - 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 - as the native Distribution/update policy core. Prerelease/build forms therefore + 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. @@ -32,6 +33,24 @@ _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" + + +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 _read_json_object(metadata_path: Path) -> dict[str, Any]: @@ -121,7 +140,7 @@ def verify_release_identity( ): raise ValueError("VERSION must contain exactly one non-empty version line") release_version = version_lines[0] - if _STABLE_VERSION_RE.fullmatch(release_version) is None: + 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") From c2fd0cda7effcb61512c2ea000b9f012953815b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 10:12:44 +0900 Subject: [PATCH 144/308] docs(traceability): close stable version range drift --- docs/traceability/release-version-identity.md | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/docs/traceability/release-version-identity.md b/docs/traceability/release-version-identity.md index 8e1fda10f..d9e6599c4 100644 --- a/docs/traceability/release-version-identity.md +++ b/docs/traceability/release-version-identity.md @@ -2,21 +2,23 @@ ## Problem -BandScope's release preflight previously 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 already accepts only canonical stable `MAJOR.MINOR.PATCH` values with no leading zeros, prerelease suffix, or build metadata. +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`. -That mismatch allowed a future stable-channel source such as `1.2.3-rc.1`, `1.2.3+build.7`, or `01.2.3` to pass repository release identity and reach tag packaging even though the runtime updater would later reject the same release identity. This is a Distribution release-truth defect: publication and consumption must use the same stable-channel grammar before any artifact write begins. +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. ## Decision -`verify_release_identity.py` is the release-pipeline grammar gate because `package_desktop_artifact.py` invokes release preflight before creating release artifacts. Stable-channel `VERSION` now must match exact numeric `MAJOR.MINOR.PATCH` with each component either `0` or a non-zero digit followed by digits. +`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 rule intentionally matches `apps/desktop/distribution-core::StableVersion`. It does not broaden the runtime to prerelease/build SemVer. A future beta/prerelease channel requires a separate explicit release decision and one canonical ordering implementation rather than letting publication and runtime evolve independently. +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. ## 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 accepts those values when all projections agree, so the new contract fails there. -- Causal fix `e268c9bbb0dd9a0e977a5b757c8426fe8d2112be` adds the canonical stable-version gate to `verify_release_identity.py` before package/Tauri/tag projection comparison. -- The checked-in current authority remains `0.1.3`; this repair changes future admission, not the identity of the current source tree. +- 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. +- The checked-in current authority remains `0.1.3`; these repairs change future admission, not the identity of the current source tree. ## Alternatives rejected @@ -24,9 +26,17 @@ The rule intentionally matches `apps/desktop/distribution-core::StableVersion`. 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. + ### 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. +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 @@ -34,9 +44,9 @@ Rejected. Prerelease precedence and build metadata semantics would then differ a ## Claim boundary -This repair proves only that repository-controlled stable release preflight and the native updater decision core agree on version grammar. It does not 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. +This repair proves only that repository-controlled stable release preflight and the native updater decision core agree on version syntax and numeric component range. It does not 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 grammar agreement does not substitute for Windows/macOS signing, updater-key authority, immutable release publication, or rights-cleared real-audio scientific acceptance. +Hosted exact-head CI and independent review remain required before merge. Version-domain agreement does not substitute for Windows/macOS signing, updater-key authority, immutable release publication, or rights-cleared real-audio scientific acceptance. ## References From ba426b4305947248bcee7acf10c175d6c2b7d383 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 10:13:10 +0900 Subject: [PATCH 145/308] docs(product): record stable version range invariant --- docs/product-technical-gap-baseline.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 097170281..e71ef4d85 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -20,7 +20,7 @@ The product keeps BandScope-specific audio/rehearsal truth inside BandScope. Org | Project Persistence | Project/cache integrity and scientific cache equivalence have dedicated owner work. | Crash/power-loss, disk-full, interrupted write/recovery, last-known-good project state and packaged-OS fault injection remain buyer gates. | | Collaboration Handoff | Export/handoff belongs to BandScope without creating a second collaboration platform. | Only released, bounded artifacts should cross product boundaries; mutable shared DB or cross-service SQL is not accepted. | | Diagnostics | Existing harness/security/build evidence is substantial. | Buyer-safe diagnostics must avoid audio/project/credential leakage and distinguish user cancel, provider/runtime failure, corrupt project and release/update failure. | -| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. Release preflight now enforces the same canonical numeric `MAJOR.MINOR.PATCH` stable-version grammar as the native updater decision core, so prerelease/build/leading-zero identities cannot enter packaging and later be rejected by the runtime. `distribution-core` and `distribution-state` define deterministic replay/rollback policy and durable highest-seen storage for already-authenticated release identity. `distribution-runtime` bounded-parses static updater JSON only as provisional remote input and now retains the selected target's exact admitted URL/signature with the same strict parse, preventing a later transport layer from needing a second looser JSON interpretation. `distribution-download` owns dependency-free Rust byte admission plus an exclusive app-owned staging lifecycle: bounded expected/content-length/chunk/cumulative sizes, sink-failure poisoning, cancel/error cleanup, `create_new` destination admission, flush/`sync_all`, descriptor size verification and still-open sealed descriptors. Exact-size seal is explicitly not trust promotion; sealed-but-unverified artifacts remain cleanup-on-drop. Verifier access is a descriptor-bound positional `Read` stream, not the underlying write-capable `File`, and the reader itself is capped at the seal-time admitted byte count so post-seal file growth cannot reopen the resource bound; truncation below that bound fails closed. | The current Tauri artifact signature verifies downloaded updater bytes, not the whole `raw_json` response. Remote release identity therefore still needs independent metadata authentication. The selected URL/signature binding is parser-consistency evidence only, not authenticity. The download/staging primitives are not yet wired to a production HTTP adapter, so end-to-end hostile-response bounds, redirect/origin behavior, real disk-full/cancel/network-error cleanup and verified artifact digest/signature binding remain gates. A verified-artifact promotion type/path must be added so only successfully authenticated bytes can outlive the verification scope. Packaged power-loss evidence, production updater key/endpoint, Windows/macOS signing/notarization and commercial model rights also remain open. | +| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. Release preflight now enforces the same canonical numeric `MAJOR.MINOR.PATCH` syntax and unsigned-64-bit component range as the native updater decision core, so prerelease/build/leading-zero/overflow identities cannot enter packaging and later be rejected by the runtime. `distribution-core` and `distribution-state` define deterministic replay/rollback policy and durable highest-seen storage for already-authenticated release identity. `distribution-runtime` bounded-parses static updater JSON only as provisional remote input and now retains the selected target's exact admitted URL/signature with the same strict parse, preventing a later transport layer from needing a second looser JSON interpretation. `distribution-download` owns dependency-free Rust byte admission plus an exclusive app-owned staging lifecycle: bounded expected/content-length/chunk/cumulative sizes, sink-failure poisoning, cancel/error cleanup, `create_new` destination admission, flush/`sync_all`, descriptor size verification and still-open sealed descriptors. Exact-size seal is explicitly not trust promotion; sealed-but-unverified artifacts remain cleanup-on-drop. Verifier access is a descriptor-bound positional `Read` stream, not the underlying write-capable `File`, and the reader itself is capped at the seal-time admitted byte count so post-seal file growth cannot reopen the resource bound; truncation below that bound fails closed. | The current Tauri artifact signature verifies downloaded updater bytes, not the whole `raw_json` response. Remote release identity therefore still needs independent metadata authentication. The selected URL/signature binding is parser-consistency evidence only, not authenticity. The download/staging primitives are not yet wired to a production HTTP adapter, so end-to-end hostile-response bounds, redirect/origin behavior, real disk-full/cancel/network-error cleanup and verified artifact digest/signature binding remain gates. A verified-artifact promotion type/path must be added so only successfully authenticated bytes can outlive the verification scope. Packaged power-loss evidence, production updater key/endpoint, Windows/macOS signing/notarization and commercial model rights also remain open. | | UI / Interaction | Rehearsal-first UI is the product surface; Anti-Slop and accessibility are acceptance criteria, not decoration. | Normal/loading/empty/error/permission/responsive states, KO/EN/JA/ZH/VI/ES/DE/FR expansion/fallback, keyboard/focus/contrast/state semantics and actual-audio E2E must be verified on the exact release candidate. | ## Distribution/update decision boundary @@ -32,7 +32,7 @@ The updater path uses evidence classes with different trust semantics and must n 3. GitHub immutable-release verification provides hosted publication evidence for the published asset set. It does not by itself authenticate a client's later `raw_json` response. 4. Distribution highest-seen state is local anti-replay authority only after the release identity entering it is authenticated. -Stable-channel version identity is deliberately narrower than full SemVer. `scripts/checks/verify_release_identity.py` and `apps/desktop/distribution-core::StableVersion` both accept only canonical numeric `MAJOR.MINOR.PATCH` with no leading zeros, prerelease suffix or build metadata. A future beta channel must introduce one explicit ordering/rollback/replay contract instead of allowing release publication and updater consumption to interpret versions differently. +Stable-channel version identity is deliberately narrower than full SemVer. `scripts/checks/verify_release_identity.py` and `apps/desktop/distribution-core::StableVersion` both accept only canonical numeric `MAJOR.MINOR.PATCH` with no leading zeros, prerelease suffix or build metadata, and both reject any component above `u64::MAX` (`18446744073709551615`). A future beta channel must introduce one explicit ordering/rollback/replay contract instead of allowing release publication and updater consumption to interpret versions differently. The Rust `apps/desktop/distribution-core` is the deterministic decision layer after authentication. It rejects malformed stable versions, target mismatch, downgrade candidates, metadata older than the locally highest authenticated release, same-version release-identity equivocation and rollback to a build that cannot read the current project schema. It does not fetch, install, sign, notarize, parse arbitrary remote JSON, or write project data. From 6e6ebe292d5a2df25dfea90585d557b919eaebcf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 11:08:06 +0900 Subject: [PATCH 146/308] feat(distribution): add updater transport crate --- apps/desktop/distribution-transport/Cargo.toml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 apps/desktop/distribution-transport/Cargo.toml 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" From 5f2986361b2a26fc0815d60b98c9c49d2e4d258e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 11:08:15 +0900 Subject: [PATCH 147/308] build(distribution): lock transport owner graph --- .../desktop/distribution-transport/Cargo.lock | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 apps/desktop/distribution-transport/Cargo.lock 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", +] From 281948161eb3926f309ee4d4ad2c9e3e9cd29631 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 11:08:59 +0900 Subject: [PATCH 148/308] feat(distribution): add transport response boundary --- .../desktop/distribution-transport/src/lib.rs | 290 ++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 apps/desktop/distribution-transport/src/lib.rs diff --git a/apps/desktop/distribution-transport/src/lib.rs b/apps/desktop/distribution-transport/src/lib.rs new file mode 100644 index 000000000..3c1fab01c --- /dev/null +++ b/apps/desktop/distribution-transport/src/lib.rs @@ -0,0 +1,290 @@ +//! 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::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/"; + +/// 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), + /// GitHub returned a redirect but this source revision has not admitted it yet. + RedirectUnsupported, + /// A redirect response omitted or supplied an invalid Location value. + InvalidRedirectLocation, + /// The redirected request completed at a URL different from the admitted Location. + RedirectEffectiveUrlDrift, + /// A redirected release-asset request attempted another redirect. + RedirectChainingRejected, +} + +/// Failure while converting an admitted response head into bounded staged bytes. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TransportDownloadError { + /// 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, Debug, Eq, PartialEq)] +pub struct AdmittedRedirect { + source_url: String, + location: String, +} + +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, Debug, Eq, PartialEq)] +pub struct AdmittedDownloadHead { + effective_url: String, + artifact_name: String, + expected_size_bytes: u64, + expected_artifact_sha256: String, + artifact_signature: String, +} + +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-length admission runs before filesystem mutation, so an immediate + /// length mismatch cannot create a staging artifact. The returned value owns + /// cleanup through the underlying `StagedArtifactFile` lifecycle. + pub fn start_staging( + &self, + staging_directory: &Path, + response_content_length: Option, + ) -> Result { + 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, Debug, Eq, PartialEq)] +pub struct ReleaseTransportPolicy { + initial_url: String, + artifact_name: String, + expected_size_bytes: u64, + expected_artifact_sha256: String, + artifact_signature: String, +} + +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. + 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 the network client's redirect behavior. + /// + /// The network adapter must disable automatic redirects and report the exact + /// effective URL and optional `Location` value. A direct `200` can stream; + /// redirect admission is intentionally RED in this source revision. + 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 _ = redirect_location.ok_or(TransportPolicyError::InvalidRedirectLocation)?; + Err(TransportPolicyError::RedirectUnsupported) + } + other => Err(TransportPolicyError::UnexpectedInitialStatus(other)), + } + } + + /// Admit the response produced by one previously admitted redirect. + /// + /// 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 || effective_url != redirect.location { + return Err(TransportPolicyError::RedirectEffectiveUrlDrift); + } + if (300..400).contains(&status) { + return Err(TransportPolicyError::RedirectChainingRejected); + } + if status != 200 { + return Err(TransportPolicyError::UnexpectedInitialStatus(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(()) +} From 8f39dfc57026a25389f985e06dacee025818c5b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 11:09:37 +0900 Subject: [PATCH 149/308] test(distribution): require admitted GitHub release redirect --- .../tests/transport_policy.rs | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 apps/desktop/distribution-transport/tests/transport_policy.rs 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..e5ed77600 --- /dev/null +++ b/apps/desktop/distribution-transport/tests/transport_policy.rs @@ -0,0 +1,136 @@ +use bandscope_distribution_download::DownloadAdmissionError; +use bandscope_distribution_runtime::admit_untrusted_raw_json; +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() -> Vec { + 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}"}}}}}}}}"# + ) + .into_bytes() +} + +fn policy() -> ReleaseTransportPolicy { + let metadata = admit_untrusted_raw_json(&updater_document(), "windows-x86_64") + .expect("fixture must satisfy provisional metadata admission"); + ReleaseTransportPolicy::from_provisional(&metadata).expect("transport projection") +} + +fn scratch_dir(label: &str) -> std::path::PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock after unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "bandscope-distribution-transport-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir(&path).expect("create isolated staging directory"); + path +} + +#[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)) + .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 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)).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 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) + .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"); +} From 1b4f7a0a840d917f54fdb6b78ec861ba4b5ba0f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 11:09:46 +0900 Subject: [PATCH 150/308] test(distribution): gate transport contract in root suite --- .../analysis-engine/tests/test_distribution_update_core.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/analysis-engine/tests/test_distribution_update_core.py b/services/analysis-engine/tests/test_distribution_update_core.py index 5193bd535..c048e555a 100644 --- a/services/analysis-engine/tests/test_distribution_update_core.py +++ b/services/analysis-engine/tests/test_distribution_update_core.py @@ -11,11 +11,12 @@ _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", ) def test_distribution_update_native_suites_are_green() -> None: - """Run the locked decision, state, metadata, and bounded-download Rust contracts.""" + """Run locked decision, state, metadata, download, and transport Rust contracts.""" for manifest in _MANIFESTS: completed = subprocess.run( [ From 4964c3cd1472ed6ac9c7a9223d3da533e1af6096 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 11:10:14 +0900 Subject: [PATCH 151/308] fix(distribution): admit one-hop GitHub release asset redirects --- .../desktop/distribution-transport/src/lib.rs | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/desktop/distribution-transport/src/lib.rs b/apps/desktop/distribution-transport/src/lib.rs index 3c1fab01c..cd71231e8 100644 --- a/apps/desktop/distribution-transport/src/lib.rs +++ b/apps/desktop/distribution-transport/src/lib.rs @@ -30,14 +30,14 @@ pub enum TransportPolicyError { EffectiveUrlDrift, /// The initial response status is not an admitted direct-download or redirect status. UnexpectedInitialStatus(u16), - /// GitHub returned a redirect but this source revision has not admitted it yet. - RedirectUnsupported, /// A redirect response omitted or supplied an invalid Location value. InvalidRedirectLocation, /// 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. @@ -170,11 +170,13 @@ impl ReleaseTransportPolicy { &self.initial_url } - /// Admit the first HTTP response without trusting the network client's redirect behavior. + /// Admit the first HTTP response without trusting automatic redirect behavior. /// /// The network adapter must disable automatic redirects and report the exact - /// effective URL and optional `Location` value. A direct `200` can stream; - /// redirect admission is intentionally RED in this source revision. + /// 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, @@ -187,8 +189,13 @@ impl ReleaseTransportPolicy { match status { 200 => Ok(ResponseDecision::Download(self.download_head(effective_url))), 302 => { - let _ = redirect_location.ok_or(TransportPolicyError::InvalidRedirectLocation)?; - Err(TransportPolicyError::RedirectUnsupported) + 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(), + })) } other => Err(TransportPolicyError::UnexpectedInitialStatus(other)), } @@ -211,7 +218,7 @@ impl ReleaseTransportPolicy { return Err(TransportPolicyError::RedirectChainingRejected); } if status != 200 { - return Err(TransportPolicyError::UnexpectedInitialStatus(status)); + return Err(TransportPolicyError::UnexpectedRedirectStatus(status)); } Ok(self.download_head(effective_url)) } From d424dd8993905e2ef5876de7715a8e3e35af5efc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 11:11:04 +0900 Subject: [PATCH 152/308] docs(distribution): trace updater transport policy --- docs/traceability/updater-transport-policy.md | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 docs/traceability/updater-transport-policy.md diff --git a/docs/traceability/updater-transport-policy.md b/docs/traceability/updater-transport-policy.md new file mode 100644 index 000000000..e5c44c5c3 --- /dev/null +++ b/docs/traceability/updater-transport-policy.md @@ -0,0 +1,57 @@ +# 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, or hand response bytes to staging without proving 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. + +## 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. +- Do not add an HTTP client dependency merely to express deterministic policy. +- 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 second redirect is rejected. A redirected `200` must report the exact admitted redirect URL as its effective URL. +- Response bodies reach disk only through `distribution-download`, preserving its expected-size, optional `Content-Length`, per-chunk, cumulative-overrun, poison and cleanup contracts. +- Content-length mismatch is 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 this small 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. + +## Selected design + +`apps/desktop/distribution-transport` is a small Rust owner between `distribution-runtime` 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. `admit_initial_response` accepts exact-URL `200`, or returns an explicit one-hop redirect decision for an admitted GitHub release-asset CDN location. `admit_redirect_response` requires that the second request terminate in `200` at that exact location. `AdmittedDownloadHead::start_staging` creates `ArtifactDownloadAdmission` before it creates a staging file, and `TransportDownload` routes chunks and exact completion into the existing sealed-descriptor lifecycle. + +The API intentionally contains no socket/client, JSON parser, installer, freshness-state repository or project-persistence dependency. It also contains no verified-artifact type: size/status/origin 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. + +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 provisional metadata projection, HTTP status, HTTP client's effective URL, redirect `Location`, `Content-Length` and response chunks. The policy uses exact URL equality before body admission, a bounded redirect string, HTTPS exact-origin pinning for the admitted CDN hop, one-hop redirect depth, and existing bounded chunk/file admission. No network, credential, subprocess, generic filesystem, installer, project or freshness-state capability is added here. Cancel/error cleanup continues to be owned by `distribution-download`. + +This boundary does not authenticate remote metadata, does not validate the Tauri signature, does not hash the sealed descriptor, 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 From adc52ef3db86b17a61544bf8052226a9bcdfd211 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 11:11:35 +0900 Subject: [PATCH 153/308] docs(product): align distribution transport gap baseline --- docs/product-technical-gap-baseline.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index e71ef4d85..b5e2b11a0 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -20,7 +20,7 @@ The product keeps BandScope-specific audio/rehearsal truth inside BandScope. Org | Project Persistence | Project/cache integrity and scientific cache equivalence have dedicated owner work. | Crash/power-loss, disk-full, interrupted write/recovery, last-known-good project state and packaged-OS fault injection remain buyer gates. | | Collaboration Handoff | Export/handoff belongs to BandScope without creating a second collaboration platform. | Only released, bounded artifacts should cross product boundaries; mutable shared DB or cross-service SQL is not accepted. | | Diagnostics | Existing harness/security/build evidence is substantial. | Buyer-safe diagnostics must avoid audio/project/credential leakage and distinguish user cancel, provider/runtime failure, corrupt project and release/update failure. | -| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. Release preflight now enforces the same canonical numeric `MAJOR.MINOR.PATCH` syntax and unsigned-64-bit component range as the native updater decision core, so prerelease/build/leading-zero/overflow identities cannot enter packaging and later be rejected by the runtime. `distribution-core` and `distribution-state` define deterministic replay/rollback policy and durable highest-seen storage for already-authenticated release identity. `distribution-runtime` bounded-parses static updater JSON only as provisional remote input and now retains the selected target's exact admitted URL/signature with the same strict parse, preventing a later transport layer from needing a second looser JSON interpretation. `distribution-download` owns dependency-free Rust byte admission plus an exclusive app-owned staging lifecycle: bounded expected/content-length/chunk/cumulative sizes, sink-failure poisoning, cancel/error cleanup, `create_new` destination admission, flush/`sync_all`, descriptor size verification and still-open sealed descriptors. Exact-size seal is explicitly not trust promotion; sealed-but-unverified artifacts remain cleanup-on-drop. Verifier access is a descriptor-bound positional `Read` stream, not the underlying write-capable `File`, and the reader itself is capped at the seal-time admitted byte count so post-seal file growth cannot reopen the resource bound; truncation below that bound fails closed. | The current Tauri artifact signature verifies downloaded updater bytes, not the whole `raw_json` response. Remote release identity therefore still needs independent metadata authentication. The selected URL/signature binding is parser-consistency evidence only, not authenticity. The download/staging primitives are not yet wired to a production HTTP adapter, so end-to-end hostile-response bounds, redirect/origin behavior, real disk-full/cancel/network-error cleanup and verified artifact digest/signature binding remain gates. A verified-artifact promotion type/path must be added so only successfully authenticated bytes can outlive the verification scope. Packaged power-loss evidence, production updater key/endpoint, Windows/macOS signing/notarization and commercial model rights also remain open. | +| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. Release preflight enforces the same canonical numeric `MAJOR.MINOR.PATCH` syntax and unsigned-64-bit component range as the native updater decision core. `distribution-core` and `distribution-state` define deterministic replay/rollback policy and durable highest-seen storage for already-authenticated release identity. `distribution-runtime` strict-parses static updater JSON only as provisional remote input and retains one selected target projection. `distribution-transport` now consumes that projection without reparsing JSON, admits either an exact direct `200` or one explicit `302` hop to the current `release-assets.githubusercontent.com` egress allowlist, rejects effective-URL drift/redirect chaining, and composes the final response with `distribution-download`. `distribution-download` owns dependency-free byte admission plus exclusive staging, exact completion, cleanup-on-failure and descriptor-bound sealed reads. | The current Tauri artifact signature verifies downloaded updater bytes, not the whole `raw_json` response. Remote release identity still needs independent metadata authentication. The new transport crate is a deterministic response-policy/staging bridge, not an HTTP client: production sockets/TLS/proxy/captive-portal behavior, actual automatic-redirect disablement, real disk-full/network-error/cancel evidence and packaged behavior remain gates. The current CDN hostname is a deliberate BandScope allowlist, not a claimed permanent GitHub API guarantee. Digest/signature verification on the exact sealed descriptor and an explicit verified-artifact promotion type/path are still required before any bytes or identity can outlive verification scope. Production updater key/endpoint, Windows/macOS signing/notarization and commercial model rights also remain open. | | UI / Interaction | Rehearsal-first UI is the product surface; Anti-Slop and accessibility are acceptance criteria, not decoration. | Normal/loading/empty/error/permission/responsive states, KO/EN/JA/ZH/VI/ES/DE/FR expansion/fallback, keyboard/focus/contrast/state semantics and actual-audio E2E must be verified on the exact release candidate. | ## Distribution/update decision boundary @@ -38,15 +38,17 @@ The Rust `apps/desktop/distribution-core` is the deterministic decision layer af `apps/desktop/distribution-runtime` is the narrow remote-metadata adapter. It accepts at most 256 KiB of UTF-8 JSON, rejects duplicate/unknown members, enforces the exact four desktop targets, bounds signature/URL/artifact-size fields, pins exact-tag URLs to the current BandScope GitHub release namespace and delegates release-identity syntax to `distribution-core`. Its result type is explicitly provisional. The selected target URL/signature are preserved from this same admitted document via `artifact_url()` and `artifact_signature()` so later transport code does not reparse `raw_json`. It has no durable-state dependency and cannot write highest-seen state from syntactically valid `raw_json` alone. -`apps/desktop/distribution-download` is a separate network-library-independent streaming/staging boundary. It rejects zero/over-ceiling expected sizes, optional `Content-Length` mismatch, chunks larger than 1 MiB, cumulative overrun before the offending bytes reach the sink, truncated completion and sink-write failure. A failed attempt is poisoned so later chunks cannot manufacture a success receipt. Its staging file uses a bounded portable basename under an existing non-symlink app-owned directory, exclusive `create_new`, cleanup on cancel/error, and flush/`sync_all` plus descriptor size verification before returning a still-open sealed artifact. The sealed artifact remains cleanup-on-drop because exact byte count and `sync_all()` do not establish digest, signature or metadata authenticity. Downstream verification gets a positional read-only wrapper over that exact open descriptor; the write-enabled staging `File` itself is no longer exposed. The reader owns the original admitted length as a hard upper bound, ignores any later appended tail, and reports early EOF if the descriptor is truncated below that length. This closes the pure byte/local-staging primitive gap without claiming that the current Tauri updater path routes its HTTP body through them or that any unverified staged bytes are safe to retain. +`apps/desktop/distribution-transport` is the deterministic response-state bridge between provisional metadata and bounded bytes. It copies the selected URL/signature/size/digest from `ProvisionalUpdateMetadata`, admits a direct `200` only at the exact initial URL, and handles GitHub's possible `302` release-asset delivery as one explicit hop rather than hidden HTTP-client behavior. The admitted redirect is currently limited to exact HTTPS `release-assets.githubusercontent.com`; a host change fails closed until the BandScope egress policy is deliberately revised. The redirect response must terminate in `200` at the exact admitted Location and a second redirect is rejected. `Content-Length` admission happens before staging-file creation. This crate has no socket, HTTP client, JSON parser, installer, trust-promotion or state-repository capability. + +`apps/desktop/distribution-download` is a separate network-library-independent streaming/staging boundary. It rejects zero/over-ceiling expected sizes, optional `Content-Length` mismatch, chunks larger than 1 MiB, cumulative overrun before the offending bytes reach the sink, truncated completion and sink-write failure. A failed attempt is poisoned so later chunks cannot manufacture a success receipt. Its staging file uses a bounded portable basename under an existing non-symlink app-owned directory, exclusive `create_new`, cleanup on cancel/error, and flush/`sync_all` plus descriptor size verification before returning a still-open sealed artifact. The sealed artifact remains cleanup-on-drop because exact byte count and `sync_all()` do not establish digest, signature or metadata authenticity. Downstream verification gets a positional read-only wrapper over that exact open descriptor; the write-enabled staging `File` itself is not exposed. The reader owns the original admitted length as a hard upper bound, ignores any later appended tail, and reports early EOF if the descriptor is truncated below that length. Highest-seen update identity remains Distribution state, not Project Persistence state. `apps/desktop/distribution-state` provides a separate bounded append-only Rust log that revalidates committed identities, rejects local version regression/equivocation, synchronizes successful appends and recovers only a syntactically valid torn final record prefix. It deliberately does not claim packaged power-loss equivalence across Windows/macOS until platform fault-injection evidence exists. Project Persistence remains authoritative only for project bytes and the project-schema evidence used by rollback compatibility checks. -Current Tauri updater APIs still materialize a verified update as in-memory bytes. The repository now owns stricter streaming and staging primitives plus a single strict source of selected transport metadata, but commercial readiness requires a production network adapter that actually consumes `artifact_url()`/`artifact_signature()` and streams bounded response chunks into `distribution-download` while preserving canonical origin/redirect policy. Counting progress callbacks or checking the fully buffered `Vec` after download is not equivalent evidence. `sync_all()` in a unit/integration test is also not packaged Windows/macOS power-loss proof. +Current Tauri updater APIs still materialize a verified update as in-memory bytes. The repository now owns strict selected transport metadata, explicit response/redirect admission and bounded staging primitives, but commercial readiness still requires a production network adapter that disables implicit redirects, reports exact status/effective URL/Location into `distribution-transport`, and streams actual response chunks into the returned staging path. Counting progress callbacks or checking the fully buffered `Vec` after download is not equivalent evidence. `sync_all()` in a unit/integration test is also not packaged Windows/macOS power-loss proof. ## Release gate -A release candidate is not commercial-ready until all of the following are true on the exact protected head: required checks and independent review are terminal/qualifying; Windows artifacts are signed by the approved publisher and macOS artifacts are signed/notarized/stapled; updater authority is admitted without placeholder values; remote updater metadata has an authenticated binding before it can mutate freshness state; updater artifact bytes are signature-verified and matched to authenticated digest/size evidence using the exact sealed descriptor; only verified bytes can be explicitly promoted beyond sealed cleanup scope; updater replay/rollback/recovery is exercised on packaged targets; production HTTP download actually consumes the strict admitted transport fields, passes through bounded streaming/staging admission and survives hostile/truncated/oversized/disk-full/cancel/network-error cases; SBOM/NOTICE/provenance agree with exact shipped bytes; model rights and exact model provenance are established; rights-cleared real-audio scientific acceptance is reproducible; the updater can recover to a compatible known-good build without losing project usability; and material UI passes actual-audio, responsive, locale and accessibility E2E. +A release candidate is not commercial-ready until all of the following are true on the exact protected head: required checks and independent review are terminal/qualifying; Windows artifacts are signed by the approved publisher and macOS artifacts are signed/notarized/stapled; updater authority is admitted without placeholder values; remote updater metadata has an authenticated binding before it can mutate freshness state; updater artifact bytes are signature-verified and matched to authenticated digest/size evidence using the exact sealed descriptor; only verified bytes can be explicitly promoted beyond sealed cleanup scope; updater replay/rollback/recovery is exercised on packaged targets; the production HTTP adapter disables implicit redirects, feeds response status/effective URL/Location through `distribution-transport`, streams body bytes through bounded staging, and survives hostile/truncated/oversized/disk-full/cancel/network-error cases; SBOM/NOTICE/provenance agree with exact shipped bytes; model rights and exact model provenance are established; rights-cleared real-audio scientific acceptance is reproducible; the updater can recover to a compatible known-good build without losing project usability; and material UI passes actual-audio, responsive, locale and accessibility E2E. Until those conditions are satisfied, Draft/open PRs and blocked release policies are expected safety states rather than reasons to bypass gates. @@ -57,6 +59,7 @@ Until those conditions are satisfied, Draft/open PRs and blocked release policie - Release receipt/publication: `docs/traceability/release-artifact-receipt.md` - Updater security metadata, provisional runtime admission, durable freshness state and replay/rollback model: `docs/traceability/updater-security-metadata.md` - Bounded updater artifact streaming/staging: `docs/traceability/updater-bounded-download.md` +- Explicit release-response/redirect transport policy: `docs/traceability/updater-transport-policy.md` - Security trust boundaries: `docs/security/app-security.md` - Cross-platform release controls: `docs/security/cross-platform-build-policy.md` - Architecture ownership: `ARCHITECTURE.md` From 2e7717349fe17c8a98fa9cc1845d81d8fdc9061f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 11:12:44 +0900 Subject: [PATCH 154/308] docs(architecture): register updater transport boundary --- ARCHITECTURE.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f505dfb28..d9ee6a84d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -60,6 +60,7 @@ Last updated: 2026-09-15 - `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; returns provisional metadata with the selected target's exact admitted URL/signature from the same strict parse and cannot mutate freshness state +- `apps/desktop/distribution-transport` - deterministic Rust response-state bridge from the strict provisional target projection to bounded updater staging; owns exact effective-URL checks and one-hop release-asset redirect admission, but not sockets, TLS, metadata authentication, signatures 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 @@ -72,7 +73,8 @@ Last updated: 2026-09-15 - 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 and invalid release-identity syntax; 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-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 consume the admitted transport fields and route actual response bytes through this boundary instead of reparsing raw JSON or relying on Tauri's full-response buffering. +- `apps/desktop/distribution-transport` consumes only that already-selected provisional projection and admits HTTP response state without reparsing `raw_json`. 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 or freshness-state capability. +- `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. @@ -81,7 +83,7 @@ Last updated: 2026-09-15 - 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`, and `docs/traceability/updater-bounded-download.md`. +- 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 @@ -120,7 +122,7 @@ Last updated: 2026-09-15 - 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-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. +- 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. From 8313e9fb2fe66711e2c3e0432413a94355fdf6e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 11:14:21 +0900 Subject: [PATCH 155/308] test(distribution): reject malformed Tauri signature envelopes --- .../tests/transport_policy.rs | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/desktop/distribution-transport/tests/transport_policy.rs b/apps/desktop/distribution-transport/tests/transport_policy.rs index e5ed77600..7d63f87e2 100644 --- a/apps/desktop/distribution-transport/tests/transport_policy.rs +++ b/apps/desktop/distribution-transport/tests/transport_policy.rs @@ -11,13 +11,17 @@ const DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 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() -> Vec { +fn updater_document_with_signature(signature: &str) -> Vec { 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}"}}}}}}}}"# + 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() -> ReleaseTransportPolicy { let metadata = admit_untrusted_raw_json(&updater_document(), "windows-x86_64") .expect("fixture must satisfy provisional metadata admission"); @@ -37,6 +41,20 @@ fn scratch_dir(label: &str) -> std::path::PathBuf { path } +#[test] +fn malformed_tauri_signature_envelope_is_rejected_before_network_admission() { + let metadata = admit_untrusted_raw_json( + &updater_document_with_signature("not-base64!"), + "windows-x86_64", + ) + .expect("metadata syntax alone remains provisional"); + + assert_eq!( + ReleaseTransportPolicy::from_provisional(&metadata), + Err(TransportPolicyError::InvalidArtifactSignatureEnvelope) + ); +} + #[test] fn github_release_redirect_is_one_hop_and_streams_through_bounded_staging() { let policy = policy(); From 6e5e42f2a20001009330c438178afa1ca811ab51 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 11:14:55 +0900 Subject: [PATCH 156/308] fix(distribution): validate Tauri signature envelope before network --- .../desktop/distribution-transport/src/lib.rs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/apps/desktop/distribution-transport/src/lib.rs b/apps/desktop/distribution-transport/src/lib.rs index cd71231e8..fed442f56 100644 --- a/apps/desktop/distribution-transport/src/lib.rs +++ b/apps/desktop/distribution-transport/src/lib.rs @@ -26,6 +26,8 @@ const RELEASE_ASSET_CDN_PREFIX: &str = "https://release-assets.githubusercontent pub enum TransportPolicyError { /// The provisional updater URL did not contain a direct artifact basename. InvalidAdmittedArtifactUrl, + /// The provisional Tauri updater signature is not canonical standard base64. + InvalidArtifactSignatureEnvelope, /// 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. @@ -147,6 +149,9 @@ impl ReleaseTransportPolicy { /// /// No raw JSON is accepted here. The URL, signature, size and digest are /// copied from `ProvisionalUpdateMetadata` and remain provisional evidence. + /// The signature is required to have Tauri's outer canonical standard-base64 + /// envelope before any network request, but this does not verify its minisign + /// payload or authenticate the remote metadata that carried it. pub fn from_provisional( metadata: &ProvisionalUpdateMetadata, ) -> Result { @@ -156,6 +161,9 @@ impl ReleaseTransportPolicy { .map(|(_, name)| name) .filter(|name| !name.is_empty()) .ok_or(TransportPolicyError::InvalidAdmittedArtifactUrl)?; + if !is_canonical_standard_base64(metadata.artifact_signature()) { + return Err(TransportPolicyError::InvalidArtifactSignatureEnvelope); + } Ok(Self { initial_url: initial_url.to_owned(), artifact_name: artifact_name.to_owned(), @@ -275,6 +283,50 @@ impl TransportDownload { } } +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_asset_cdn_url(value: &str) -> Result<(), TransportPolicyError> { if value.is_empty() || value.len() > MAX_REDIRECT_URL_BYTES From 03c1314884a4044129ead75db59d341b80ed4499 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 11:15:34 +0900 Subject: [PATCH 157/308] test(release): reject non-base64 updater signatures --- .../test_updater_manifest_publication.py | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/services/analysis-engine/tests/test_updater_manifest_publication.py b/services/analysis-engine/tests/test_updater_manifest_publication.py index c03ff400f..6126e697a 100644 --- a/services/analysis-engine/tests/test_updater_manifest_publication.py +++ b/services/analysis-engine/tests/test_updater_manifest_publication.py @@ -2,6 +2,7 @@ from __future__ import annotations +import base64 import hashlib import json import subprocess @@ -80,7 +81,9 @@ def _write_release_graph( updater_payload = f"updater-{platform}-{arch}".encode() (artifacts / updater_name).write_bytes(updater_payload) signature_name = f"{updater_name}.sig" - signature_text = f"signature-{platform}-{arch}" + 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 @@ -193,6 +196,33 @@ def test_manifest_binds_exact_receipts_and_signature_contents(tmp_path: Path) -> } +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: From 2c7c772abcceff96dafceaaaa3b6a4e2af5f8cbc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 11:16:37 +0900 Subject: [PATCH 158/308] fix(release): enforce Tauri signature envelope before publication --- scripts/release/build_updater_manifest.py | 37 +++++++++++++++-------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/scripts/release/build_updater_manifest.py b/scripts/release/build_updater_manifest.py index 0b0d3d8f7..67af54316 100644 --- a/scripts/release/build_updater_manifest.py +++ b/scripts/release/build_updater_manifest.py @@ -6,19 +6,22 @@ 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 - and an exact size/SHA-256 comparison against the target receipt. 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 a filesystem authority. + 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 @@ -253,11 +256,21 @@ def _signature_text( if not isinstance(expected_digest, str) or digest != expected_digest: raise ValueError("updater signature digest does not match release receipt") try: - text = payload.decode("utf-8") + text = payload.decode("ascii") except UnicodeError as error: - raise ValueError("updater signature must contain UTF-8 text") from error - if not text.strip() or "\x00" in text: - raise ValueError("updater signature must contain non-empty UTF-8 text") + 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 From fd906e235af7e3a0373569691a2545d1268f518a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 11:17:36 +0900 Subject: [PATCH 159/308] docs(distribution): trace signature-envelope admission --- docs/traceability/updater-transport-policy.md | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/docs/traceability/updater-transport-policy.md b/docs/traceability/updater-transport-policy.md index e5c44c5c3..b5c67e449 100644 --- a/docs/traceability/updater-transport-policy.md +++ b/docs/traceability/updater-transport-policy.md @@ -8,11 +8,15 @@ BandScope already has a strict provisional updater-metadata parser and a bounded 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. + ## 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. -- Do not add an HTTP client dependency merely to express deterministic policy. +- Require the selected Tauri signature to be canonical RFC 4648 standard base64 before any network request. This validates only the outer encoding contract, not the decoded minisign structure or cryptographic signature. +- 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 an HTTP client or base64 dependency merely to express deterministic policy; the Rust envelope 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. @@ -25,13 +29,17 @@ GitHub's REST release-asset contract requires clients requesting binary asset co 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 this small 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. +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. Reimplementing minisign verification was also rejected: Tauri remains the signature-verification owner, and BandScope only mirrors the documented outer transport envelope needed to fail earlier. + ## Selected design `apps/desktop/distribution-transport` is a small Rust owner between `distribution-runtime` 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. `admit_initial_response` accepts exact-URL `200`, or returns an explicit one-hop redirect decision for an admitted GitHub release-asset CDN location. `admit_redirect_response` requires that the second request terminate in `200` at that exact location. `AdmittedDownloadHead::start_staging` creates `ArtifactDownloadAdmission` before it creates a staging file, and `TransportDownload` routes chunks and exact completion into the existing sealed-descriptor lifecycle. +`ReleaseTransportPolicy::from_provisional` copies only the already-selected target projection: canonical initial URL, safe artifact basename, declared byte size, SHA-256 and Tauri signature. Before response admission it validates that signature as canonical standard base64, including padding placement and zero pad bits. `admit_initial_response` accepts exact-URL `200`, or returns an explicit one-hop redirect decision for an admitted GitHub release-asset CDN location. `admit_redirect_response` requires that the second request terminate in `200` at that exact location. `AdmittedDownloadHead::start_staging` creates `ArtifactDownloadAdmission` before it creates a staging file, and `TransportDownload` routes chunks and exact completion into the existing sealed-descriptor lifecycle. -The API intentionally contains no socket/client, JSON parser, installer, freshness-state repository or project-persistence dependency. It also contains no verified-artifact type: size/status/origin evidence is not cryptographic authenticity. +`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 evidence is not cryptographic authenticity. ## RED → repair evidence @@ -39,14 +47,16 @@ The API intentionally contains no socket/client, JSON parser, installer, freshne - `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 a clean transport RED proving that syntactically admitted `not-base64!` metadata must not reach network response admission. `6e5e42f2a20001009330c438178afa1ca811ab51` added the dependency-free canonical-base64 envelope guard. +- `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. 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 provisional metadata projection, HTTP status, HTTP client's effective URL, redirect `Location`, `Content-Length` and response chunks. The policy uses exact URL equality before body admission, a bounded redirect string, HTTPS exact-origin pinning for the admitted CDN hop, one-hop redirect depth, and existing bounded chunk/file admission. No network, credential, subprocess, generic filesystem, installer, project or freshness-state capability is added here. Cancel/error cleanup continues to be owned by `distribution-download`. +Untrusted inputs are the provisional metadata projection, signature envelope, HTTP status, HTTP client's effective URL, redirect `Location`, `Content-Length` and response chunks. The policy uses canonical outer-base64 admission before network work, exact URL equality before body admission, a bounded redirect string, HTTPS exact-origin pinning for the admitted CDN hop, one-hop redirect depth, and existing bounded chunk/file admission. No network, credential, subprocess, generic filesystem, installer, project or freshness-state capability is added here. Cancel/error cleanup continues to be owned by `distribution-download`. -This boundary does not authenticate remote metadata, does not validate the Tauri signature, does not hash the sealed descriptor, and does not prove packaged Windows/macOS networking behavior. Those claims remain release gates. +This boundary does not authenticate remote metadata, does not parse or cryptographically verify the decoded minisign signature, does not hash the sealed descriptor, and does not prove packaged Windows/macOS networking behavior. Those claims remain release gates. ## References @@ -55,3 +65,5 @@ GitHub. (2026). *REST API endpoints for release assets*. GitHub Docs. https://do 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 From 0e318ef4e3160a66438b36c5d26d9210bb2526cd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 11:18:10 +0900 Subject: [PATCH 160/308] docs(product): align updater signature-envelope gap --- docs/product-technical-gap-baseline.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index b5e2b11a0..9f9901da2 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -20,7 +20,7 @@ The product keeps BandScope-specific audio/rehearsal truth inside BandScope. Org | Project Persistence | Project/cache integrity and scientific cache equivalence have dedicated owner work. | Crash/power-loss, disk-full, interrupted write/recovery, last-known-good project state and packaged-OS fault injection remain buyer gates. | | Collaboration Handoff | Export/handoff belongs to BandScope without creating a second collaboration platform. | Only released, bounded artifacts should cross product boundaries; mutable shared DB or cross-service SQL is not accepted. | | Diagnostics | Existing harness/security/build evidence is substantial. | Buyer-safe diagnostics must avoid audio/project/credential leakage and distinguish user cancel, provider/runtime failure, corrupt project and release/update failure. | -| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. Release preflight enforces the same canonical numeric `MAJOR.MINOR.PATCH` syntax and unsigned-64-bit component range as the native updater decision core. `distribution-core` and `distribution-state` define deterministic replay/rollback policy and durable highest-seen storage for already-authenticated release identity. `distribution-runtime` strict-parses static updater JSON only as provisional remote input and retains one selected target projection. `distribution-transport` now consumes that projection without reparsing JSON, admits either an exact direct `200` or one explicit `302` hop to the current `release-assets.githubusercontent.com` egress allowlist, rejects effective-URL drift/redirect chaining, and composes the final response with `distribution-download`. `distribution-download` owns dependency-free byte admission plus exclusive staging, exact completion, cleanup-on-failure and descriptor-bound sealed reads. | The current Tauri artifact signature verifies downloaded updater bytes, not the whole `raw_json` response. Remote release identity still needs independent metadata authentication. The new transport crate is a deterministic response-policy/staging bridge, not an HTTP client: production sockets/TLS/proxy/captive-portal behavior, actual automatic-redirect disablement, real disk-full/network-error/cancel evidence and packaged behavior remain gates. The current CDN hostname is a deliberate BandScope allowlist, not a claimed permanent GitHub API guarantee. Digest/signature verification on the exact sealed descriptor and an explicit verified-artifact promotion type/path are still required before any bytes or identity can outlive verification scope. Production updater key/endpoint, Windows/macOS signing/notarization and commercial model rights also remain open. | +| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. Release preflight enforces the same canonical numeric `MAJOR.MINOR.PATCH` syntax and unsigned-64-bit component range as the native updater decision core. `distribution-core` and `distribution-state` define deterministic replay/rollback policy and durable highest-seen storage for already-authenticated release identity. `distribution-runtime` strict-parses static updater JSON only as provisional remote input and retains one selected target projection. `distribution-transport` consumes that projection without reparsing JSON, rejects non-canonical Tauri outer signature base64 before network work, admits either an exact direct `200` or one explicit `302` hop to the current `release-assets.githubusercontent.com` egress allowlist, rejects effective-URL drift/redirect chaining, and composes the final response with `distribution-download`. Publication also rejects receipt-consistent `.sig` bytes that are not canonical standard base64 decoding to UTF-8. `distribution-download` owns dependency-free byte admission plus exclusive staging, exact completion, cleanup-on-failure and descriptor-bound sealed reads. | Tauri cryptographic verification of the decoded updater signature and exact downloaded bytes is still a later trust step; outer base64 validation is only an early syntax/resource gate. Remote release identity still needs independent metadata authentication. The transport crate is a deterministic response-policy/staging bridge, not an HTTP client: production sockets/TLS/proxy/captive-portal behavior, actual automatic-redirect disablement, real disk-full/network-error/cancel evidence and packaged behavior remain gates. The current CDN hostname is a deliberate BandScope allowlist, not a claimed permanent GitHub API guarantee. Digest/signature verification on the exact sealed descriptor and an explicit verified-artifact promotion type/path are still required before any bytes or identity can outlive verification scope. Production updater key/endpoint, Windows/macOS signing/notarization and commercial model rights also remain open. | | UI / Interaction | Rehearsal-first UI is the product surface; Anti-Slop and accessibility are acceptance criteria, not decoration. | Normal/loading/empty/error/permission/responsive states, KO/EN/JA/ZH/VI/ES/DE/FR expansion/fallback, keyboard/focus/contrast/state semantics and actual-audio E2E must be verified on the exact release candidate. | ## Distribution/update decision boundary @@ -38,17 +38,19 @@ The Rust `apps/desktop/distribution-core` is the deterministic decision layer af `apps/desktop/distribution-runtime` is the narrow remote-metadata adapter. It accepts at most 256 KiB of UTF-8 JSON, rejects duplicate/unknown members, enforces the exact four desktop targets, bounds signature/URL/artifact-size fields, pins exact-tag URLs to the current BandScope GitHub release namespace and delegates release-identity syntax to `distribution-core`. Its result type is explicitly provisional. The selected target URL/signature are preserved from this same admitted document via `artifact_url()` and `artifact_signature()` so later transport code does not reparse `raw_json`. It has no durable-state dependency and cannot write highest-seen state from syntactically valid `raw_json` alone. -`apps/desktop/distribution-transport` is the deterministic response-state bridge between provisional metadata and bounded bytes. It copies the selected URL/signature/size/digest from `ProvisionalUpdateMetadata`, admits a direct `200` only at the exact initial URL, and handles GitHub's possible `302` release-asset delivery as one explicit hop rather than hidden HTTP-client behavior. The admitted redirect is currently limited to exact HTTPS `release-assets.githubusercontent.com`; a host change fails closed until the BandScope egress policy is deliberately revised. The redirect response must terminate in `200` at the exact admitted Location and a second redirect is rejected. `Content-Length` admission happens before staging-file creation. This crate has no socket, HTTP client, JSON parser, installer, trust-promotion or state-repository capability. +`apps/desktop/distribution-transport` is the deterministic response-state bridge between provisional metadata and bounded bytes. It copies the selected URL/signature/size/digest from `ProvisionalUpdateMetadata` and first requires the Tauri signature field to be canonical RFC 4648 standard base64, including canonical padding bits. That check only admits the outer Tauri transport envelope; it does not parse minisign or establish cryptographic trust. It then admits a direct `200` only at the exact initial URL and handles GitHub's possible `302` release-asset delivery as one explicit hop rather than hidden HTTP-client behavior. The admitted redirect is currently limited to exact HTTPS `release-assets.githubusercontent.com`; a host change fails closed until the BandScope egress policy is deliberately revised. The redirect response must terminate in `200` at the exact admitted Location and a second redirect is rejected. `Content-Length` admission happens before staging-file creation. This crate has no socket, HTTP client, JSON parser, installer, trust-promotion or state-repository capability. + +Publication mirrors the outer signature-envelope contract after receipt byte binding: `build_updater_manifest.py` requires exact `.sig` bytes to be ASCII canonical standard base64, requires exact decode/re-encode equivalence, and requires the decoded payload to be UTF-8 before embedding it in static updater JSON. This catches malformed receipt-consistent signature bytes before publication but still does not replace Tauri's minisign verification. `apps/desktop/distribution-download` is a separate network-library-independent streaming/staging boundary. It rejects zero/over-ceiling expected sizes, optional `Content-Length` mismatch, chunks larger than 1 MiB, cumulative overrun before the offending bytes reach the sink, truncated completion and sink-write failure. A failed attempt is poisoned so later chunks cannot manufacture a success receipt. Its staging file uses a bounded portable basename under an existing non-symlink app-owned directory, exclusive `create_new`, cleanup on cancel/error, and flush/`sync_all` plus descriptor size verification before returning a still-open sealed artifact. The sealed artifact remains cleanup-on-drop because exact byte count and `sync_all()` do not establish digest, signature or metadata authenticity. Downstream verification gets a positional read-only wrapper over that exact open descriptor; the write-enabled staging `File` itself is not exposed. The reader owns the original admitted length as a hard upper bound, ignores any later appended tail, and reports early EOF if the descriptor is truncated below that length. Highest-seen update identity remains Distribution state, not Project Persistence state. `apps/desktop/distribution-state` provides a separate bounded append-only Rust log that revalidates committed identities, rejects local version regression/equivocation, synchronizes successful appends and recovers only a syntactically valid torn final record prefix. It deliberately does not claim packaged power-loss equivalence across Windows/macOS until platform fault-injection evidence exists. Project Persistence remains authoritative only for project bytes and the project-schema evidence used by rollback compatibility checks. -Current Tauri updater APIs still materialize a verified update as in-memory bytes. The repository now owns strict selected transport metadata, explicit response/redirect admission and bounded staging primitives, but commercial readiness still requires a production network adapter that disables implicit redirects, reports exact status/effective URL/Location into `distribution-transport`, and streams actual response chunks into the returned staging path. Counting progress callbacks or checking the fully buffered `Vec` after download is not equivalent evidence. `sync_all()` in a unit/integration test is also not packaged Windows/macOS power-loss proof. +Current Tauri updater APIs still materialize a verified update as in-memory bytes. The repository now owns strict selected transport metadata, canonical outer-signature admission, explicit response/redirect admission and bounded staging primitives, but commercial readiness still requires a production network adapter that disables implicit redirects, reports exact status/effective URL/Location into `distribution-transport`, and streams actual response chunks into the returned staging path. Counting progress callbacks or checking the fully buffered `Vec` after download is not equivalent evidence. `sync_all()` in a unit/integration test is also not packaged Windows/macOS power-loss proof. ## Release gate -A release candidate is not commercial-ready until all of the following are true on the exact protected head: required checks and independent review are terminal/qualifying; Windows artifacts are signed by the approved publisher and macOS artifacts are signed/notarized/stapled; updater authority is admitted without placeholder values; remote updater metadata has an authenticated binding before it can mutate freshness state; updater artifact bytes are signature-verified and matched to authenticated digest/size evidence using the exact sealed descriptor; only verified bytes can be explicitly promoted beyond sealed cleanup scope; updater replay/rollback/recovery is exercised on packaged targets; the production HTTP adapter disables implicit redirects, feeds response status/effective URL/Location through `distribution-transport`, streams body bytes through bounded staging, and survives hostile/truncated/oversized/disk-full/cancel/network-error cases; SBOM/NOTICE/provenance agree with exact shipped bytes; model rights and exact model provenance are established; rights-cleared real-audio scientific acceptance is reproducible; the updater can recover to a compatible known-good build without losing project usability; and material UI passes actual-audio, responsive, locale and accessibility E2E. +A release candidate is not commercial-ready until all of the following are true on the exact protected head: required checks and independent review are terminal/qualifying; Windows artifacts are signed by the approved publisher and macOS artifacts are signed/notarized/stapled; updater authority is admitted without placeholder values; remote updater metadata has an authenticated binding before it can mutate freshness state; updater artifact bytes are cryptographically signature-verified and matched to authenticated digest/size evidence using the exact sealed descriptor; only verified bytes can be explicitly promoted beyond sealed cleanup scope; updater replay/rollback/recovery is exercised on packaged targets; the production HTTP adapter disables implicit redirects, feeds response status/effective URL/Location through `distribution-transport`, streams body bytes through bounded staging, and survives hostile/truncated/oversized/disk-full/cancel/network-error cases; SBOM/NOTICE/provenance agree with exact shipped bytes; model rights and exact model provenance are established; rights-cleared real-audio scientific acceptance is reproducible; the updater can recover to a compatible known-good build without losing project usability; and material UI passes actual-audio, responsive, locale and accessibility E2E. Until those conditions are satisfied, Draft/open PRs and blocked release policies are expected safety states rather than reasons to bypass gates. @@ -59,7 +61,7 @@ Until those conditions are satisfied, Draft/open PRs and blocked release policie - Release receipt/publication: `docs/traceability/release-artifact-receipt.md` - Updater security metadata, provisional runtime admission, durable freshness state and replay/rollback model: `docs/traceability/updater-security-metadata.md` - Bounded updater artifact streaming/staging: `docs/traceability/updater-bounded-download.md` -- Explicit release-response/redirect transport policy: `docs/traceability/updater-transport-policy.md` +- Explicit signature-envelope/release-response/redirect transport policy: `docs/traceability/updater-transport-policy.md` - Security trust boundaries: `docs/security/app-security.md` - Cross-platform release controls: `docs/security/cross-platform-build-policy.md` - Architecture ownership: `ARCHITECTURE.md` From 2f5af3ef82e6a880d557f4925fef42a805a828a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 11:19:48 +0900 Subject: [PATCH 161/308] docs(architecture): align updater signature-envelope ownership --- ARCHITECTURE.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d9ee6a84d..33ab1b84a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -60,7 +60,7 @@ Last updated: 2026-09-15 - `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; returns provisional metadata with the selected target's exact admitted URL/signature from the same strict parse and cannot mutate freshness state -- `apps/desktop/distribution-transport` - deterministic Rust response-state bridge from the strict provisional target projection to bounded updater staging; owns exact effective-URL checks and one-hop release-asset redirect admission, but not sockets, TLS, metadata authentication, signatures or installation +- `apps/desktop/distribution-transport` - deterministic Rust response-state bridge from the strict provisional target projection to bounded updater staging; owns canonical outer updater-signature envelope admission, exact effective-URL checks and 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 @@ -73,7 +73,8 @@ Last updated: 2026-09-15 - 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 and invalid release-identity syntax; 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 HTTP response state without reparsing `raw_json`. 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 or freshness-state capability. +- `apps/desktop/distribution-transport` consumes only that already-selected provisional projection and admits updater transport state without reparsing `raw_json`. Before any network response is admitted it requires the selected Tauri signature field to be canonical RFC 4648 standard base64, including canonical padding bits; this validates only Tauri's outer textual signature envelope and does not parse or cryptographically verify minisign. 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. From e37632589960cd3571c99eafafdcf205734bb21b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 12:06:29 +0900 Subject: [PATCH 162/308] test(distribution): reject encoded updater response bodies --- .../tests/transport_policy.rs | 47 +++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/apps/desktop/distribution-transport/tests/transport_policy.rs b/apps/desktop/distribution-transport/tests/transport_policy.rs index 7d63f87e2..78287020a 100644 --- a/apps/desktop/distribution-transport/tests/transport_policy.rs +++ b/apps/desktop/distribution-transport/tests/transport_policy.rs @@ -76,7 +76,7 @@ fn github_release_redirect_is_one_hop_and_streams_through_bounded_staging() { let directory = scratch_dir("redirect"); let mut download = head - .start_staging(&directory, Some(4)) + .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"); @@ -125,13 +125,54 @@ fn content_length_mismatch_fails_before_staging_file_creation() { }; let directory = scratch_dir("length"); assert_eq!( - head.start_staging(&directory, Some(3)).unwrap_err(), + 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(); @@ -144,7 +185,7 @@ fn cancelled_transport_drops_partial_staging_bytes() { }; let directory = scratch_dir("cancel"); let mut download = head - .start_staging(&directory, None) + .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); From 606095ec6f2ae9b5d22a777f70806dc79baa8f36 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 12:07:04 +0900 Subject: [PATCH 163/308] fix(distribution): reject transformed updater response bodies --- apps/desktop/distribution-transport/src/lib.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/desktop/distribution-transport/src/lib.rs b/apps/desktop/distribution-transport/src/lib.rs index fed442f56..8113d0535 100644 --- a/apps/desktop/distribution-transport/src/lib.rs +++ b/apps/desktop/distribution-transport/src/lib.rs @@ -45,6 +45,8 @@ pub enum TransportPolicyError { /// 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. @@ -103,14 +105,22 @@ impl AdmittedDownloadHead { /// Start one bounded staged body after response-head admission succeeds. /// - /// Content-length admission runs before filesystem mutation, so an immediate - /// length mismatch cannot create a staging artifact. The returned value owns - /// cleanup through the underlying `StagedArtifactFile` lifecycle. + /// 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, From f616421a2416e95709420c04c652667ef6f7de59 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 12:07:49 +0900 Subject: [PATCH 164/308] docs(distribution): trace exact response-byte framing admission --- docs/traceability/updater-transport-policy.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/traceability/updater-transport-policy.md b/docs/traceability/updater-transport-policy.md index b5c67e449..c3c1f0e96 100644 --- a/docs/traceability/updater-transport-policy.md +++ b/docs/traceability/updater-transport-policy.md @@ -4,12 +4,14 @@ 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, or hand response bytes to staging without proving which effective URL produced them. +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. +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. + ## Constraints - Consume `ProvisionalUpdateMetadata` directly; do not introduce a second remote-JSON parser. @@ -21,25 +23,28 @@ Tauri's updater CLI writes the textual minisign signature box as standard-base64 - 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 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-length mismatch is evaluated before staging-file creation. +- 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 this small 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 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. Reimplementing minisign verification was also rejected: Tauri remains the signature-verification owner, and BandScope only mirrors the documented outer transport envelope needed to fail earlier. ## Selected design `apps/desktop/distribution-transport` is a small Rust owner between `distribution-runtime` 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. Before response admission it validates that signature as canonical standard base64, including padding placement and zero pad bits. `admit_initial_response` accepts exact-URL `200`, or returns an explicit one-hop redirect decision for an admitted GitHub release-asset CDN location. `admit_redirect_response` requires that the second request terminate in `200` at that exact location. `AdmittedDownloadHead::start_staging` creates `ArtifactDownloadAdmission` before it creates a staging file, and `TransportDownload` routes chunks and exact completion into the existing sealed-descriptor lifecycle. +`ReleaseTransportPolicy::from_provisional` copies only the already-selected target projection: canonical initial URL, safe artifact basename, declared byte size, SHA-256 and Tauri signature. Before response admission it validates that signature as canonical standard base64, including padding placement and zero pad bits. `admit_initial_response` accepts exact-URL `200`, or returns an explicit one-hop redirect decision for an admitted GitHub release-asset CDN location. `admit_redirect_response` requires that the second request terminate in `200` at that exact 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 evidence is not cryptographic authenticity. +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 @@ -49,14 +54,16 @@ The transport API intentionally contains no socket/client, JSON parser, installe - `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 a clean transport RED proving that syntactically admitted `not-base64!` metadata must not reach network response admission. `6e5e42f2a20001009330c438178afa1ca811ab51` added the dependency-free canonical-base64 envelope guard. - `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. That head is RED against the predecessor implementation because the required third argument and error variant do not exist yet. +- `606095ec6f2ae9b5d22a777f70806dc79baa8f36` is the causal repair: `AdmittedDownloadHead::start_staging` now rejects every supplied content coding except case-insensitive `identity` before byte-count admission or filesystem mutation. 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 provisional metadata projection, signature envelope, HTTP status, HTTP client's effective URL, redirect `Location`, `Content-Length` and response chunks. The policy uses canonical outer-base64 admission before network work, exact URL equality before body admission, a bounded redirect string, HTTPS exact-origin pinning for the admitted CDN hop, one-hop redirect depth, and existing bounded chunk/file admission. No network, credential, subprocess, generic filesystem, installer, project or freshness-state capability is added here. Cancel/error cleanup continues to be owned by `distribution-download`. +Untrusted inputs are the provisional metadata projection, signature envelope, HTTP status, HTTP client's effective URL, redirect `Location`, `Content-Encoding`, `Content-Length` and response chunks. The policy uses canonical outer-base64 admission before network work, exact URL equality before body admission, a bounded redirect string, HTTPS exact-origin pinning for the admitted CDN hop, 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 here. 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, and does not prove packaged Windows/macOS networking behavior. Those claims remain release gates. +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 From 80fdc39983d8182cb7268f11460a9fbf6e4373c8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 12:09:05 +0900 Subject: [PATCH 165/308] docs(product): record exact-byte updater response framing --- docs/product-technical-gap-baseline.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 9f9901da2..72bd07d1b 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -20,7 +20,7 @@ The product keeps BandScope-specific audio/rehearsal truth inside BandScope. Org | Project Persistence | Project/cache integrity and scientific cache equivalence have dedicated owner work. | Crash/power-loss, disk-full, interrupted write/recovery, last-known-good project state and packaged-OS fault injection remain buyer gates. | | Collaboration Handoff | Export/handoff belongs to BandScope without creating a second collaboration platform. | Only released, bounded artifacts should cross product boundaries; mutable shared DB or cross-service SQL is not accepted. | | Diagnostics | Existing harness/security/build evidence is substantial. | Buyer-safe diagnostics must avoid audio/project/credential leakage and distinguish user cancel, provider/runtime failure, corrupt project and release/update failure. | -| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. Release preflight enforces the same canonical numeric `MAJOR.MINOR.PATCH` syntax and unsigned-64-bit component range as the native updater decision core. `distribution-core` and `distribution-state` define deterministic replay/rollback policy and durable highest-seen storage for already-authenticated release identity. `distribution-runtime` strict-parses static updater JSON only as provisional remote input and retains one selected target projection. `distribution-transport` consumes that projection without reparsing JSON, rejects non-canonical Tauri outer signature base64 before network work, admits either an exact direct `200` or one explicit `302` hop to the current `release-assets.githubusercontent.com` egress allowlist, rejects effective-URL drift/redirect chaining, and composes the final response with `distribution-download`. Publication also rejects receipt-consistent `.sig` bytes that are not canonical standard base64 decoding to UTF-8. `distribution-download` owns dependency-free byte admission plus exclusive staging, exact completion, cleanup-on-failure and descriptor-bound sealed reads. | Tauri cryptographic verification of the decoded updater signature and exact downloaded bytes is still a later trust step; outer base64 validation is only an early syntax/resource gate. Remote release identity still needs independent metadata authentication. The transport crate is a deterministic response-policy/staging bridge, not an HTTP client: production sockets/TLS/proxy/captive-portal behavior, actual automatic-redirect disablement, real disk-full/network-error/cancel evidence and packaged behavior remain gates. The current CDN hostname is a deliberate BandScope allowlist, not a claimed permanent GitHub API guarantee. Digest/signature verification on the exact sealed descriptor and an explicit verified-artifact promotion type/path are still required before any bytes or identity can outlive verification scope. Production updater key/endpoint, Windows/macOS signing/notarization and commercial model rights also remain open. | +| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. Release preflight enforces the same canonical numeric `MAJOR.MINOR.PATCH` syntax and unsigned-64-bit component range as the native updater decision core. `distribution-core` and `distribution-state` define deterministic replay/rollback policy and durable highest-seen storage for already-authenticated release identity. `distribution-runtime` strict-parses static updater JSON only as provisional remote input and retains one selected target projection. `distribution-transport` consumes that projection without reparsing JSON, rejects non-canonical Tauri outer signature base64 before network work, admits either an exact direct `200` or one explicit `302` hop to the current `release-assets.githubusercontent.com` egress allowlist, rejects effective-URL drift/redirect chaining, rejects transformed response bodies through non-identity `Content-Encoding` before filesystem mutation, and composes the final response with `distribution-download`. Publication also rejects receipt-consistent `.sig` bytes that are not canonical standard base64 decoding to UTF-8. `distribution-download` owns dependency-free byte admission plus exclusive staging, exact completion, cleanup-on-failure and descriptor-bound sealed reads. | Tauri cryptographic verification of the decoded updater signature and exact downloaded bytes is still a later trust step; outer base64 validation is only an early syntax/resource gate. Remote release identity still needs independent metadata authentication. The transport crate is a deterministic response-policy/staging bridge, not an HTTP client: production sockets/TLS/proxy/captive-portal behavior, actual automatic-redirect and automatic-decompression disablement, real disk-full/network-error/cancel evidence and packaged behavior remain gates. The current CDN hostname is a deliberate BandScope allowlist, not a claimed permanent GitHub API guarantee. Digest/signature verification on the exact sealed descriptor and an explicit verified-artifact promotion type/path are still required before any bytes or identity can outlive verification scope. Production updater key/endpoint, Windows/macOS signing/notarization and commercial model rights also remain open. | | UI / Interaction | Rehearsal-first UI is the product surface; Anti-Slop and accessibility are acceptance criteria, not decoration. | Normal/loading/empty/error/permission/responsive states, KO/EN/JA/ZH/VI/ES/DE/FR expansion/fallback, keyboard/focus/contrast/state semantics and actual-audio E2E must be verified on the exact release candidate. | ## Distribution/update decision boundary @@ -38,7 +38,7 @@ The Rust `apps/desktop/distribution-core` is the deterministic decision layer af `apps/desktop/distribution-runtime` is the narrow remote-metadata adapter. It accepts at most 256 KiB of UTF-8 JSON, rejects duplicate/unknown members, enforces the exact four desktop targets, bounds signature/URL/artifact-size fields, pins exact-tag URLs to the current BandScope GitHub release namespace and delegates release-identity syntax to `distribution-core`. Its result type is explicitly provisional. The selected target URL/signature are preserved from this same admitted document via `artifact_url()` and `artifact_signature()` so later transport code does not reparse `raw_json`. It has no durable-state dependency and cannot write highest-seen state from syntactically valid `raw_json` alone. -`apps/desktop/distribution-transport` is the deterministic response-state bridge between provisional metadata and bounded bytes. It copies the selected URL/signature/size/digest from `ProvisionalUpdateMetadata` and first requires the Tauri signature field to be canonical RFC 4648 standard base64, including canonical padding bits. That check only admits the outer Tauri transport envelope; it does not parse minisign or establish cryptographic trust. It then admits a direct `200` only at the exact initial URL and handles GitHub's possible `302` release-asset delivery as one explicit hop rather than hidden HTTP-client behavior. The admitted redirect is currently limited to exact HTTPS `release-assets.githubusercontent.com`; a host change fails closed until the BandScope egress policy is deliberately revised. The redirect response must terminate in `200` at the exact admitted Location and a second redirect is rejected. `Content-Length` admission happens before staging-file creation. This crate has no socket, HTTP client, JSON parser, installer, trust-promotion or state-repository capability. +`apps/desktop/distribution-transport` is the deterministic response-state bridge between provisional metadata and bounded bytes. It copies the selected URL/signature/size/digest from `ProvisionalUpdateMetadata` and first requires the Tauri signature field to be canonical RFC 4648 standard base64, including canonical padding bits. That check only admits the outer Tauri transport envelope; it does not parse minisign or establish cryptographic trust. It then admits a direct `200` only at the exact initial URL and handles GitHub's possible `302` release-asset delivery as one explicit hop rather than hidden HTTP-client behavior. The admitted redirect is currently limited to exact HTTPS `release-assets.githubusercontent.com`; a host change fails closed until the BandScope egress policy is deliberately revised. The redirect response must terminate in `200` at the exact admitted Location and a second redirect is rejected. Before byte-count admission or staging-file creation, any supplied `Content-Encoding` must be explicit `identity`; transformed encodings such as gzip are rejected because updater digest/signature verification must see the exact published artifact bytes. `Content-Length` admission then happens before staging-file creation. This crate has no socket, HTTP client, JSON parser, installer, trust-promotion or state-repository capability. Publication mirrors the outer signature-envelope contract after receipt byte binding: `build_updater_manifest.py` requires exact `.sig` bytes to be ASCII canonical standard base64, requires exact decode/re-encode equivalence, and requires the decoded payload to be UTF-8 before embedding it in static updater JSON. This catches malformed receipt-consistent signature bytes before publication but still does not replace Tauri's minisign verification. @@ -46,11 +46,11 @@ Publication mirrors the outer signature-envelope contract after receipt byte bin Highest-seen update identity remains Distribution state, not Project Persistence state. `apps/desktop/distribution-state` provides a separate bounded append-only Rust log that revalidates committed identities, rejects local version regression/equivocation, synchronizes successful appends and recovers only a syntactically valid torn final record prefix. It deliberately does not claim packaged power-loss equivalence across Windows/macOS until platform fault-injection evidence exists. Project Persistence remains authoritative only for project bytes and the project-schema evidence used by rollback compatibility checks. -Current Tauri updater APIs still materialize a verified update as in-memory bytes. The repository now owns strict selected transport metadata, canonical outer-signature admission, explicit response/redirect admission and bounded staging primitives, but commercial readiness still requires a production network adapter that disables implicit redirects, reports exact status/effective URL/Location into `distribution-transport`, and streams actual response chunks into the returned staging path. Counting progress callbacks or checking the fully buffered `Vec` after download is not equivalent evidence. `sync_all()` in a unit/integration test is also not packaged Windows/macOS power-loss proof. +Current Tauri updater APIs still materialize a verified update as in-memory bytes. The repository now owns strict selected transport metadata, canonical outer-signature admission, explicit response/redirect/content-coding admission and bounded staging primitives, but commercial readiness still requires a production network adapter that disables implicit redirects and automatic decompression, reports exact status/effective URL/Location/Content-Encoding into `distribution-transport`, and streams actual response chunks into the returned staging path. Counting progress callbacks or checking the fully buffered `Vec` after download is not equivalent evidence. `sync_all()` in a unit/integration test is also not packaged Windows/macOS power-loss proof. ## Release gate -A release candidate is not commercial-ready until all of the following are true on the exact protected head: required checks and independent review are terminal/qualifying; Windows artifacts are signed by the approved publisher and macOS artifacts are signed/notarized/stapled; updater authority is admitted without placeholder values; remote updater metadata has an authenticated binding before it can mutate freshness state; updater artifact bytes are cryptographically signature-verified and matched to authenticated digest/size evidence using the exact sealed descriptor; only verified bytes can be explicitly promoted beyond sealed cleanup scope; updater replay/rollback/recovery is exercised on packaged targets; the production HTTP adapter disables implicit redirects, feeds response status/effective URL/Location through `distribution-transport`, streams body bytes through bounded staging, and survives hostile/truncated/oversized/disk-full/cancel/network-error cases; SBOM/NOTICE/provenance agree with exact shipped bytes; model rights and exact model provenance are established; rights-cleared real-audio scientific acceptance is reproducible; the updater can recover to a compatible known-good build without losing project usability; and material UI passes actual-audio, responsive, locale and accessibility E2E. +A release candidate is not commercial-ready until all of the following are true on the exact protected head: required checks and independent review are terminal/qualifying; Windows artifacts are signed by the approved publisher and macOS artifacts are signed/notarized/stapled; updater authority is admitted without placeholder values; remote updater metadata has an authenticated binding before it can mutate freshness state; updater artifact bytes are cryptographically signature-verified and matched to authenticated digest/size evidence using the exact sealed descriptor; only verified bytes can be explicitly promoted beyond sealed cleanup scope; updater replay/rollback/recovery is exercised on packaged targets; the production HTTP adapter disables implicit redirects and automatic content decoding, feeds response status/effective URL/Location/Content-Encoding through `distribution-transport`, streams body bytes through bounded staging, and survives hostile/truncated/oversized/disk-full/cancel/network-error cases; SBOM/NOTICE/provenance agree with exact shipped bytes; model rights and exact model provenance are established; rights-cleared real-audio scientific acceptance is reproducible; the updater can recover to a compatible known-good build without losing project usability; and material UI passes actual-audio, responsive, locale and accessibility E2E. Until those conditions are satisfied, Draft/open PRs and blocked release policies are expected safety states rather than reasons to bypass gates. From c3dbebeff5ad4c9abe7e6d61cdded840bbfd9d3c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 13:04:52 +0900 Subject: [PATCH 166/308] test(distribution): redact redirect queries in diagnostics --- .../tests/transport_diagnostics.rs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 apps/desktop/distribution-transport/tests/transport_diagnostics.rs 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..6aaea6b0e --- /dev/null +++ b/apps/desktop/distribution-transport/tests/transport_diagnostics.rs @@ -0,0 +1,42 @@ +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_is_redacted_from_debug_surfaces() { + let policy = policy(); + 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_eq!(redirect.location(), CDN_URL_WITH_QUERY); + assert_eq!(head.effective_url(), CDN_URL_WITH_QUERY); +} From be91505c0df2dc496849ae8b289f252643f9e055 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 13:05:54 +0900 Subject: [PATCH 167/308] fix(distribution): redact redirect query data from diagnostics --- .../desktop/distribution-transport/src/lib.rs | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/apps/desktop/distribution-transport/src/lib.rs b/apps/desktop/distribution-transport/src/lib.rs index 8113d0535..8070825ec 100644 --- a/apps/desktop/distribution-transport/src/lib.rs +++ b/apps/desktop/distribution-transport/src/lib.rs @@ -14,6 +14,7 @@ use bandscope_distribution_download::{ StagingArtifactError, }; use bandscope_distribution_runtime::ProvisionalUpdateMetadata; +use std::fmt; use std::path::Path; /// Maximum redirect location accepted from one release-asset response. @@ -21,6 +22,17 @@ pub const MAX_REDIRECT_URL_BYTES: usize = 16 * 1024; const RELEASE_ASSET_CDN_PREFIX: &str = "https://release-assets.githubusercontent.com/"; +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 { @@ -54,12 +66,22 @@ pub enum TransportDownloadError { } /// A one-hop release-asset redirect admitted by Distribution policy. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct AdmittedRedirect { source_url: String, location: 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 { @@ -68,7 +90,7 @@ impl AdmittedRedirect { } /// An admitted final response whose body may enter bounded staging. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct AdmittedDownloadHead { effective_url: String, artifact_name: String, @@ -77,6 +99,19 @@ pub struct AdmittedDownloadHead { 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", &self.artifact_signature) + .finish() + } +} + impl AdmittedDownloadHead { /// Return the exact final URL admitted for this response body. pub fn effective_url(&self) -> &str { From 6b8cabb2f5b594d1e45be7bfd20ef5826eefe2b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 13:06:32 +0900 Subject: [PATCH 168/308] docs(distribution): trace updater diagnostic redaction --- .../updater-transport-diagnostics.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 docs/traceability/updater-transport-diagnostics.md diff --git a/docs/traceability/updater-transport-diagnostics.md b/docs/traceability/updater-transport-diagnostics.md new file mode 100644 index 000000000..9e09937a2 --- /dev/null +++ b/docs/traceability/updater-transport-diagnostics.md @@ -0,0 +1,41 @@ +# 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` previously 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. + +## 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. +- 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 the query payload 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. + +## Selected design + +A private `RedactedUrl` formatter owns diagnostic rendering. 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 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. + +## Claim boundary and remaining work + +This repair prevents automatic Rust `Debug` output for these two Distribution types from exposing redirect query contents. It does not prove that callers never log the explicit `location()` or `effective_url()` accessors; those exact accessors remain necessary for the network adapter and must be handled as transport data. The future production HTTP adapter must avoid logging full request 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 From 935266a1065787443a2a441bcfdc2933905d1157 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 13:11:37 +0900 Subject: [PATCH 169/308] test(distribution): bound updater signature debug output --- .../tests/transport_diagnostics.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/desktop/distribution-transport/tests/transport_diagnostics.rs b/apps/desktop/distribution-transport/tests/transport_diagnostics.rs index 6aaea6b0e..6d48a1b82 100644 --- a/apps/desktop/distribution-transport/tests/transport_diagnostics.rs +++ b/apps/desktop/distribution-transport/tests/transport_diagnostics.rs @@ -16,8 +16,12 @@ fn policy() -> ReleaseTransportPolicy { } #[test] -fn redirect_query_is_redacted_from_debug_surfaces() { +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") @@ -36,7 +40,10 @@ fn redirect_query_is_redacted_from_debug_surfaces() { 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"); } From 1a4e8f541e3017f0e666935c3e003027b871d131 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 13:12:12 +0900 Subject: [PATCH 170/308] fix(distribution): bound signature diagnostics --- apps/desktop/distribution-transport/src/lib.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/desktop/distribution-transport/src/lib.rs b/apps/desktop/distribution-transport/src/lib.rs index 8070825ec..76a0a3d09 100644 --- a/apps/desktop/distribution-transport/src/lib.rs +++ b/apps/desktop/distribution-transport/src/lib.rs @@ -21,6 +21,7 @@ use std::path::Path; 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); @@ -107,7 +108,7 @@ impl fmt::Debug for AdmittedDownloadHead { .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", &self.artifact_signature) + .field("artifact_signature", &REDACTED_SIGNATURE) .finish() } } @@ -180,7 +181,7 @@ pub enum ResponseDecision { } /// Deterministic transport policy derived from one strictly admitted updater target. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct ReleaseTransportPolicy { initial_url: String, artifact_name: String, @@ -189,6 +190,19 @@ pub struct ReleaseTransportPolicy { 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. /// From b290bf0efd7b96ad147db463e4c2122a68c9f8d0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 13:12:34 +0900 Subject: [PATCH 171/308] docs(distribution): trace bounded signature diagnostics --- .../updater-transport-diagnostics.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/traceability/updater-transport-diagnostics.md b/docs/traceability/updater-transport-diagnostics.md index 9e09937a2..3159cdf01 100644 --- a/docs/traceability/updater-transport-diagnostics.md +++ b/docs/traceability/updater-transport-diagnostics.md @@ -6,31 +6,38 @@ Status: source-repaired; hosted exact-head verification pending. 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` previously 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. +`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. ## 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()` 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 the query payload from appearing in formatted values. +- 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. ## Selected design -A private `RedactedUrl` formatter owns diagnostic rendering. 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. +A private `RedactedUrl` formatter owns URL diagnostic rendering. 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. -The 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 a fixed `` marker in both `ReleaseTransportPolicy` and `AdmittedDownloadHead`. The actual signature remains private state exposed only through the exact verification accessor. This bounds normal debug output independently of the remote signature-size allowance and avoids turning transport diagnostics into a copy of untrusted metadata. ## Claim boundary and remaining work -This repair prevents automatic Rust `Debug` output for these two Distribution types from exposing redirect query contents. It does not prove that callers never log the explicit `location()` or `effective_url()` accessors; those exact accessors remain necessary for the network adapter and must be handled as transport data. The future production HTTP adapter must avoid logging full request URLs, must still disable implicit redirects and automatic decompression, and must stream only admitted response bytes through `distribution-download`. +This repair prevents automatic Rust `Debug` output for the Distribution transport policy, redirect decision, and final download head from exposing redirect query contents or full provisional signature text. It does not prove that callers never log the explicit `location()`, `effective_url()`, or `artifact_signature()` accessors; those exact accessors remain necessary for the network and verification boundaries and must be handled as transport/security data. The future production HTTP adapter must avoid logging full request 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. From 027ba1474281b0ed968f039eb20073f1b7b9b2e9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 13:59:39 +0900 Subject: [PATCH 172/308] test(distribution): reject provisional signature debug exposure --- .../tests/provisional_diagnostics.rs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 apps/desktop/distribution-runtime/tests/provisional_diagnostics.rs 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); +} From 90855ccdb8ecb1a1166a6c2e614b6851ae26f662 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 14:01:07 +0900 Subject: [PATCH 173/308] fix(distribution): redact provisional signature diagnostics --- apps/desktop/distribution-runtime/src/lib.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/desktop/distribution-runtime/src/lib.rs b/apps/desktop/distribution-runtime/src/lib.rs index 9b038f155..93176683b 100644 --- a/apps/desktop/distribution-runtime/src/lib.rs +++ b/apps/desktop/distribution-runtime/src/lib.rs @@ -10,6 +10,7 @@ #![forbid(unsafe_code)] use bandscope_distribution_core::{UpdateCandidate, UpdateRejection}; +use std::fmt; use std::path::{Path, PathBuf}; /// Maximum accepted updater JSON payload before parsing. @@ -36,6 +37,7 @@ 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)] @@ -69,7 +71,7 @@ pub enum MetadataError { /// 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, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct ProvisionalUpdateMetadata { candidate: UpdateCandidate, artifact_size_bytes: u64, @@ -77,6 +79,18 @@ pub struct ProvisionalUpdateMetadata { 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) { From bd17041fe51eef2ece6c07c18d0c7d2d56195fc5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 14:01:37 +0900 Subject: [PATCH 174/308] docs(distribution): record provisional diagnostic redaction --- .../updater-transport-diagnostics.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/traceability/updater-transport-diagnostics.md b/docs/traceability/updater-transport-diagnostics.md index 3159cdf01..e5c7ef22f 100644 --- a/docs/traceability/updater-transport-diagnostics.md +++ b/docs/traceability/updater-transport-diagnostics.md @@ -10,11 +10,13 @@ The Distribution transport policy must preserve the exact release-asset redirect 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()` for the later Tauri verification boundary; diagnostic redaction must not mutate or replace verification input. +- 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. @@ -26,18 +28,26 @@ The same diagnostics surface also retained the provisional Tauri signature strin - `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. 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. +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 a fixed `` marker in both `ReleaseTransportPolicy` and `AdmittedDownloadHead`. The actual signature remains private state exposed only through the exact verification accessor. This bounds normal debug output independently of the remote signature-size allowance and avoids turning transport diagnostics into a copy of untrusted metadata. +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 the Distribution transport policy, redirect decision, and final download head from exposing redirect query contents or full provisional signature text. It does not prove that callers never log the explicit `location()`, `effective_url()`, or `artifact_signature()` accessors; those exact accessors remain necessary for the network and verification boundaries and must be handled as transport/security data. The future production HTTP adapter must avoid logging full request URLs, must still disable implicit redirects and automatic decompression, and must stream only admitted response bytes through `distribution-download`. +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. From 93526ae11bd5d94d8117fc78a62698b9b8f236d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 14:02:37 +0900 Subject: [PATCH 175/308] docs(gap): keep updater diagnostics buyer truth current --- docs/product-technical-gap-baseline.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 72bd07d1b..0d42560a5 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -19,8 +19,8 @@ The product keeps BandScope-specific audio/rehearsal truth inside BandScope. Org | Active Player | Desktop UI has a player surface but commercial acceptance is not complete. | Actual decoded audio must remain audible and synchronized across seek/range/section selection, reload and stale-source races; pointer/touch/keyboard and screen-reader alternatives require current-head E2E evidence. | | Project Persistence | Project/cache integrity and scientific cache equivalence have dedicated owner work. | Crash/power-loss, disk-full, interrupted write/recovery, last-known-good project state and packaged-OS fault injection remain buyer gates. | | Collaboration Handoff | Export/handoff belongs to BandScope without creating a second collaboration platform. | Only released, bounded artifacts should cross product boundaries; mutable shared DB or cross-service SQL is not accepted. | -| Diagnostics | Existing harness/security/build evidence is substantial. | Buyer-safe diagnostics must avoid audio/project/credential leakage and distinguish user cancel, provider/runtime failure, corrupt project and release/update failure. | -| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. Release preflight enforces the same canonical numeric `MAJOR.MINOR.PATCH` syntax and unsigned-64-bit component range as the native updater decision core. `distribution-core` and `distribution-state` define deterministic replay/rollback policy and durable highest-seen storage for already-authenticated release identity. `distribution-runtime` strict-parses static updater JSON only as provisional remote input and retains one selected target projection. `distribution-transport` consumes that projection without reparsing JSON, rejects non-canonical Tauri outer signature base64 before network work, admits either an exact direct `200` or one explicit `302` hop to the current `release-assets.githubusercontent.com` egress allowlist, rejects effective-URL drift/redirect chaining, rejects transformed response bodies through non-identity `Content-Encoding` before filesystem mutation, and composes the final response with `distribution-download`. Publication also rejects receipt-consistent `.sig` bytes that are not canonical standard base64 decoding to UTF-8. `distribution-download` owns dependency-free byte admission plus exclusive staging, exact completion, cleanup-on-failure and descriptor-bound sealed reads. | Tauri cryptographic verification of the decoded updater signature and exact downloaded bytes is still a later trust step; outer base64 validation is only an early syntax/resource gate. Remote release identity still needs independent metadata authentication. The transport crate is a deterministic response-policy/staging bridge, not an HTTP client: production sockets/TLS/proxy/captive-portal behavior, actual automatic-redirect and automatic-decompression disablement, real disk-full/network-error/cancel evidence and packaged behavior remain gates. The current CDN hostname is a deliberate BandScope allowlist, not a claimed permanent GitHub API guarantee. Digest/signature verification on the exact sealed descriptor and an explicit verified-artifact promotion type/path are still required before any bytes or identity can outlive verification scope. Production updater key/endpoint, Windows/macOS signing/notarization and commercial model rights also remain open. | +| Diagnostics | Existing harness/security/build evidence is substantial. Distribution-owned provisional update metadata and transport response objects now bound ordinary Rust `Debug`: exact remote updater signatures are replaced with a fixed marker, and opaque CDN redirect query data is redacted while exact verification/network accessors remain unchanged. | Buyer-safe diagnostics must still prove that support bundles and production call sites do not log exact signature/redirect accessors, audio/project content or credentials, and must distinguish user cancel, provider/runtime failure, corrupt project and release/update failure. | +| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. Release preflight enforces the same canonical numeric `MAJOR.MINOR.PATCH` syntax and unsigned-64-bit component range as the native updater decision core. `distribution-core` and `distribution-state` define deterministic replay/rollback policy and durable highest-seen storage for already-authenticated release identity. `distribution-runtime` strict-parses static updater JSON only as provisional remote input, retains one selected target projection, and redacts the exact selected remote signature from its ordinary `Debug` surface without changing the verification accessor. `distribution-transport` consumes that projection without reparsing JSON, rejects non-canonical Tauri outer signature base64 before network work, admits either an exact direct `200` or one explicit `302` hop to the current `release-assets.githubusercontent.com` egress allowlist, rejects effective-URL drift/redirect chaining, rejects transformed response bodies through non-identity `Content-Encoding` before filesystem mutation, redacts signature/query material from ordinary diagnostics, and composes the final response with `distribution-download`. Publication also rejects receipt-consistent `.sig` bytes that are not canonical standard base64 decoding to UTF-8. `distribution-download` owns dependency-free byte admission plus exclusive staging, exact completion, cleanup-on-failure and descriptor-bound sealed reads. | Tauri cryptographic verification of the decoded updater signature and exact downloaded bytes is still a later trust step; outer base64 validation and diagnostic redaction are only syntax/resource/observability controls. Remote release identity still needs independent metadata authentication. The transport crate is a deterministic response-policy/staging bridge, not an HTTP client: production sockets/TLS/proxy/captive-portal behavior, actual automatic-redirect and automatic-decompression disablement, real disk-full/network-error/cancel evidence and packaged behavior remain gates. The current CDN hostname is a deliberate BandScope allowlist, not a claimed permanent GitHub API guarantee. Digest/signature verification on the exact sealed descriptor and an explicit verified-artifact promotion type/path are still required before any bytes or identity can outlive verification scope. Production updater key/endpoint, Windows/macOS signing/notarization and commercial model rights also remain open. | | UI / Interaction | Rehearsal-first UI is the product surface; Anti-Slop and accessibility are acceptance criteria, not decoration. | Normal/loading/empty/error/permission/responsive states, KO/EN/JA/ZH/VI/ES/DE/FR expansion/fallback, keyboard/focus/contrast/state semantics and actual-audio E2E must be verified on the exact release candidate. | ## Distribution/update decision boundary @@ -36,9 +36,9 @@ Stable-channel version identity is deliberately narrower than full SemVer. `scri The Rust `apps/desktop/distribution-core` is the deterministic decision layer after authentication. It rejects malformed stable versions, target mismatch, downgrade candidates, metadata older than the locally highest authenticated release, same-version release-identity equivocation and rollback to a build that cannot read the current project schema. It does not fetch, install, sign, notarize, parse arbitrary remote JSON, or write project data. -`apps/desktop/distribution-runtime` is the narrow remote-metadata adapter. It accepts at most 256 KiB of UTF-8 JSON, rejects duplicate/unknown members, enforces the exact four desktop targets, bounds signature/URL/artifact-size fields, pins exact-tag URLs to the current BandScope GitHub release namespace and delegates release-identity syntax to `distribution-core`. Its result type is explicitly provisional. The selected target URL/signature are preserved from this same admitted document via `artifact_url()` and `artifact_signature()` so later transport code does not reparse `raw_json`. It has no durable-state dependency and cannot write highest-seen state from syntactically valid `raw_json` alone. +`apps/desktop/distribution-runtime` is the narrow remote-metadata adapter. It accepts at most 256 KiB of UTF-8 JSON, rejects duplicate/unknown members, enforces the exact four desktop targets, bounds signature/URL/artifact-size fields, pins exact-tag URLs to the current BandScope GitHub release namespace and delegates release-identity syntax to `distribution-core`. Its result type is explicitly provisional. The selected target URL/signature are preserved from this same admitted document via `artifact_url()` and `artifact_signature()` so later transport code does not reparse `raw_json`. Ordinary `Debug` retains bounded candidate/size/release-URL evidence but replaces the exact selected signature with ``; the exact accessor remains unchanged for the later verifier. It has no durable-state dependency and cannot write highest-seen state from syntactically valid `raw_json` alone. -`apps/desktop/distribution-transport` is the deterministic response-state bridge between provisional metadata and bounded bytes. It copies the selected URL/signature/size/digest from `ProvisionalUpdateMetadata` and first requires the Tauri signature field to be canonical RFC 4648 standard base64, including canonical padding bits. That check only admits the outer Tauri transport envelope; it does not parse minisign or establish cryptographic trust. It then admits a direct `200` only at the exact initial URL and handles GitHub's possible `302` release-asset delivery as one explicit hop rather than hidden HTTP-client behavior. The admitted redirect is currently limited to exact HTTPS `release-assets.githubusercontent.com`; a host change fails closed until the BandScope egress policy is deliberately revised. The redirect response must terminate in `200` at the exact admitted Location and a second redirect is rejected. Before byte-count admission or staging-file creation, any supplied `Content-Encoding` must be explicit `identity`; transformed encodings such as gzip are rejected because updater digest/signature verification must see the exact published artifact bytes. `Content-Length` admission then happens before staging-file creation. This crate has no socket, HTTP client, JSON parser, installer, trust-promotion or state-repository capability. +`apps/desktop/distribution-transport` is the deterministic response-state bridge between provisional metadata and bounded bytes. It copies the selected URL/signature/size/digest from `ProvisionalUpdateMetadata` and first requires the Tauri signature field to be canonical RFC 4648 standard base64, including canonical padding bits. That check only admits the outer Tauri transport envelope; it does not parse minisign or establish cryptographic trust. It then admits a direct `200` only at the exact initial URL and handles GitHub's possible `302` release-asset delivery as one explicit hop rather than hidden HTTP-client behavior. The admitted redirect is currently limited to exact HTTPS `release-assets.githubusercontent.com`; a host change fails closed until the BandScope egress policy is deliberately revised. The redirect response must terminate in `200` at the exact admitted Location and a second redirect is rejected. Before byte-count admission or staging-file creation, any supplied `Content-Encoding` must be explicit `identity`; transformed encodings such as gzip are rejected because updater digest/signature verification must see the exact published artifact bytes. `Content-Length` admission then happens before staging-file creation. Ordinary transport `Debug` replaces the exact signature with a fixed marker and masks opaque CDN query contents without modifying the exact network/verification values. This crate has no socket, HTTP client, JSON parser, installer, trust-promotion or state-repository capability. Publication mirrors the outer signature-envelope contract after receipt byte binding: `build_updater_manifest.py` requires exact `.sig` bytes to be ASCII canonical standard base64, requires exact decode/re-encode equivalence, and requires the decoded payload to be UTF-8 before embedding it in static updater JSON. This catches malformed receipt-consistent signature bytes before publication but still does not replace Tauri's minisign verification. @@ -46,11 +46,11 @@ Publication mirrors the outer signature-envelope contract after receipt byte bin Highest-seen update identity remains Distribution state, not Project Persistence state. `apps/desktop/distribution-state` provides a separate bounded append-only Rust log that revalidates committed identities, rejects local version regression/equivocation, synchronizes successful appends and recovers only a syntactically valid torn final record prefix. It deliberately does not claim packaged power-loss equivalence across Windows/macOS until platform fault-injection evidence exists. Project Persistence remains authoritative only for project bytes and the project-schema evidence used by rollback compatibility checks. -Current Tauri updater APIs still materialize a verified update as in-memory bytes. The repository now owns strict selected transport metadata, canonical outer-signature admission, explicit response/redirect/content-coding admission and bounded staging primitives, but commercial readiness still requires a production network adapter that disables implicit redirects and automatic decompression, reports exact status/effective URL/Location/Content-Encoding into `distribution-transport`, and streams actual response chunks into the returned staging path. Counting progress callbacks or checking the fully buffered `Vec` after download is not equivalent evidence. `sync_all()` in a unit/integration test is also not packaged Windows/macOS power-loss proof. +Current Tauri updater APIs still materialize a verified update as in-memory bytes. The repository now owns strict selected transport metadata, canonical outer-signature admission, explicit response/redirect/content-coding admission, bounded diagnostic rendering and bounded staging primitives, but commercial readiness still requires a production network adapter that disables implicit redirects and automatic decompression, reports exact status/effective URL/Location/Content-Encoding into `distribution-transport`, and streams actual response chunks into the returned staging path. Counting progress callbacks or checking the fully buffered `Vec` after download is not equivalent evidence. `sync_all()` in a unit/integration test is also not packaged Windows/macOS power-loss proof. ## Release gate -A release candidate is not commercial-ready until all of the following are true on the exact protected head: required checks and independent review are terminal/qualifying; Windows artifacts are signed by the approved publisher and macOS artifacts are signed/notarized/stapled; updater authority is admitted without placeholder values; remote updater metadata has an authenticated binding before it can mutate freshness state; updater artifact bytes are cryptographically signature-verified and matched to authenticated digest/size evidence using the exact sealed descriptor; only verified bytes can be explicitly promoted beyond sealed cleanup scope; updater replay/rollback/recovery is exercised on packaged targets; the production HTTP adapter disables implicit redirects and automatic content decoding, feeds response status/effective URL/Location/Content-Encoding through `distribution-transport`, streams body bytes through bounded staging, and survives hostile/truncated/oversized/disk-full/cancel/network-error cases; SBOM/NOTICE/provenance agree with exact shipped bytes; model rights and exact model provenance are established; rights-cleared real-audio scientific acceptance is reproducible; the updater can recover to a compatible known-good build without losing project usability; and material UI passes actual-audio, responsive, locale and accessibility E2E. +A release candidate is not commercial-ready until all of the following are true on the exact protected head: required checks and independent review are terminal/qualifying; Windows artifacts are signed by the approved publisher and macOS artifacts are signed/notarized/stapled; updater authority is admitted without placeholder values; remote updater metadata has an authenticated binding before it can mutate freshness state; updater artifact bytes are cryptographically signature-verified and matched to authenticated digest/size evidence using the exact sealed descriptor; only verified bytes can be explicitly promoted beyond sealed cleanup scope; updater replay/rollback/recovery is exercised on packaged targets; the production HTTP adapter disables implicit redirects and automatic content decoding, feeds response status/effective URL/Location/Content-Encoding through `distribution-transport`, streams body bytes through bounded staging, avoids emitting exact provider redirect/signature accessors into ordinary diagnostics, and survives hostile/truncated/oversized/disk-full/cancel/network-error cases; SBOM/NOTICE/provenance agree with exact shipped bytes; model rights and exact model provenance are established; rights-cleared real-audio scientific acceptance is reproducible; the updater can recover to a compatible known-good build without losing project usability; and material UI passes actual-audio, responsive, locale and accessibility E2E. Until those conditions are satisfied, Draft/open PRs and blocked release policies are expected safety states rather than reasons to bypass gates. @@ -62,6 +62,7 @@ Until those conditions are satisfied, Draft/open PRs and blocked release policie - Updater security metadata, provisional runtime admission, durable freshness state and replay/rollback model: `docs/traceability/updater-security-metadata.md` - Bounded updater artifact streaming/staging: `docs/traceability/updater-bounded-download.md` - Explicit signature-envelope/release-response/redirect transport policy: `docs/traceability/updater-transport-policy.md` +- Updater diagnostics redaction: `docs/traceability/updater-transport-diagnostics.md` - Security trust boundaries: `docs/security/app-security.md` - Cross-platform release controls: `docs/security/cross-platform-build-policy.md` - Architecture ownership: `ARCHITECTURE.md` From 5b0ddb585ee1eb7ddadaa66eeab267c6f55d6467 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 15:02:33 +0900 Subject: [PATCH 176/308] test(distribution): require restart recovery for stale staging artifact --- .../tests/staged_artifact.rs | 47 +++++++++++++++---- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/apps/desktop/distribution-download/tests/staged_artifact.rs b/apps/desktop/distribution-download/tests/staged_artifact.rs index 57914e758..6be6ac405 100644 --- a/apps/desktop/distribution-download/tests/staged_artifact.rs +++ b/apps/desktop/distribution-download/tests/staged_artifact.rs @@ -86,20 +86,29 @@ fn receipt_size_mismatch_removes_unsealed_staging_file() { } #[test] -fn preexisting_destination_and_path_like_names_fail_closed() { - let directory = scratch_dir("exclusive"); - fs::write(directory.join("update.bin"), b"existing").expect("write existing file"); +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()); + fs::remove_dir(directory).expect("remove staging directory"); +} + +#[test] +fn path_like_artifact_names_fail_closed() { + let directory = scratch_dir("path-like-name"); - assert_eq!( - StagedArtifactFile::create(&directory, "update.bin").unwrap_err(), - StagingArtifactError::DestinationExists - ); assert_eq!( StagedArtifactFile::create(&directory, "../escape.bin").unwrap_err(), StagingArtifactError::InvalidArtifactName ); - fs::remove_file(directory.join("update.bin")).expect("remove existing file"); fs::remove_dir(directory).expect("remove staging directory"); } @@ -143,3 +152,25 @@ fn symlink_staging_root_is_rejected() { fs::remove_dir(target).expect("remove target directory"); 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"); + fs::remove_dir(directory).expect("remove staging directory"); +} From b7a1839d5941c52800bbeaf22921e143060d1ff6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 15:03:33 +0900 Subject: [PATCH 177/308] fix(distribution): reclaim stale unverified staging artifact on restart --- apps/desktop/distribution-download/src/lib.rs | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/desktop/distribution-download/src/lib.rs b/apps/desktop/distribution-download/src/lib.rs index 719c8203a..49a955274 100644 --- a/apps/desktop/distribution-download/src/lib.rs +++ b/apps/desktop/distribution-download/src/lib.rs @@ -49,9 +49,9 @@ pub enum StagingArtifactError { StagingDirectoryUnavailable(ErrorKind), /// The staging root is not a direct, non-symlink directory. InvalidStagingDirectory, - /// The exact staging destination already exists. + /// A non-regular destination exists or another writer won the exclusive create race. DestinationExists, - /// Exclusive staging-file creation failed. + /// Exclusive staging-file creation or stale-regular cleanup failed. CreateFailed(ErrorKind), /// Flushing userspace buffers failed before sealing. FlushFailed(ErrorKind), @@ -176,8 +176,13 @@ impl ArtifactDownloadAdmission { /// Exclusive temporary artifact owned by the Distribution staging directory. /// /// Creation accepts one portable basename under an already-existing app-owned -/// non-symlink directory. The file is removed on drop unless `seal` transfers -/// cleanup ownership to `SealedArtifactFile`. Callers cannot write the +/// non-symlink directory. This directory is an unverified scratch namespace: +/// a pre-existing regular child with the same admitted basename is treated as +/// an interrupted prior attempt, removed, then replaced with `create_new`. +/// 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 cleanup ownership to `SealedArtifactFile`. Callers cannot write the /// descriptor directly; response bytes must pass through /// `ArtifactDownloadAdmission` via `admit_chunk`. #[derive(Debug)] @@ -188,7 +193,7 @@ pub struct StagedArtifactFile { } impl StagedArtifactFile { - /// Create one new staging artifact without overwriting any existing path. + /// Create one new staging artifact, reclaiming only a stale regular child. pub fn create( staging_directory: &Path, artifact_name: &str, @@ -203,6 +208,18 @@ impl StagedArtifactFile { } 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) From e1b4a8194c5769c559edcaf33aceb567bc1c9a5b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 15:04:16 +0900 Subject: [PATCH 178/308] docs(distribution): trace stale staging restart recovery --- .../updater-staging-restart-recovery.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 docs/traceability/updater-staging-restart-recovery.md diff --git a/docs/traceability/updater-staging-restart-recovery.md b/docs/traceability/updater-staging-restart-recovery.md new file mode 100644 index 000000000..08f84a99f --- /dev/null +++ b/docs/traceability/updater-staging-restart-recovery.md @@ -0,0 +1,41 @@ +# Updater staging restart recovery traceability + +BandScope의 updater staging은 신뢰 검증 전 bytes만 두는 scratch namespace입니다. 정상 cancel/error/drop 경로는 partial file을 지우지만, 프로세스 강제 종료나 전원 상실은 Rust `Drop`을 실행하지 않으므로 같은 artifact basename의 regular file이 남을 수 있습니다. 기존 `create_new`-only 동작은 그 파일을 무조건 `DestinationExists`로 처리해 다음 실행의 동일 업데이트를 영구적으로 막았습니다. + +## 문제와 제약 + +이 경계는 stale partial을 자동으로 신뢰하거나 이어받아서는 안 됩니다. 이전 프로세스가 남긴 bytes에는 response completion, digest, updater signature, metadata authenticity 증거가 없기 때문입니다. 반대로 app-owned staging scratch에 남은 regular file을 수동 정리 전까지 영구 blocker로 두는 것도 restart/recovery 요구에 맞지 않습니다. + +`distribution-download`는 single-writer staging owner라는 전제를 유지합니다. 이 crate의 staging namespace에는 검증 완료 artifact를 장기 보존하지 않습니다. 향후 verified-artifact promotion은 검증된 bytes를 별도 retained/known-good owner로 이동한 뒤에만 수행해야 하며, staging basename을 장기 보관 위치로 재사용하면 안 됩니다. + +## RED → causal fix + +- RED `5b0ddb585ee1eb7ddadaa66eeab267c6f55d6467`: 이전 프로세스가 `update.bin` regular file을 남긴 상황을 재현하고, 새 `StagedArtifactFile::create`가 stale bytes를 그대로 신뢰하지 않으면서 새 zero-length exclusive attempt를 만들 수 있어야 한다는 integration contract를 추가했습니다. 기존 구현은 모든 pre-existing destination을 `DestinationExists`로 거부하므로 이 contract에서 실패합니다. +- Causal fix `b7a1839d5941c52800bbeaf22921e143060d1ff6`: staging root와 portable basename 검증 뒤 exact child를 `symlink_metadata`로 검사합니다. Existing child가 regular file이면 interrupted unverified attempt로 간주해 제거한 뒤 `create_new`로 새 descriptor를 만듭니다. Symlink, directory 등 non-regular child는 제거하지 않고 `DestinationExists`로 fail closed합니다. Cleanup과 exclusive create 사이에 다른 writer가 path를 선점하면 `create_new`가 다시 `DestinationExists`로 실패합니다. +- Unix coverage는 destination symlink가 stale regular artifact로 오인되어 제거되지 않고, symlink target bytes도 변경되지 않는 것을 검증합니다. + +## 실행 계약 + +- stale recovery 대상은 app-owned, non-symlink staging directory의 exact direct child 하나뿐입니다. +- artifact basename의 portable/path-traversal 규칙은 stale recovery 전에 동일하게 적용됩니다. +- pre-existing symlink, directory 또는 기타 non-regular entry는 자동 삭제하지 않습니다. +- pre-existing regular file의 bytes는 재사용하거나 resume하지 않습니다. 검증되지 않은 이전 attempt이므로 제거 후 byte zero에서 다시 시작합니다. +- replacement는 반드시 `create_new`입니다. Cleanup 이후 다른 writer가 path를 선점하면 overwrite하지 않고 실패합니다. +- 정상 새 attempt의 cancel/error/drop cleanup과 sealed-but-unverified cleanup 계약은 그대로 유지됩니다. +- verified artifact를 이 scratch namespace에 장기 보존하는 API는 여전히 없습니다. + +## 기각한 대안 + +기존 regular file을 그대로 열어 이어받는 방식은 기각합니다. 어느 byte까지 authenticated response였는지, 이전 process가 어떤 metadata/signature를 사용했는지 증명할 수 없고, partial bytes를 새 response와 혼합할 수 있습니다. + +`truncate(true)` 또는 overwrite-open으로 기존 path를 바로 재사용하는 방식도 기각합니다. Symlink/non-regular destination을 따라가거나 덮어쓸 수 있고 exclusive ownership 증거가 약해집니다. Exact child를 먼저 `symlink_metadata`로 분류한 뒤 regular file만 제거하고, 별도 `create_new`로 새 attempt를 시작합니다. + +모든 pre-existing destination을 자동 삭제하는 방식도 기각합니다. Directory나 symlink를 stale partial과 동일 취급하면 app-owned scratch 경계를 벗어난 삭제나 예상하지 못한 filesystem object mutation으로 이어질 수 있습니다. + +## Claim boundary + +이 수리는 **restart 이후 stale regular staging file 때문에 동일 update가 영구 차단되는 source-level failure**를 닫습니다. Packaged Windows/macOS에서 실제 process kill, power loss, disk-full, antivirus/file-lock, filesystem crash가 모두 검증됐다는 뜻은 아닙니다. Staging root 자체의 권한/ownership hardening, OS-level pathname race 방어, production HTTP adapter, cryptographic verification, verified-artifact promotion과 last-known-good retention은 별도 release gate입니다. + +## Security Notes + +Staging bytes는 canonical release namespace에서 왔더라도 verification 전까지 untrusted입니다. Restart recovery는 stale bytes를 살리는 기능이 아니라 제거 후 새 admission을 시작하는 기능입니다. Symlink와 non-regular destination은 자동 정리 대상이 아니며, verified artifact는 staging scratch 밖의 별도 owner로 승격되어야 합니다. Audio/project content는 이 updater staging 경계에 들어오지 않습니다. From 84d4a7bd0c5414b3e7625d3d8e72fb21f1396edc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 15:04:52 +0900 Subject: [PATCH 179/308] docs(product): record updater staging restart recovery boundary --- docs/product-technical-gap-baseline.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 0d42560a5..a5d6f8795 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -20,7 +20,7 @@ The product keeps BandScope-specific audio/rehearsal truth inside BandScope. Org | Project Persistence | Project/cache integrity and scientific cache equivalence have dedicated owner work. | Crash/power-loss, disk-full, interrupted write/recovery, last-known-good project state and packaged-OS fault injection remain buyer gates. | | Collaboration Handoff | Export/handoff belongs to BandScope without creating a second collaboration platform. | Only released, bounded artifacts should cross product boundaries; mutable shared DB or cross-service SQL is not accepted. | | Diagnostics | Existing harness/security/build evidence is substantial. Distribution-owned provisional update metadata and transport response objects now bound ordinary Rust `Debug`: exact remote updater signatures are replaced with a fixed marker, and opaque CDN redirect query data is redacted while exact verification/network accessors remain unchanged. | Buyer-safe diagnostics must still prove that support bundles and production call sites do not log exact signature/redirect accessors, audio/project content or credentials, and must distinguish user cancel, provider/runtime failure, corrupt project and release/update failure. | -| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. Release preflight enforces the same canonical numeric `MAJOR.MINOR.PATCH` syntax and unsigned-64-bit component range as the native updater decision core. `distribution-core` and `distribution-state` define deterministic replay/rollback policy and durable highest-seen storage for already-authenticated release identity. `distribution-runtime` strict-parses static updater JSON only as provisional remote input, retains one selected target projection, and redacts the exact selected remote signature from its ordinary `Debug` surface without changing the verification accessor. `distribution-transport` consumes that projection without reparsing JSON, rejects non-canonical Tauri outer signature base64 before network work, admits either an exact direct `200` or one explicit `302` hop to the current `release-assets.githubusercontent.com` egress allowlist, rejects effective-URL drift/redirect chaining, rejects transformed response bodies through non-identity `Content-Encoding` before filesystem mutation, redacts signature/query material from ordinary diagnostics, and composes the final response with `distribution-download`. Publication also rejects receipt-consistent `.sig` bytes that are not canonical standard base64 decoding to UTF-8. `distribution-download` owns dependency-free byte admission plus exclusive staging, exact completion, cleanup-on-failure and descriptor-bound sealed reads. | Tauri cryptographic verification of the decoded updater signature and exact downloaded bytes is still a later trust step; outer base64 validation and diagnostic redaction are only syntax/resource/observability controls. Remote release identity still needs independent metadata authentication. The transport crate is a deterministic response-policy/staging bridge, not an HTTP client: production sockets/TLS/proxy/captive-portal behavior, actual automatic-redirect and automatic-decompression disablement, real disk-full/network-error/cancel evidence and packaged behavior remain gates. The current CDN hostname is a deliberate BandScope allowlist, not a claimed permanent GitHub API guarantee. Digest/signature verification on the exact sealed descriptor and an explicit verified-artifact promotion type/path are still required before any bytes or identity can outlive verification scope. Production updater key/endpoint, Windows/macOS signing/notarization and commercial model rights also remain open. | +| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. Release preflight enforces the same canonical numeric `MAJOR.MINOR.PATCH` syntax and unsigned-64-bit component range as the native updater decision core. `distribution-core` and `distribution-state` define deterministic replay/rollback policy and durable highest-seen storage for already-authenticated release identity. `distribution-runtime` strict-parses static updater JSON only as provisional remote input, retains one selected target projection, and redacts the exact selected remote signature from its ordinary `Debug` surface without changing the verification accessor. `distribution-transport` consumes that projection without reparsing JSON, rejects non-canonical Tauri outer signature base64 before network work, admits either an exact direct `200` or one explicit `302` hop to the current `release-assets.githubusercontent.com` egress allowlist, rejects effective-URL drift/redirect chaining, rejects transformed response bodies through non-identity `Content-Encoding` before filesystem mutation, redacts signature/query material from ordinary diagnostics, and composes the final response with `distribution-download`. Publication also rejects receipt-consistent `.sig` bytes that are not canonical standard base64 decoding to UTF-8. `distribution-download` owns dependency-free byte admission plus exclusive staging, exact completion, cleanup-on-failure and descriptor-bound sealed reads. Its staging namespace now reclaims only a pre-existing regular child with the exact admitted basename as an interrupted unverified attempt, then restarts from byte zero with `create_new`; symlink and other non-regular destinations remain fail-closed. | Tauri cryptographic verification of the decoded updater signature and exact downloaded bytes is still a later trust step; outer base64 validation and diagnostic redaction are only syntax/resource/observability controls. Remote release identity still needs independent metadata authentication. The transport crate is a deterministic response-policy/staging bridge, not an HTTP client: production sockets/TLS/proxy/captive-portal behavior, actual automatic-redirect and automatic-decompression disablement, real disk-full/network-error/cancel evidence and packaged behavior remain gates. Source-level stale-staging restart recovery is not packaged Windows/macOS power-loss evidence. The current CDN hostname is a deliberate BandScope allowlist, not a claimed permanent GitHub API guarantee. Digest/signature verification on the exact sealed descriptor and an explicit verified-artifact promotion type/path are still required before any bytes or identity can outlive verification scope. Production updater key/endpoint, Windows/macOS signing/notarization and commercial model rights also remain open. | | UI / Interaction | Rehearsal-first UI is the product surface; Anti-Slop and accessibility are acceptance criteria, not decoration. | Normal/loading/empty/error/permission/responsive states, KO/EN/JA/ZH/VI/ES/DE/FR expansion/fallback, keyboard/focus/contrast/state semantics and actual-audio E2E must be verified on the exact release candidate. | ## Distribution/update decision boundary @@ -42,15 +42,15 @@ The Rust `apps/desktop/distribution-core` is the deterministic decision layer af Publication mirrors the outer signature-envelope contract after receipt byte binding: `build_updater_manifest.py` requires exact `.sig` bytes to be ASCII canonical standard base64, requires exact decode/re-encode equivalence, and requires the decoded payload to be UTF-8 before embedding it in static updater JSON. This catches malformed receipt-consistent signature bytes before publication but still does not replace Tauri's minisign verification. -`apps/desktop/distribution-download` is a separate network-library-independent streaming/staging boundary. It rejects zero/over-ceiling expected sizes, optional `Content-Length` mismatch, chunks larger than 1 MiB, cumulative overrun before the offending bytes reach the sink, truncated completion and sink-write failure. A failed attempt is poisoned so later chunks cannot manufacture a success receipt. Its staging file uses a bounded portable basename under an existing non-symlink app-owned directory, exclusive `create_new`, cleanup on cancel/error, and flush/`sync_all` plus descriptor size verification before returning a still-open sealed artifact. The sealed artifact remains cleanup-on-drop because exact byte count and `sync_all()` do not establish digest, signature or metadata authenticity. Downstream verification gets a positional read-only wrapper over that exact open descriptor; the write-enabled staging `File` itself is not exposed. The reader owns the original admitted length as a hard upper bound, ignores any later appended tail, and reports early EOF if the descriptor is truncated below that length. +`apps/desktop/distribution-download` is a separate network-library-independent streaming/staging boundary. It rejects zero/over-ceiling expected sizes, optional `Content-Length` mismatch, chunks larger than 1 MiB, cumulative overrun before the offending bytes reach the sink, truncated completion and sink-write failure. A failed attempt is poisoned so later chunks cannot manufacture a success receipt. Its staging file uses a bounded portable basename under an existing non-symlink app-owned directory. If process termination or power loss left a regular file at that exact staging child, a new attempt discards those unverified bytes and restarts from byte zero before exclusive `create_new`; symlink, directory and other non-regular destinations are not reclaimed. Cleanup on cancel/error and flush/`sync_all` plus descriptor size verification still precede returning a still-open sealed artifact. The sealed artifact remains cleanup-on-drop because exact byte count and `sync_all()` do not establish digest, signature or metadata authenticity. Downstream verification gets a positional read-only wrapper over that exact open descriptor; the write-enabled staging `File` itself is not exposed. The reader owns the original admitted length as a hard upper bound, ignores any later appended tail, and reports early EOF if the descriptor is truncated below that length. Verified bytes must eventually move to a separate retained/known-good owner rather than turning this unverified staging basename into persistent storage. Highest-seen update identity remains Distribution state, not Project Persistence state. `apps/desktop/distribution-state` provides a separate bounded append-only Rust log that revalidates committed identities, rejects local version regression/equivocation, synchronizes successful appends and recovers only a syntactically valid torn final record prefix. It deliberately does not claim packaged power-loss equivalence across Windows/macOS until platform fault-injection evidence exists. Project Persistence remains authoritative only for project bytes and the project-schema evidence used by rollback compatibility checks. -Current Tauri updater APIs still materialize a verified update as in-memory bytes. The repository now owns strict selected transport metadata, canonical outer-signature admission, explicit response/redirect/content-coding admission, bounded diagnostic rendering and bounded staging primitives, but commercial readiness still requires a production network adapter that disables implicit redirects and automatic decompression, reports exact status/effective URL/Location/Content-Encoding into `distribution-transport`, and streams actual response chunks into the returned staging path. Counting progress callbacks or checking the fully buffered `Vec` after download is not equivalent evidence. `sync_all()` in a unit/integration test is also not packaged Windows/macOS power-loss proof. +Current Tauri updater APIs still materialize a verified update as in-memory bytes. The repository now owns strict selected transport metadata, canonical outer-signature admission, explicit response/redirect/content-coding admission, bounded diagnostic rendering, bounded staging primitives and source-level recovery from a stale regular staging child left by an interrupted process. Commercial readiness still requires a production network adapter that disables implicit redirects and automatic decompression, reports exact status/effective URL/Location/Content-Encoding into `distribution-transport`, and streams actual response chunks into the returned staging path. Counting progress callbacks or checking the fully buffered `Vec` after download is not equivalent evidence. `sync_all()` and stale-file restart tests are also not packaged Windows/macOS process-kill or power-loss proof. ## Release gate -A release candidate is not commercial-ready until all of the following are true on the exact protected head: required checks and independent review are terminal/qualifying; Windows artifacts are signed by the approved publisher and macOS artifacts are signed/notarized/stapled; updater authority is admitted without placeholder values; remote updater metadata has an authenticated binding before it can mutate freshness state; updater artifact bytes are cryptographically signature-verified and matched to authenticated digest/size evidence using the exact sealed descriptor; only verified bytes can be explicitly promoted beyond sealed cleanup scope; updater replay/rollback/recovery is exercised on packaged targets; the production HTTP adapter disables implicit redirects and automatic content decoding, feeds response status/effective URL/Location/Content-Encoding through `distribution-transport`, streams body bytes through bounded staging, avoids emitting exact provider redirect/signature accessors into ordinary diagnostics, and survives hostile/truncated/oversized/disk-full/cancel/network-error cases; SBOM/NOTICE/provenance agree with exact shipped bytes; model rights and exact model provenance are established; rights-cleared real-audio scientific acceptance is reproducible; the updater can recover to a compatible known-good build without losing project usability; and material UI passes actual-audio, responsive, locale and accessibility E2E. +A release candidate is not commercial-ready until all of the following are true on the exact protected head: required checks and independent review are terminal/qualifying; Windows artifacts are signed by the approved publisher and macOS artifacts are signed/notarized/stapled; updater authority is admitted without placeholder values; remote updater metadata has an authenticated binding before it can mutate freshness state; updater artifact bytes are cryptographically signature-verified and matched to authenticated digest/size evidence using the exact sealed descriptor; only verified bytes can be explicitly promoted beyond sealed cleanup scope; updater replay/rollback/recovery is exercised on packaged targets; the production HTTP adapter disables implicit redirects and automatic content decoding, feeds response status/effective URL/Location/Content-Encoding through `distribution-transport`, streams body bytes through bounded staging, avoids emitting exact provider redirect/signature accessors into ordinary diagnostics, and survives hostile/truncated/oversized/disk-full/cancel/network-error cases; packaged restart/power-loss tests prove stale staging recovery does not block a later valid update; SBOM/NOTICE/provenance agree with exact shipped bytes; model rights and exact model provenance are established; rights-cleared real-audio scientific acceptance is reproducible; the updater can recover to a compatible known-good build without losing project usability; and material UI passes actual-audio, responsive, locale and accessibility E2E. Until those conditions are satisfied, Draft/open PRs and blocked release policies are expected safety states rather than reasons to bypass gates. @@ -61,6 +61,7 @@ Until those conditions are satisfied, Draft/open PRs and blocked release policie - Release receipt/publication: `docs/traceability/release-artifact-receipt.md` - Updater security metadata, provisional runtime admission, durable freshness state and replay/rollback model: `docs/traceability/updater-security-metadata.md` - Bounded updater artifact streaming/staging: `docs/traceability/updater-bounded-download.md` +- Staging restart recovery: `docs/traceability/updater-staging-restart-recovery.md` - Explicit signature-envelope/release-response/redirect transport policy: `docs/traceability/updater-transport-policy.md` - Updater diagnostics redaction: `docs/traceability/updater-transport-diagnostics.md` - Security trust boundaries: `docs/security/app-security.md` From dcd7a805ac2d47e0b1881e0680e59111bf0d9965 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 15:05:43 +0900 Subject: [PATCH 180/308] docs(distribution): align bounded staging with restart recovery --- docs/traceability/updater-bounded-download.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/traceability/updater-bounded-download.md b/docs/traceability/updater-bounded-download.md index 4d0fabd90..2f63d309e 100644 --- a/docs/traceability/updater-bounded-download.md +++ b/docs/traceability/updater-bounded-download.md @@ -23,6 +23,8 @@ BandScope의 Distribution/update 경계는 updater artifact를 신뢰하기 전 - 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`로 실패합니다. +- 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합니다. Cleanup 뒤 path를 다른 writer가 선점하면 exclusive create가 다시 실패하므로 overwrite로 내려가지 않습니다. ## 실행 계약 @@ -40,7 +42,8 @@ BandScope의 Distribution/update 경계는 updater artifact를 신뢰하기 전 - 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을 허용하지 않습니다. -- destination은 `create_new`로만 만들며 기존 file/symlink를 overwrite하지 않습니다. +- staging namespace는 unverified scratch 전용입니다. 같은 exact basename의 pre-existing regular file은 interrupted attempt로 간주해 bytes를 재사용하지 않고 제거한 뒤 byte zero에서 새 `create_new` attempt를 시작합니다. +- pre-existing symlink, directory 또는 기타 non-regular destination은 stale regular artifact로 자동 정리하지 않습니다. Cleanup 뒤 다른 writer가 path를 선점한 경우에도 `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와 같은지 다시 확인합니다. @@ -48,8 +51,9 @@ BandScope의 Distribution/update 경계는 updater artifact를 신뢰하기 전 - 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를 먼저 닫은 다음 staging path를 제거합니다. 후속 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, existing destination, path-like name, invalid staging root와 Unix symlink root를 다룹니다. Python production logic은 추가하지 않았고 repository harness는 locked Rust suite를 validation boundary로 호출합니다. +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, path-like name, invalid staging root, Unix symlink root와 Unix symlink destination 보존을 다룹니다. Python production logic은 추가하지 않았고 repository harness는 locked Rust suite를 validation boundary로 호출합니다. ## 기각한 대안 @@ -61,6 +65,10 @@ Declared `sizeBytes`와 `Content-Length`를 동일시하는 방식도 기각합 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합니다. + 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를 사용합니다. @@ -69,13 +77,13 @@ Descriptor EOF까지 무제한 읽는 방식도 기각합니다. Seal 당시에 ## Claim boundary -현재 crate는 **network-library-independent streaming + staging primitive**입니다. 실제 production updater가 아직 이 crate를 통해 HTTP body를 수신하지 않으므로 end-to-end bounded download가 완료됐다고 주장하지 않습니다. 또한 `sync_all()`과 cleanup tests를 packaged Windows/macOS power-loss durability와 동일시하지 않습니다. 이 crate는 SHA-256, updater signature, metadata authenticity, installer trust도 검증하지 않습니다. +현재 crate는 **network-library-independent streaming + staging primitive**입니다. 실제 production updater가 아직 이 crate를 통해 HTTP body를 수신하지 않으므로 end-to-end bounded download가 완료됐다고 주장하지 않습니다. Stale regular child를 reclaim하는 source test는 process-kill/power-loss 뒤 동일 update가 영구 차단되는 경로를 닫지만, packaged Windows/macOS power-loss durability나 filesystem race hardening 전체를 증명하지 않습니다. 또한 `sync_all()`과 cleanup tests를 packaged Windows/macOS power-loss 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 정책을 보존하고, 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, 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로 제한해야 합니다. Cleanup은 app-owned non-symlink directory라는 전제 안에서만 pathname removal을 수행합니다. 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에 포함하지 않습니다. +Attack surface는 updater HTTP response body, transport length metadata, temporary artifact directory/path, 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를 살리는 기능이 아니라 app-owned scratch의 exact regular child를 제거하고 새 admission을 시작하는 기능입니다. Symlink와 기타 non-regular destination은 자동 정리하지 않습니다. 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 @@ -89,4 +97,4 @@ Rust Project Developers. (2026). *Write in std::io* (Rust 1.98). https://doc.rus 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 +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 From 74565b8a0f26477b022502d27ab55abd77e1c44b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 15:09:42 +0900 Subject: [PATCH 181/308] repair(distribution): return product baseline to canonical owner --- docs/product-technical-gap-baseline.md | 69 -------------------------- 1 file changed, 69 deletions(-) delete mode 100644 docs/product-technical-gap-baseline.md diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md deleted file mode 100644 index a5d6f8795..000000000 --- a/docs/product-technical-gap-baseline.md +++ /dev/null @@ -1,69 +0,0 @@ -# BandScope product / technical gap baseline - -Status: commercial-development baseline, 2026-09-15. This document is a buyer-facing gap register, not a completion claim. Live protected branches, PR/Issue state, executable tests, release receipts and platform evidence remain authoritative when they are more specific. - -## Product truth - -BandScope is a local-first rehearsal decision tool. A buyer should be able to admit a real audio source, derive reproducible MIR evidence, turn it into section/role rehearsal decisions, rehearse against audible source material, save and recover the project safely, and install a verifiable update without losing project usability. A synthetic array, mock player, unsigned package, mutable model dependency, or documentation-only workflow cannot satisfy those claims. - -The product keeps BandScope-specific audio/rehearsal truth inside BandScope. Organization-wide identity, orchestration, graph, sandbox, egress, policy and other CWL foundation capabilities are consumed only through released contracts when needed; their source is not copied into this repository. - -## Bounded-context gap register - -| Bounded context | Current buyer truth | Commercial gap / acceptance boundary | -| --- | --- | --- | -| Audio Ingestion | Local file and YouTube intake have narrow validation paths; project bootstrap is local-first. | Rights-cleared real-audio fixtures must prove supported decode/admission on packaged Windows/macOS, including corrupt/truncated/oversized/link/path edge cases. | -| Resource Admission & Decode | Source identity and admission are separate from derived cache/persistence authority. | The protected integration must preserve one source-admission owner and prove decoder/license/provenance behavior on real files. | -| Signal / MIR Analysis | Rehearsal analysis and stem separation have scientific-generation/cache identity work in flight. | Rights-cleared real decoded audio, recognized MIR metrics, uncertainty boundaries, exact implementation/model generation, full model provenance/rights and reproducible CPU reference evidence remain release gates. | -| Rehearsal Insight | Section/role contracts carry rehearsal-facing cues, confidence and export semantics. | Buyer acceptance still needs real-audio evidence that recommendations remain directionally correct, explainable and stable across supported platforms. | -| Active Player | Desktop UI has a player surface but commercial acceptance is not complete. | Actual decoded audio must remain audible and synchronized across seek/range/section selection, reload and stale-source races; pointer/touch/keyboard and screen-reader alternatives require current-head E2E evidence. | -| Project Persistence | Project/cache integrity and scientific cache equivalence have dedicated owner work. | Crash/power-loss, disk-full, interrupted write/recovery, last-known-good project state and packaged-OS fault injection remain buyer gates. | -| Collaboration Handoff | Export/handoff belongs to BandScope without creating a second collaboration platform. | Only released, bounded artifacts should cross product boundaries; mutable shared DB or cross-service SQL is not accepted. | -| Diagnostics | Existing harness/security/build evidence is substantial. Distribution-owned provisional update metadata and transport response objects now bound ordinary Rust `Debug`: exact remote updater signatures are replaced with a fixed marker, and opaque CDN redirect query data is redacted while exact verification/network accessors remain unchanged. | Buyer-safe diagnostics must still prove that support bundles and production call sites do not log exact signature/redirect accessors, audio/project content or credentials, and must distinguish user cancel, provider/runtime failure, corrupt project and release/update failure. | -| Distribution / Update | #1126 owns exact release identity, model/updater admission, native platform trust, receipts, static manifest, hosted-byte re-verification and immutable-release evidence. Release preflight enforces the same canonical numeric `MAJOR.MINOR.PATCH` syntax and unsigned-64-bit component range as the native updater decision core. `distribution-core` and `distribution-state` define deterministic replay/rollback policy and durable highest-seen storage for already-authenticated release identity. `distribution-runtime` strict-parses static updater JSON only as provisional remote input, retains one selected target projection, and redacts the exact selected remote signature from its ordinary `Debug` surface without changing the verification accessor. `distribution-transport` consumes that projection without reparsing JSON, rejects non-canonical Tauri outer signature base64 before network work, admits either an exact direct `200` or one explicit `302` hop to the current `release-assets.githubusercontent.com` egress allowlist, rejects effective-URL drift/redirect chaining, rejects transformed response bodies through non-identity `Content-Encoding` before filesystem mutation, redacts signature/query material from ordinary diagnostics, and composes the final response with `distribution-download`. Publication also rejects receipt-consistent `.sig` bytes that are not canonical standard base64 decoding to UTF-8. `distribution-download` owns dependency-free byte admission plus exclusive staging, exact completion, cleanup-on-failure and descriptor-bound sealed reads. Its staging namespace now reclaims only a pre-existing regular child with the exact admitted basename as an interrupted unverified attempt, then restarts from byte zero with `create_new`; symlink and other non-regular destinations remain fail-closed. | Tauri cryptographic verification of the decoded updater signature and exact downloaded bytes is still a later trust step; outer base64 validation and diagnostic redaction are only syntax/resource/observability controls. Remote release identity still needs independent metadata authentication. The transport crate is a deterministic response-policy/staging bridge, not an HTTP client: production sockets/TLS/proxy/captive-portal behavior, actual automatic-redirect and automatic-decompression disablement, real disk-full/network-error/cancel evidence and packaged behavior remain gates. Source-level stale-staging restart recovery is not packaged Windows/macOS power-loss evidence. The current CDN hostname is a deliberate BandScope allowlist, not a claimed permanent GitHub API guarantee. Digest/signature verification on the exact sealed descriptor and an explicit verified-artifact promotion type/path are still required before any bytes or identity can outlive verification scope. Production updater key/endpoint, Windows/macOS signing/notarization and commercial model rights also remain open. | -| UI / Interaction | Rehearsal-first UI is the product surface; Anti-Slop and accessibility are acceptance criteria, not decoration. | Normal/loading/empty/error/permission/responsive states, KO/EN/JA/ZH/VI/ES/DE/FR expansion/fallback, keyboard/focus/contrast/state semantics and actual-audio E2E must be verified on the exact release candidate. | - -## Distribution/update decision boundary - -The updater path uses evidence classes with different trust semantics and must not collapse them into one claim. - -1. Tauri updater signatures authenticate the downloaded updater artifact bytes under an organization-approved updater key. -2. BandScope release receipts and `bandscope` static-manifest fields bind publication-time version, source commit, target, artifact byte size/full SHA-256 and minimum supported version. Once fetched remotely, those JSON fields are provisional until an independent metadata-authentication path binds them to trusted release authority. -3. GitHub immutable-release verification provides hosted publication evidence for the published asset set. It does not by itself authenticate a client's later `raw_json` response. -4. Distribution highest-seen state is local anti-replay authority only after the release identity entering it is authenticated. - -Stable-channel version identity is deliberately narrower than full SemVer. `scripts/checks/verify_release_identity.py` and `apps/desktop/distribution-core::StableVersion` both accept only canonical numeric `MAJOR.MINOR.PATCH` with no leading zeros, prerelease suffix or build metadata, and both reject any component above `u64::MAX` (`18446744073709551615`). A future beta channel must introduce one explicit ordering/rollback/replay contract instead of allowing release publication and updater consumption to interpret versions differently. - -The Rust `apps/desktop/distribution-core` is the deterministic decision layer after authentication. It rejects malformed stable versions, target mismatch, downgrade candidates, metadata older than the locally highest authenticated release, same-version release-identity equivocation and rollback to a build that cannot read the current project schema. It does not fetch, install, sign, notarize, parse arbitrary remote JSON, or write project data. - -`apps/desktop/distribution-runtime` is the narrow remote-metadata adapter. It accepts at most 256 KiB of UTF-8 JSON, rejects duplicate/unknown members, enforces the exact four desktop targets, bounds signature/URL/artifact-size fields, pins exact-tag URLs to the current BandScope GitHub release namespace and delegates release-identity syntax to `distribution-core`. Its result type is explicitly provisional. The selected target URL/signature are preserved from this same admitted document via `artifact_url()` and `artifact_signature()` so later transport code does not reparse `raw_json`. Ordinary `Debug` retains bounded candidate/size/release-URL evidence but replaces the exact selected signature with ``; the exact accessor remains unchanged for the later verifier. It has no durable-state dependency and cannot write highest-seen state from syntactically valid `raw_json` alone. - -`apps/desktop/distribution-transport` is the deterministic response-state bridge between provisional metadata and bounded bytes. It copies the selected URL/signature/size/digest from `ProvisionalUpdateMetadata` and first requires the Tauri signature field to be canonical RFC 4648 standard base64, including canonical padding bits. That check only admits the outer Tauri transport envelope; it does not parse minisign or establish cryptographic trust. It then admits a direct `200` only at the exact initial URL and handles GitHub's possible `302` release-asset delivery as one explicit hop rather than hidden HTTP-client behavior. The admitted redirect is currently limited to exact HTTPS `release-assets.githubusercontent.com`; a host change fails closed until the BandScope egress policy is deliberately revised. The redirect response must terminate in `200` at the exact admitted Location and a second redirect is rejected. Before byte-count admission or staging-file creation, any supplied `Content-Encoding` must be explicit `identity`; transformed encodings such as gzip are rejected because updater digest/signature verification must see the exact published artifact bytes. `Content-Length` admission then happens before staging-file creation. Ordinary transport `Debug` replaces the exact signature with a fixed marker and masks opaque CDN query contents without modifying the exact network/verification values. This crate has no socket, HTTP client, JSON parser, installer, trust-promotion or state-repository capability. - -Publication mirrors the outer signature-envelope contract after receipt byte binding: `build_updater_manifest.py` requires exact `.sig` bytes to be ASCII canonical standard base64, requires exact decode/re-encode equivalence, and requires the decoded payload to be UTF-8 before embedding it in static updater JSON. This catches malformed receipt-consistent signature bytes before publication but still does not replace Tauri's minisign verification. - -`apps/desktop/distribution-download` is a separate network-library-independent streaming/staging boundary. It rejects zero/over-ceiling expected sizes, optional `Content-Length` mismatch, chunks larger than 1 MiB, cumulative overrun before the offending bytes reach the sink, truncated completion and sink-write failure. A failed attempt is poisoned so later chunks cannot manufacture a success receipt. Its staging file uses a bounded portable basename under an existing non-symlink app-owned directory. If process termination or power loss left a regular file at that exact staging child, a new attempt discards those unverified bytes and restarts from byte zero before exclusive `create_new`; symlink, directory and other non-regular destinations are not reclaimed. Cleanup on cancel/error and flush/`sync_all` plus descriptor size verification still precede returning a still-open sealed artifact. The sealed artifact remains cleanup-on-drop because exact byte count and `sync_all()` do not establish digest, signature or metadata authenticity. Downstream verification gets a positional read-only wrapper over that exact open descriptor; the write-enabled staging `File` itself is not exposed. The reader owns the original admitted length as a hard upper bound, ignores any later appended tail, and reports early EOF if the descriptor is truncated below that length. Verified bytes must eventually move to a separate retained/known-good owner rather than turning this unverified staging basename into persistent storage. - -Highest-seen update identity remains Distribution state, not Project Persistence state. `apps/desktop/distribution-state` provides a separate bounded append-only Rust log that revalidates committed identities, rejects local version regression/equivocation, synchronizes successful appends and recovers only a syntactically valid torn final record prefix. It deliberately does not claim packaged power-loss equivalence across Windows/macOS until platform fault-injection evidence exists. Project Persistence remains authoritative only for project bytes and the project-schema evidence used by rollback compatibility checks. - -Current Tauri updater APIs still materialize a verified update as in-memory bytes. The repository now owns strict selected transport metadata, canonical outer-signature admission, explicit response/redirect/content-coding admission, bounded diagnostic rendering, bounded staging primitives and source-level recovery from a stale regular staging child left by an interrupted process. Commercial readiness still requires a production network adapter that disables implicit redirects and automatic decompression, reports exact status/effective URL/Location/Content-Encoding into `distribution-transport`, and streams actual response chunks into the returned staging path. Counting progress callbacks or checking the fully buffered `Vec` after download is not equivalent evidence. `sync_all()` and stale-file restart tests are also not packaged Windows/macOS process-kill or power-loss proof. - -## Release gate - -A release candidate is not commercial-ready until all of the following are true on the exact protected head: required checks and independent review are terminal/qualifying; Windows artifacts are signed by the approved publisher and macOS artifacts are signed/notarized/stapled; updater authority is admitted without placeholder values; remote updater metadata has an authenticated binding before it can mutate freshness state; updater artifact bytes are cryptographically signature-verified and matched to authenticated digest/size evidence using the exact sealed descriptor; only verified bytes can be explicitly promoted beyond sealed cleanup scope; updater replay/rollback/recovery is exercised on packaged targets; the production HTTP adapter disables implicit redirects and automatic content decoding, feeds response status/effective URL/Location/Content-Encoding through `distribution-transport`, streams body bytes through bounded staging, avoids emitting exact provider redirect/signature accessors into ordinary diagnostics, and survives hostile/truncated/oversized/disk-full/cancel/network-error cases; packaged restart/power-loss tests prove stale staging recovery does not block a later valid update; SBOM/NOTICE/provenance agree with exact shipped bytes; model rights and exact model provenance are established; rights-cleared real-audio scientific acceptance is reproducible; the updater can recover to a compatible known-good build without losing project usability; and material UI passes actual-audio, responsive, locale and accessibility E2E. - -Until those conditions are satisfied, Draft/open PRs and blocked release policies are expected safety states rather than reasons to bypass gates. - -## Evidence links - -- Stable release version identity: `docs/traceability/release-version-identity.md` -- Distribution admission: `docs/traceability/updater-release-admission.md` -- Release receipt/publication: `docs/traceability/release-artifact-receipt.md` -- Updater security metadata, provisional runtime admission, durable freshness state and replay/rollback model: `docs/traceability/updater-security-metadata.md` -- Bounded updater artifact streaming/staging: `docs/traceability/updater-bounded-download.md` -- Staging restart recovery: `docs/traceability/updater-staging-restart-recovery.md` -- Explicit signature-envelope/release-response/redirect transport policy: `docs/traceability/updater-transport-policy.md` -- Updater diagnostics redaction: `docs/traceability/updater-transport-diagnostics.md` -- Security trust boundaries: `docs/security/app-security.md` -- Cross-platform release controls: `docs/security/cross-platform-build-policy.md` -- Architecture ownership: `ARCHITECTURE.md` From 82843b4833df264aa6d5530d9f46545b1179a0bb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 16:31:42 +0900 Subject: [PATCH 182/308] test(distribution): reject reclaiming active staging attempts --- .../tests/staged_artifact.rs | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/desktop/distribution-download/tests/staged_artifact.rs b/apps/desktop/distribution-download/tests/staged_artifact.rs index 6be6ac405..2e540271c 100644 --- a/apps/desktop/distribution-download/tests/staged_artifact.rs +++ b/apps/desktop/distribution-download/tests/staged_artifact.rs @@ -100,6 +100,31 @@ fn stale_regular_destination_is_reclaimed_before_new_attempt() { fs::remove_dir(directory).expect("remove staging 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); + fs::remove_dir(directory).expect("remove staging directory"); +} + #[test] fn path_like_artifact_names_fail_closed() { let directory = scratch_dir("path-like-name"); @@ -173,4 +198,4 @@ fn symlink_destination_is_not_reclaimed_as_stale_regular_file() { fs::remove_file(link).expect("remove destination symlink"); fs::remove_file(target).expect("remove target fixture"); fs::remove_dir(directory).expect("remove staging directory"); -} +} \ No newline at end of file From 40cd7543fab6ac6cb203e310b16058645edcedae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 16:34:09 +0900 Subject: [PATCH 183/308] fix(distribution): lease staging namespace before stale recovery --- apps/desktop/distribution-download/src/lib.rs | 83 +++++++++++++++---- 1 file changed, 66 insertions(+), 17 deletions(-) diff --git a/apps/desktop/distribution-download/src/lib.rs b/apps/desktop/distribution-download/src/lib.rs index 49a955274..48cd6db5c 100644 --- a/apps/desktop/distribution-download/src/lib.rs +++ b/apps/desktop/distribution-download/src/lib.rs @@ -10,7 +10,7 @@ #![forbid(unsafe_code)] -use std::fs::{self, File, OpenOptions}; +use std::fs::{self, File, OpenOptions, TryLockError}; use std::io::{ErrorKind, Read, Write}; use std::path::{Path, PathBuf}; @@ -21,6 +21,8 @@ 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 { @@ -49,9 +51,11 @@ pub enum StagingArtifactError { 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 or stale-regular cleanup failed. + /// Exclusive staging-file creation, lease acquisition, or stale-regular cleanup failed. CreateFailed(ErrorKind), /// Flushing userspace buffers failed before sealing. FlushFailed(ErrorKind), @@ -176,18 +180,21 @@ impl ArtifactDownloadAdmission { /// Exclusive temporary artifact owned by the Distribution staging directory. /// /// Creation accepts one portable basename under an already-existing app-owned -/// non-symlink directory. This directory is an unverified scratch namespace: -/// a pre-existing regular child with the same admitted basename is treated as -/// an interrupted prior attempt, removed, then replaced with `create_new`. -/// 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 cleanup ownership to `SealedArtifactFile`. Callers cannot write the +/// 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, } @@ -207,6 +214,7 @@ impl StagedArtifactFile { 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) => { @@ -235,6 +243,7 @@ impl StagedArtifactFile { Ok(Self { file: Some(file), + staging_lease: Some(staging_lease), path, retain_on_drop: false, }) @@ -260,9 +269,9 @@ impl StagedArtifactFile { /// Flush, synchronize, and descriptor-check an exactly downloaded artifact. /// - /// A successful seal transfers cleanup responsibility to a still-open - /// `SealedArtifactFile` so later digest/signature verification remains - /// bound to the exact staged bytes rather than reopening an + /// 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( @@ -292,8 +301,13 @@ impl StagedArtifactFile { .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(), }) @@ -309,18 +323,21 @@ impl Drop for StagedArtifactFile { drop(file); } let _ = fs::remove_file(&self.path); + let _ = self.staging_lease.take(); } } /// Synchronized but still unverified staging artifact. /// -/// The descriptor stays 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. A later trust-promotion -/// type, not this byte-count boundary, must explicitly retain verified bytes. +/// 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, } @@ -403,6 +420,38 @@ impl Drop for SealedArtifactFile { 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())), } } @@ -590,4 +639,4 @@ mod tests { assert!(!is_portable_artifact_name("../escape")); assert!(!is_portable_artifact_name("name%2fescape")); } -} +} \ No newline at end of file From ebf94287ea54d329a3276f02a5251054c9b2d20c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 16:34:35 +0900 Subject: [PATCH 184/308] test(distribution): account for persistent staging lease sentinel --- .../tests/staged_artifact.rs | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/apps/desktop/distribution-download/tests/staged_artifact.rs b/apps/desktop/distribution-download/tests/staged_artifact.rs index 2e540271c..6752ea458 100644 --- a/apps/desktop/distribution-download/tests/staged_artifact.rs +++ b/apps/desktop/distribution-download/tests/staged_artifact.rs @@ -3,6 +3,7 @@ use bandscope_distribution_download::{ }; use std::fs; use std::io::ErrorKind; +use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; fn scratch_dir(label: &str) -> std::path::PathBuf { @@ -18,6 +19,16 @@ fn scratch_dir(label: &str) -> std::path::PathBuf { 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"); +} + #[test] fn cancelled_staging_file_is_removed_on_drop() { let directory = scratch_dir("cancel"); @@ -28,7 +39,7 @@ fn cancelled_staging_file_is_removed_on_drop() { drop(staged); assert!(!path.exists()); - fs::remove_dir(directory).expect("remove staging directory"); + remove_scratch_dir(&directory); } #[test] @@ -48,7 +59,7 @@ fn sealed_but_unverified_artifact_is_removed_on_drop() { drop(sealed); assert!(!path.exists()); - fs::remove_dir(directory).expect("remove staging directory"); + remove_scratch_dir(&directory); } #[test] @@ -65,7 +76,7 @@ fn failed_admission_removes_partial_staging_file() { drop(staged); assert!(!path.exists()); - fs::remove_dir(directory).expect("remove staging directory"); + remove_scratch_dir(&directory); } #[test] @@ -82,7 +93,7 @@ fn receipt_size_mismatch_removes_unsealed_staging_file() { assert_eq!(staged.seal(receipt).unwrap_err(), StagingArtifactError::SizeMismatch); assert!(!path.exists()); - fs::remove_dir(directory).expect("remove staging directory"); + remove_scratch_dir(&directory); } #[test] @@ -97,7 +108,7 @@ fn stale_regular_destination_is_reclaimed_before_new_attempt() { assert_eq!(fs::metadata(&path).expect("replacement metadata").len(), 0); drop(staged); assert!(!path.exists()); - fs::remove_dir(directory).expect("remove staging directory"); + remove_scratch_dir(&directory); } #[test] @@ -122,7 +133,7 @@ fn active_staging_attempt_is_not_reclaimed_as_stale() { let replacement = StagedArtifactFile::create(&directory, "update.bin") .expect("released active attempt must allow a fresh retry"); drop(replacement); - fs::remove_dir(directory).expect("remove staging directory"); + remove_scratch_dir(&directory); } #[test] @@ -134,7 +145,7 @@ fn path_like_artifact_names_fail_closed() { StagingArtifactError::InvalidArtifactName ); - fs::remove_dir(directory).expect("remove staging directory"); + remove_scratch_dir(&directory); } #[test] @@ -154,7 +165,7 @@ fn unavailable_or_non_directory_staging_roots_fail_closed() { ); fs::remove_file(regular_file).expect("remove regular fixture"); - fs::remove_dir(directory).expect("remove staging directory"); + remove_scratch_dir(&directory); } #[cfg(unix)] @@ -175,7 +186,7 @@ fn symlink_staging_root_is_rejected() { fs::remove_file(link).expect("remove symlink"); fs::remove_dir(target).expect("remove target directory"); - fs::remove_dir(directory).expect("remove staging directory"); + remove_scratch_dir(&directory); } #[cfg(unix)] @@ -197,5 +208,5 @@ fn symlink_destination_is_not_reclaimed_as_stale_regular_file() { fs::remove_file(link).expect("remove destination symlink"); fs::remove_file(target).expect("remove target fixture"); - fs::remove_dir(directory).expect("remove staging directory"); + remove_scratch_dir(&directory); } \ No newline at end of file From d2d187288ef27e7fabdacde062d83423bfa2e243 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 16:35:12 +0900 Subject: [PATCH 185/308] test(distribution): cover sealed lease and lease symlink boundary --- .../tests/staged_artifact.rs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/apps/desktop/distribution-download/tests/staged_artifact.rs b/apps/desktop/distribution-download/tests/staged_artifact.rs index 6752ea458..e7bd8f1be 100644 --- a/apps/desktop/distribution-download/tests/staged_artifact.rs +++ b/apps/desktop/distribution-download/tests/staged_artifact.rs @@ -62,6 +62,30 @@ fn sealed_but_unverified_artifact_is_removed_on_drop() { 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_eq!(fs::read(sealed.path()).expect("read sealed path"), b"data"); + + 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"); @@ -189,6 +213,31 @@ fn symlink_staging_root_is_rejected() { 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() { From ebefa230880f3e460be012af9cbc42651814c73f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 16:35:53 +0900 Subject: [PATCH 186/308] docs(distribution): trace active staging lease recovery --- .../updater-staging-restart-recovery.md | 55 ++++++++++++------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/docs/traceability/updater-staging-restart-recovery.md b/docs/traceability/updater-staging-restart-recovery.md index 08f84a99f..4ea6a6862 100644 --- a/docs/traceability/updater-staging-restart-recovery.md +++ b/docs/traceability/updater-staging-restart-recovery.md @@ -1,41 +1,58 @@ # Updater staging restart recovery traceability -BandScope의 updater staging은 신뢰 검증 전 bytes만 두는 scratch namespace입니다. 정상 cancel/error/drop 경로는 partial file을 지우지만, 프로세스 강제 종료나 전원 상실은 Rust `Drop`을 실행하지 않으므로 같은 artifact basename의 regular file이 남을 수 있습니다. 기존 `create_new`-only 동작은 그 파일을 무조건 `DestinationExists`로 처리해 다음 실행의 동일 업데이트를 영구적으로 막았습니다. +BandScope의 updater staging은 신뢰 검증 전 bytes만 두는 scratch namespace입니다. 정상 cancel/error/drop에서는 partial file을 제거하지만 프로세스 강제 종료나 전원 상실은 Rust `Drop`을 실행하지 않을 수 있습니다. 반대로 살아 있는 다른 BandScope 인스턴스의 regular staging file을 crash residue로 오인해 지우면 안 됩니다. Restart recovery와 concurrent ownership을 함께 만족해야 합니다. ## 문제와 제약 -이 경계는 stale partial을 자동으로 신뢰하거나 이어받아서는 안 됩니다. 이전 프로세스가 남긴 bytes에는 response completion, digest, updater signature, metadata authenticity 증거가 없기 때문입니다. 반대로 app-owned staging scratch에 남은 regular file을 수동 정리 전까지 영구 blocker로 두는 것도 restart/recovery 요구에 맞지 않습니다. +최초 구현은 `create_new`만 사용했기 때문에 crash 뒤 남은 regular child가 다음 동일 업데이트를 영구적으로 `DestinationExists`에 가둘 수 있었습니다. 이를 고친 `b7a1839d5941c52800bbeaf22921e143060d1ff6`는 app-owned staging의 pre-existing regular child를 stale unverified bytes로 보고 제거했습니다. -`distribution-download`는 single-writer staging owner라는 전제를 유지합니다. 이 crate의 staging namespace에는 검증 완료 artifact를 장기 보존하지 않습니다. 향후 verified-artifact promotion은 검증된 bytes를 별도 retained/known-good owner로 이동한 뒤에만 수행해야 하며, staging basename을 장기 보관 위치로 재사용하면 안 됩니다. +그 수리만으로는 충분하지 않았습니다. 다른 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 -- RED `5b0ddb585ee1eb7ddadaa66eeab267c6f55d6467`: 이전 프로세스가 `update.bin` regular file을 남긴 상황을 재현하고, 새 `StagedArtifactFile::create`가 stale bytes를 그대로 신뢰하지 않으면서 새 zero-length exclusive attempt를 만들 수 있어야 한다는 integration contract를 추가했습니다. 기존 구현은 모든 pre-existing destination을 `DestinationExists`로 거부하므로 이 contract에서 실패합니다. -- Causal fix `b7a1839d5941c52800bbeaf22921e143060d1ff6`: staging root와 portable basename 검증 뒤 exact child를 `symlink_metadata`로 검사합니다. Existing child가 regular file이면 interrupted unverified attempt로 간주해 제거한 뒤 `create_new`로 새 descriptor를 만듭니다. Symlink, directory 등 non-regular child는 제거하지 않고 `DestinationExists`로 fail closed합니다. Cleanup과 exclusive create 사이에 다른 writer가 path를 선점하면 `create_new`가 다시 `DestinationExists`로 실패합니다. -- Unix coverage는 destination symlink가 stale regular artifact로 오인되어 제거되지 않고, symlink target bytes도 변경되지 않는 것을 검증합니다. +- 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를 따라가지 않는지를 고정했습니다. ## 실행 계약 -- stale recovery 대상은 app-owned, non-symlink staging directory의 exact direct child 하나뿐입니다. -- artifact basename의 portable/path-traversal 규칙은 stale recovery 전에 동일하게 적용됩니다. -- pre-existing symlink, directory 또는 기타 non-regular entry는 자동 삭제하지 않습니다. -- pre-existing regular file의 bytes는 재사용하거나 resume하지 않습니다. 검증되지 않은 이전 attempt이므로 제거 후 byte zero에서 다시 시작합니다. -- replacement는 반드시 `create_new`입니다. Cleanup 이후 다른 writer가 path를 선점하면 overwrite하지 않고 실패합니다. -- 정상 새 attempt의 cancel/error/drop cleanup과 sealed-but-unverified cleanup 계약은 그대로 유지됩니다. -- verified artifact를 이 scratch namespace에 장기 보존하는 API는 여전히 없습니다. +- 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는 없습니다. + +## 선택과 기각한 대안 -## 기각한 대안 +Artifact file 자체만 advisory-lock하는 방식은 선택하지 않았습니다. `create_new`와 lock 획득 사이에는 별도 process가 새 pathname을 관찰할 수 있어 create+lock을 하나의 portable atomic operation으로 만들 수 없고, stale classification과 live ownership을 안정적으로 직렬화하지 못합니다. -기존 regular file을 그대로 열어 이어받는 방식은 기각합니다. 어느 byte까지 authenticated response였는지, 이전 process가 어떤 metadata/signature를 사용했는지 증명할 수 없고, partial bytes를 새 response와 혼합할 수 있습니다. +기존 regular file을 그대로 열어 resume하는 방식도 기각합니다. 어느 byte까지 authenticated response였는지, 이전 process가 어떤 metadata/signature를 사용했는지 증명할 수 없고 partial bytes를 새 response와 혼합할 수 있습니다. -`truncate(true)` 또는 overwrite-open으로 기존 path를 바로 재사용하는 방식도 기각합니다. Symlink/non-regular destination을 따라가거나 덮어쓸 수 있고 exclusive ownership 증거가 약해집니다. Exact child를 먼저 `symlink_metadata`로 분류한 뒤 regular file만 제거하고, 별도 `create_new`로 새 attempt를 시작합니다. +`truncate(true)` 또는 overwrite-open으로 기존 artifact path를 바로 재사용하는 방식도 기각합니다. Symlink/non-regular destination을 따라가거나 덮어쓸 수 있고 exclusive ownership 증거가 약해집니다. -모든 pre-existing destination을 자동 삭제하는 방식도 기각합니다. Directory나 symlink를 stale partial과 동일 취급하면 app-owned scratch 경계를 벗어난 삭제나 예상하지 못한 filesystem object mutation으로 이어질 수 있습니다. +Lease sentinel을 정상 drop마다 삭제하는 방식도 사용하지 않습니다. Lock holder가 sentinel pathname을 unlink하면 다른 process가 새 sentinel inode를 만들 수 있고, 기존 inode를 열어 기다리던 process와 lock domain이 갈라질 수 있습니다. Sentinel은 남겨 두고 OS lock의 보유 여부만 active ownership으로 사용합니다. ## Claim boundary -이 수리는 **restart 이후 stale regular staging file 때문에 동일 update가 영구 차단되는 source-level failure**를 닫습니다. Packaged Windows/macOS에서 실제 process kill, power loss, disk-full, antivirus/file-lock, filesystem crash가 모두 검증됐다는 뜻은 아닙니다. Staging root 자체의 권한/ownership hardening, OS-level pathname race 방어, production HTTP adapter, cryptographic verification, verified-artifact promotion과 last-known-good retention은 별도 release gate입니다. +이 수리는 **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입니다. + +Packaged Windows/macOS에서 실제 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` 계열에 대응하지만 세부 상호작용은 platform-specific이라고 명시합니다. BandScope는 이 API를 cooperating updater process 간 lease로만 사용합니다. ## Security Notes -Staging bytes는 canonical release namespace에서 왔더라도 verification 전까지 untrusted입니다. Restart recovery는 stale bytes를 살리는 기능이 아니라 제거 후 새 admission을 시작하는 기능입니다. Symlink와 non-regular destination은 자동 정리 대상이 아니며, verified artifact는 staging scratch 밖의 별도 owner로 승격되어야 합니다. Audio/project content는 이 updater staging 경계에 들어오지 않습니다. +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 From 9046e904b247fafb76e7e55d0d9fd578de4916ab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 16:37:55 +0900 Subject: [PATCH 187/308] docs(distribution): make bounded staging lease code-current --- docs/traceability/updater-bounded-download.md | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/docs/traceability/updater-bounded-download.md b/docs/traceability/updater-bounded-download.md index 2f63d309e..e18506830 100644 --- a/docs/traceability/updater-bounded-download.md +++ b/docs/traceability/updater-bounded-download.md @@ -24,7 +24,12 @@ BandScope의 Distribution/update 경계는 updater artifact를 신뢰하기 전 - 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`로 실패합니다. -- 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합니다. Cleanup 뒤 path를 다른 writer가 선점하면 exclusive create가 다시 실패하므로 overwrite로 내려가지 않습니다. +- 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 문서에 연결했습니다. ## 실행 계약 @@ -42,18 +47,21 @@ BandScope의 Distribution/update 경계는 updater artifact를 신뢰하기 전 - 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을 허용하지 않습니다. -- staging namespace는 unverified scratch 전용입니다. 같은 exact basename의 pre-existing regular file은 interrupted attempt로 간주해 bytes를 재사용하지 않고 제거한 뒤 byte zero에서 새 `create_new` attempt를 시작합니다. -- pre-existing symlink, directory 또는 기타 non-regular destination은 stale regular artifact로 자동 정리하지 않습니다. Cleanup 뒤 다른 writer가 path를 선점한 경우에도 `create_new`가 overwrite하지 않고 `DestinationExists`로 실패합니다. +- 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를 계속 열어 두므로 후속 digest/signature verification이 path reopen보다 exact staged bytes에 결합될 수 있습니다. +- 성공한 `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를 먼저 닫은 다음 staging path를 제거합니다. 후속 digest/signature/authenticated-metadata 결합이 성공하기 전에는 unverified bytes가 정상 종료 경로에서 남지 않습니다. +- 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, path-like name, invalid staging root, Unix symlink root와 Unix symlink destination 보존을 다룹니다. Python production logic은 추가하지 않았고 repository harness는 locked Rust suite를 validation boundary로 호출합니다. +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로 호출합니다. ## 기각한 대안 @@ -69,6 +77,10 @@ Crash 뒤 남은 regular staging file을 그대로 resume하는 방식도 기각 모든 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를 사용합니다. @@ -77,13 +89,15 @@ Descriptor EOF까지 무제한 읽는 방식도 기각합니다. Seal 당시에 ## Claim boundary -현재 crate는 **network-library-independent streaming + staging primitive**입니다. 실제 production updater가 아직 이 crate를 통해 HTTP body를 수신하지 않으므로 end-to-end bounded download가 완료됐다고 주장하지 않습니다. Stale regular child를 reclaim하는 source test는 process-kill/power-loss 뒤 동일 update가 영구 차단되는 경로를 닫지만, packaged Windows/macOS power-loss durability나 filesystem race hardening 전체를 증명하지 않습니다. 또한 `sync_all()`과 cleanup tests를 packaged Windows/macOS power-loss durability와 동일시하지 않습니다. 이 crate는 SHA-256, updater signature, metadata authenticity, installer trust도 검증하지 않습니다. +현재 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입니다. -다음 repository-owned 단계는 production network adapter가 full-response buffering 없이 bounded chunks를 이 primitive에 전달하도록 연결하는 것입니다. 그 adapter는 canonical release origin/redirect 정책을 보존하고, 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를 넘겨야 합니다. +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, 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를 살리는 기능이 아니라 app-owned scratch의 exact regular child를 제거하고 새 admission을 시작하는 기능입니다. Symlink와 기타 non-regular destination은 자동 정리하지 않습니다. 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에 포함하지 않습니다. +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 @@ -91,6 +105,8 @@ Tauri Contributors. (2026). *Updater*. Tauri v2 documentation. https://v2.tauri. 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 From 752b5343c809b8e8f76a9886295de42e19ebc3ff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 16:40:25 +0900 Subject: [PATCH 188/308] test(distribution): avoid platform-specific reads under staging lease --- apps/desktop/distribution-download/tests/staged_artifact.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/distribution-download/tests/staged_artifact.rs b/apps/desktop/distribution-download/tests/staged_artifact.rs index e7bd8f1be..67808b43d 100644 --- a/apps/desktop/distribution-download/tests/staged_artifact.rs +++ b/apps/desktop/distribution-download/tests/staged_artifact.rs @@ -77,7 +77,7 @@ fn sealed_unverified_artifact_keeps_staging_lease() { StagedArtifactFile::create(&directory, "update.bin").unwrap_err(), StagingArtifactError::ConcurrentAttempt ); - assert_eq!(fs::read(sealed.path()).expect("read sealed path"), b"data"); + assert!(sealed.path().is_file()); drop(sealed); let replacement = StagedArtifactFile::create(&directory, "update.bin") From c30167d9afe501a6d7c2c9c31e8c75ecce8c2bb6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 16:41:31 +0900 Subject: [PATCH 189/308] docs(distribution): record cross-platform staging lease fixture --- docs/traceability/updater-staging-restart-recovery.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/traceability/updater-staging-restart-recovery.md b/docs/traceability/updater-staging-restart-recovery.md index 4ea6a6862..2154122fb 100644 --- a/docs/traceability/updater-staging-restart-recovery.md +++ b/docs/traceability/updater-staging-restart-recovery.md @@ -18,6 +18,7 @@ Stale bytes를 resume하거나 신뢰하는 것도 허용하지 않습니다. - 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는 바꾸지 않습니다. ## 실행 계약 @@ -51,7 +52,7 @@ Packaged Windows/macOS에서 실제 process kill, power loss, disk-full, antivir 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` 계열에 대응하지만 세부 상호작용은 platform-specific이라고 명시합니다. BandScope는 이 API를 cooperating updater process 간 lease로만 사용합니다. +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 From 41afd2abb6f3beeded35d2576f3f1e9532b75ce3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 16:45:54 +0900 Subject: [PATCH 190/308] test(distribution): require staging lease contracts on shipped OS families --- .../tests/test_distribution_update_core.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/services/analysis-engine/tests/test_distribution_update_core.py b/services/analysis-engine/tests/test_distribution_update_core.py index c048e555a..7e0be96fa 100644 --- a/services/analysis-engine/tests/test_distribution_update_core.py +++ b/services/analysis-engine/tests/test_distribution_update_core.py @@ -4,6 +4,9 @@ import subprocess from pathlib import Path +from types import MappingProxyType + +import yaml _REPO_ROOT = Path(__file__).resolve().parents[3] _MANIFESTS = ( @@ -13,6 +16,8 @@ _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: @@ -39,3 +44,26 @@ def test_distribution_update_native_suites_are_green() -> None: + 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 From cfb3ec11503fd8b7abafce05f07ea916cc34153c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 16:46:16 +0900 Subject: [PATCH 191/308] ci(distribution): gate staging lease tests on Windows macOS and Linux --- .github/workflows/ci.yml | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) 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 From 137d1620f62e289c4d655290ebb89ae756222611 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 16:46:51 +0900 Subject: [PATCH 192/308] docs(distribution): require cross-platform staging lease evidence --- docs/traceability/updater-staging-restart-recovery.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/traceability/updater-staging-restart-recovery.md b/docs/traceability/updater-staging-restart-recovery.md index 2154122fb..af40d8e8f 100644 --- a/docs/traceability/updater-staging-restart-recovery.md +++ b/docs/traceability/updater-staging-restart-recovery.md @@ -19,6 +19,8 @@ Stale bytes를 resume하거나 신뢰하는 것도 허용하지 않습니다. - 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가 성공할 수 없습니다. ## 실행 계약 @@ -31,6 +33,7 @@ Stale bytes를 resume하거나 신뢰하는 것도 허용하지 않습니다. - 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를 통과한 뒤에만 시작할 수 있습니다. ## 선택과 기각한 대안 @@ -42,11 +45,13 @@ Artifact file 자체만 advisory-lock하는 방식은 선택하지 않았습니 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를 직접 확보해야 합니다. + ## 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입니다. -Packaged Windows/macOS에서 실제 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입니다. +Cross-platform CI matrix는 Windows/macOS/Linux에서 현재 source 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입니다. ## 근거 From 44265a038bf1c162df15539de0bcfaaf6f286bea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 16:48:51 +0900 Subject: [PATCH 193/308] test(distribution): prove staging lease across real processes --- .../tests/staged_artifact.rs | 70 ++++++++++++++++++- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/apps/desktop/distribution-download/tests/staged_artifact.rs b/apps/desktop/distribution-download/tests/staged_artifact.rs index 67808b43d..d1d42e3be 100644 --- a/apps/desktop/distribution-download/tests/staged_artifact.rs +++ b/apps/desktop/distribution-download/tests/staged_artifact.rs @@ -4,7 +4,13 @@ use bandscope_distribution_download::{ use std::fs; use std::io::ErrorKind; use std::path::Path; -use std::time::{SystemTime, UNIX_EPOCH}; +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() @@ -29,6 +35,16 @@ fn remove_scratch_dir(directory: &Path) { 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"); @@ -160,6 +176,56 @@ fn active_staging_attempt_is_not_reclaimed_as_stale() { 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"); @@ -258,4 +324,4 @@ fn symlink_destination_is_not_reclaimed_as_stale_regular_file() { fs::remove_file(link).expect("remove destination symlink"); fs::remove_file(target).expect("remove target fixture"); remove_scratch_dir(&directory); -} \ No newline at end of file +} From a999376b1d414a454b8eb178b9498acee59f992e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 16:49:42 +0900 Subject: [PATCH 194/308] docs(distribution): trace real-process staging lease coverage --- docs/traceability/updater-staging-restart-recovery.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/traceability/updater-staging-restart-recovery.md b/docs/traceability/updater-staging-restart-recovery.md index af40d8e8f..8082b23c7 100644 --- a/docs/traceability/updater-staging-restart-recovery.md +++ b/docs/traceability/updater-staging-restart-recovery.md @@ -21,6 +21,7 @@ Stale bytes를 resume하거나 신뢰하는 것도 허용하지 않습니다. - 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에서 실행됩니다. ## 실행 계약 @@ -34,6 +35,7 @@ Stale bytes를 resume하거나 신뢰하는 것도 허용하지 않습니다. - 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을 증명했다고 보지 않습니다. ## 선택과 기각한 대안 @@ -47,11 +49,13 @@ Lease sentinel을 정상 drop마다 삭제하는 방식도 사용하지 않습 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는 Windows/macOS/Linux에서 현재 source 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입니다. +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입니다. ## 근거 From af893377a17c527df951dc70836c942509c230c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 17:06:32 +0900 Subject: [PATCH 195/308] test(release): red for descriptor-bound identity inputs --- .../test_release_identity_file_admission.py | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 services/analysis-engine/tests/test_release_identity_file_admission.py 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..f82709f3c --- /dev/null +++ b/services/analysis-engine/tests/test_release_identity_file_admission.py @@ -0,0 +1,51 @@ +"""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) + + +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) From 42d34b5e0b0bb01ebc8c6801552e10b9857ab1f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 17:07:53 +0900 Subject: [PATCH 196/308] fix(release): bind identity reads to stable descriptors --- scripts/checks/verify_release_identity.py | 92 +++++++++++++++++++++-- 1 file changed, 85 insertions(+), 7 deletions(-) diff --git a/scripts/checks/verify_release_identity.py b/scripts/checks/verify_release_identity.py index e3f619ce7..173bc0d40 100644 --- a/scripts/checks/verify_release_identity.py +++ b/scripts/checks/verify_release_identity.py @@ -4,6 +4,9 @@ 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 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 @@ -24,6 +27,7 @@ import json import os import re +import stat import sys from pathlib import Path from types import ModuleType @@ -34,6 +38,8 @@ 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: @@ -53,11 +59,82 @@ def _is_canonical_stable_version(value: str) -> bool: ) +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 _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 release metadata document and require a JSON object root.""" + """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(metadata_path.read_text(encoding="utf-8")) - except (OSError, UnicodeError, json.JSONDecodeError) as metadata_error: + metadata_document = json.loads( + raw_text, object_pairs_hook=_reject_duplicate_pairs + ) + except json.JSONDecodeError as metadata_error: raise ValueError( f"could not read release metadata: {metadata_path.name}" ) from metadata_error @@ -126,10 +203,11 @@ def verify_release_identity( repository_root: Path, release_tag: str | None = None ) -> str: """Verify package, Tauri, and optional tag versions against ``VERSION``.""" - try: - version_text = (repository_root / "VERSION").read_text(encoding="utf-8") - except (OSError, UnicodeError) as identity_error: - raise ValueError("could not read authoritative VERSION") from identity_error + version_text = _read_bounded_regular_text( + repository_root / "VERSION", + maximum_bytes=_MAX_VERSION_BYTES, + label="VERSION", + ) version_lines = version_text.splitlines() if ( From 0ef8912516edba7447408ddd1f3279c811888b3f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 17:08:32 +0900 Subject: [PATCH 197/308] docs(release): trace descriptor-bound identity admission --- docs/traceability/release-version-identity.md | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/traceability/release-version-identity.md b/docs/traceability/release-version-identity.md index d9e6599c4..3e1d7fcc8 100644 --- a/docs/traceability/release-version-identity.md +++ b/docs/traceability/release-version-identity.md @@ -6,18 +6,24 @@ BandScope's release preflight originally required `VERSION` to be one trimmed li 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. + ## 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 now 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. + ## 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. - The checked-in current authority remains `0.1.3`; these repairs change future admission, not the identity of the current source tree. ## Alternatives rejected @@ -34,6 +40,14 @@ Rejected. Lexical grammar and numeric domain are different constraints. An unbou 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. + +### 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. @@ -44,10 +58,12 @@ Rejected. Prerelease precedence and build metadata semantics would then differ a ## Claim boundary -This repair proves only that repository-controlled stable release preflight and the native updater decision core agree on version syntax and numeric component range. It does not 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. +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, 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-domain agreement does not substitute for Windows/macOS signing, updater-key authority, immutable release publication, or rights-cleared real-audio scientific acceptance. +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 Preston-Werner, T. (n.d.). *Semantic Versioning 2.0.0*. https://semver.org/spec/v2.0.0.html + +Python Software Foundation. (2026). *os — Miscellaneous operating system interfaces: `open`, `fstat`, and `lstat`*. Python 3 standard library documentation. From 68f1bb5531879b8f75d08f1a7e0db84f0b4a36c6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 18:01:31 +0900 Subject: [PATCH 198/308] test(release): reject nonstandard JSON constants in identity inputs --- .../test_release_identity_file_admission.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/services/analysis-engine/tests/test_release_identity_file_admission.py b/services/analysis-engine/tests/test_release_identity_file_admission.py index f82709f3c..42bd125f5 100644 --- a/services/analysis-engine/tests/test_release_identity_file_admission.py +++ b/services/analysis-engine/tests/test_release_identity_file_admission.py @@ -34,6 +34,24 @@ def test_release_identity_rejects_duplicate_json_version_projection(tmp_path: Pa 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( From 494aa0f5d0b966a1a6e2c5fcc64ab5655c56d5d0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 18:02:09 +0900 Subject: [PATCH 199/308] fix(release): require strict standard JSON identity metadata --- scripts/checks/verify_release_identity.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/checks/verify_release_identity.py b/scripts/checks/verify_release_identity.py index 173bc0d40..a2fd15378 100644 --- a/scripts/checks/verify_release_identity.py +++ b/scripts/checks/verify_release_identity.py @@ -6,7 +6,8 @@ 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 are rejected before any version value is compared. + 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 @@ -69,6 +70,11 @@ def _reject_duplicate_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: 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: @@ -132,7 +138,9 @@ def _read_json_object(metadata_path: Path) -> dict[str, Any]: ) try: metadata_document = json.loads( - raw_text, object_pairs_hook=_reject_duplicate_pairs + raw_text, + object_pairs_hook=_reject_duplicate_pairs, + parse_constant=_reject_nonstandard_json_constant, ) except json.JSONDecodeError as metadata_error: raise ValueError( From 6808f2930fc1523152fb087aeb2a0524163cd345 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 18:02:48 +0900 Subject: [PATCH 200/308] docs(traceability): record strict JSON release identity admission --- docs/traceability/release-version-identity.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/traceability/release-version-identity.md b/docs/traceability/release-version-identity.md index 3e1d7fcc8..b17182958 100644 --- a/docs/traceability/release-version-identity.md +++ b/docs/traceability/release-version-identity.md @@ -8,13 +8,17 @@ The first grammar repair rejected prerelease/build/leading-zero forms, but fresh 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 now 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. +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 @@ -24,6 +28,8 @@ Release identity inputs are now admitted from one opened descriptor each. `VERSI - 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 @@ -44,6 +50,10 @@ Rejected. Python integers are not the runtime domain, and very large decimal con 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. @@ -58,12 +68,16 @@ Rejected. Prerelease precedence and build metadata semantics would then differ a ## 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, 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. +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. From 8d56ab1015077e256e560deba9611979cb81ec5d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 19:10:18 +0900 Subject: [PATCH 201/308] test(distribution): reject malformed nonselected signature envelope --- .../tests/signature_envelope.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 apps/desktop/distribution-runtime/tests/signature_envelope.rs 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..e6b5f2da2 --- /dev/null +++ b/apps/desktop/distribution-runtime/tests/signature_envelope.rs @@ -0,0 +1,19 @@ +use bandscope_distribution_runtime::{admit_untrusted_raw_json, MetadataError}; + +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) + ); +} From 4e28d0cf5edfb399e3ced07b12daf5c4a7aace62 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 19:11:42 +0900 Subject: [PATCH 202/308] fix(distribution): validate all updater signature envelopes --- apps/desktop/distribution-runtime/src/lib.rs | 87 ++++++++++++++++---- 1 file changed, 70 insertions(+), 17 deletions(-) diff --git a/apps/desktop/distribution-runtime/src/lib.rs b/apps/desktop/distribution-runtime/src/lib.rs index 93176683b..0be6b7d73 100644 --- a/apps/desktop/distribution-runtime/src/lib.rs +++ b/apps/desktop/distribution-runtime/src/lib.rs @@ -54,7 +54,7 @@ pub enum MetadataError { UnexpectedShape, /// The requested desktop target is not one of BandScope's release targets. UnsupportedTarget, - /// A platform signature field is empty, oversized, or contains a NUL byte. + /// 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, @@ -137,13 +137,15 @@ impl ProvisionalUpdateMetadata { /// /// 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, bounds signature/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. +/// 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, @@ -272,12 +274,56 @@ fn validate_candidate_syntax( } fn validate_signature(value: &str) -> Result<(), MetadataError> { - if value.is_empty() || value.len() > MAX_SIGNATURE_BYTES || value.as_bytes().contains(&0) { + 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 @@ -607,10 +653,10 @@ mod tests { r#"{{ "version": "{version}", "platforms": {{ - "windows-x86_64": {{"signature": "sig-win-x86\\n", "url": "https://github.com/ContextualWisdomLab/bandscope/releases/download/v{version}/win-x86.zip"}}, - "windows-aarch64": {{"signature": "sig-win-arm", "url": "https://github.com/ContextualWisdomLab/bandscope/releases/download/v{version}/win-arm.zip"}}, - "darwin-x86_64": {{"signature": "sig-mac-x86", "url": "https://github.com/ContextualWisdomLab/bandscope/releases/download/v{version}/mac-x86.tar.gz"}}, - "darwin-aarch64": {{"signature": "sig-mac-arm", "url": "https://github.com/ContextualWisdomLab/bandscope/releases/download/v{version}/mac-arm.tar.gz"}} + "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, @@ -762,12 +808,19 @@ mod tests { #[test] fn parser_accepts_json_unicode_escape_but_rejects_invalid_surrogate() { - let escaped = manifest("2.0.0").replace("sig-win-arm", "sig-\\u2603"); - assert!(admit_untrusted_raw_json(escaped.as_bytes(), "windows-x86_64").is_ok()); + 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()) + )]) + ); - let invalid = manifest("2.0.0").replace("sig-win-arm", "sig-\\uD800x"); assert_eq!( - admit_untrusted_raw_json(invalid.as_bytes(), "windows-x86_64"), + Parser::new(br#"{"value":"\uD800x"}"#).parse_document(), Err(MetadataError::InvalidJson) ); } From 9b691d7f1e67dff23b26b1427a8c7bf63b6fd025 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 19:12:43 +0900 Subject: [PATCH 203/308] refactor(distribution): keep signature syntax in metadata owner --- .../desktop/distribution-transport/src/lib.rs | 57 ++----------------- 1 file changed, 5 insertions(+), 52 deletions(-) diff --git a/apps/desktop/distribution-transport/src/lib.rs b/apps/desktop/distribution-transport/src/lib.rs index 76a0a3d09..10cdd3370 100644 --- a/apps/desktop/distribution-transport/src/lib.rs +++ b/apps/desktop/distribution-transport/src/lib.rs @@ -39,8 +39,6 @@ impl fmt::Debug for RedactedUrl<'_> { pub enum TransportPolicyError { /// The provisional updater URL did not contain a direct artifact basename. InvalidAdmittedArtifactUrl, - /// The provisional Tauri updater signature is not canonical standard base64. - InvalidArtifactSignatureEnvelope, /// 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. @@ -208,9 +206,11 @@ impl ReleaseTransportPolicy { /// /// No raw JSON is accepted here. The URL, signature, size and digest are /// copied from `ProvisionalUpdateMetadata` and remain provisional evidence. - /// The signature is required to have Tauri's outer canonical standard-base64 - /// envelope before any network request, but this does not verify its minisign - /// payload or authenticate the remote metadata that carried it. + /// `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 { @@ -220,9 +220,6 @@ impl ReleaseTransportPolicy { .map(|(_, name)| name) .filter(|name| !name.is_empty()) .ok_or(TransportPolicyError::InvalidAdmittedArtifactUrl)?; - if !is_canonical_standard_base64(metadata.artifact_signature()) { - return Err(TransportPolicyError::InvalidArtifactSignatureEnvelope); - } Ok(Self { initial_url: initial_url.to_owned(), artifact_name: artifact_name.to_owned(), @@ -342,50 +339,6 @@ impl TransportDownload { } } -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_asset_cdn_url(value: &str) -> Result<(), TransportPolicyError> { if value.is_empty() || value.len() > MAX_REDIRECT_URL_BYTES From 5b85e03fde690240df62ac18c4e49b9052047f83 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 19:13:11 +0900 Subject: [PATCH 204/308] test(distribution): assert signature envelope owner boundary --- .../tests/transport_policy.rs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/apps/desktop/distribution-transport/tests/transport_policy.rs b/apps/desktop/distribution-transport/tests/transport_policy.rs index 78287020a..01e246ce2 100644 --- a/apps/desktop/distribution-transport/tests/transport_policy.rs +++ b/apps/desktop/distribution-transport/tests/transport_policy.rs @@ -1,5 +1,5 @@ use bandscope_distribution_download::DownloadAdmissionError; -use bandscope_distribution_runtime::admit_untrusted_raw_json; +use bandscope_distribution_runtime::{admit_untrusted_raw_json, MetadataError}; use bandscope_distribution_transport::{ ReleaseTransportPolicy, ResponseDecision, TransportDownloadError, TransportPolicyError, }; @@ -42,16 +42,13 @@ fn scratch_dir(label: &str) -> std::path::PathBuf { } #[test] -fn malformed_tauri_signature_envelope_is_rejected_before_network_admission() { - let metadata = admit_untrusted_raw_json( - &updater_document_with_signature("not-base64!"), - "windows-x86_64", - ) - .expect("metadata syntax alone remains provisional"); - +fn malformed_tauri_signature_envelope_is_rejected_by_metadata_owner() { assert_eq!( - ReleaseTransportPolicy::from_provisional(&metadata), - Err(TransportPolicyError::InvalidArtifactSignatureEnvelope) + admit_untrusted_raw_json( + &updater_document_with_signature("not-base64!"), + "windows-x86_64", + ), + Err(MetadataError::InvalidSignature) ); } From 89f97a23cfe8b2d6c7125a2a4f39bda21e5ebebe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 19:13:49 +0900 Subject: [PATCH 205/308] docs(distribution): make metadata owner authoritative for signature envelopes --- docs/traceability/updater-transport-policy.md | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/docs/traceability/updater-transport-policy.md b/docs/traceability/updater-transport-policy.md index c3c1f0e96..781f3a053 100644 --- a/docs/traceability/updater-transport-policy.md +++ b/docs/traceability/updater-transport-policy.md @@ -8,7 +8,7 @@ BandScope already has a strict provisional updater-metadata parser and a bounded 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. +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. @@ -16,9 +16,10 @@ Updater signatures and SHA-256 evidence are defined over the exact published art - 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. -- Require the selected Tauri signature to be canonical RFC 4648 standard base64 before any network request. This validates only the outer encoding contract, not the decoded minisign structure or cryptographic signature. +- `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 an HTTP client or base64 dependency merely to express deterministic policy; the Rust envelope check is dependency-free and publication uses Python's standard library. +- 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. @@ -30,17 +31,17 @@ Updater signatures and SHA-256 evidence are defined over the exact published art ## 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 this small 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. +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 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. Reimplementing minisign verification was also rejected: Tauri remains the signature-verification owner, and BandScope only mirrors the documented outer transport envelope needed to fail earlier. +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-transport` is a small Rust owner between `distribution-runtime` and `distribution-download`. +`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. -`ReleaseTransportPolicy::from_provisional` copies only the already-selected target projection: canonical initial URL, safe artifact basename, declared byte size, SHA-256 and Tauri signature. Before response admission it validates that signature as canonical standard base64, including padding placement and zero pad bits. `admit_initial_response` accepts exact-URL `200`, or returns an explicit one-hop redirect decision for an admitted GitHub release-asset CDN location. `admit_redirect_response` requires that the second request terminate in `200` at that exact 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. +`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. `admit_redirect_response` requires that the second request terminate in `200` at that exact 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. @@ -52,16 +53,18 @@ The transport API intentionally contains no socket/client, JSON parser, installe - `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 a clean transport RED proving that syntactically admitted `not-base64!` metadata must not reach network response admission. `6e5e42f2a20001009330c438178afa1ca811ab51` added the dependency-free canonical-base64 envelope guard. +- `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. That head is RED against the predecessor implementation because the required third argument and error variant do not exist yet. -- `606095ec6f2ae9b5d22a777f70806dc79baa8f36` is the causal repair: `AdmittedDownloadHead::start_staging` now rejects every supplied content coding except case-insensitive `identity` before byte-count admission or filesystem mutation. +- `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. 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 provisional metadata projection, signature envelope, HTTP status, HTTP client's effective URL, redirect `Location`, `Content-Encoding`, `Content-Length` and response chunks. The policy uses canonical outer-base64 admission before network work, exact URL equality before body admission, a bounded redirect string, HTTPS exact-origin pinning for the admitted CDN hop, 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 here. Cancel/error cleanup continues to be owned by `distribution-download`. +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, 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. From eb29922a3f119a22a72166e640318e21ce3b7629 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 19:14:55 +0900 Subject: [PATCH 206/308] test(distribution): cover signature envelope padding bounds --- .../tests/signature_envelope.rs | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/apps/desktop/distribution-runtime/tests/signature_envelope.rs b/apps/desktop/distribution-runtime/tests/signature_envelope.rs index e6b5f2da2..73a57c1c7 100644 --- a/apps/desktop/distribution-runtime/tests/signature_envelope.rs +++ b/apps/desktop/distribution-runtime/tests/signature_envelope.rs @@ -1,4 +1,6 @@ -use bandscope_distribution_runtime::{admit_untrusted_raw_json, MetadataError}; +use bandscope_distribution_runtime::{ + admit_untrusted_raw_json, MetadataError, MAX_SIGNATURE_BYTES, +}; const SOURCE_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; const DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; @@ -17,3 +19,38 @@ fn malformed_nonselected_signature_fails_at_metadata_owner() { 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) + ); +} From a62b9ca3a418d1feb8686a444730dc6cf473ba50 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 19:16:05 +0900 Subject: [PATCH 207/308] docs(architecture): align updater signature-envelope ownership --- ARCHITECTURE.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 33ab1b84a..63eea8415 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -59,8 +59,8 @@ Last updated: 2026-09-15 - `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; returns provisional metadata with the selected target's exact admitted URL/signature from the same strict parse and cannot mutate freshness state -- `apps/desktop/distribution-transport` - deterministic Rust response-state bridge from the strict provisional target projection to bounded updater staging; owns canonical outer updater-signature envelope admission, exact effective-URL checks and one-hop release-asset redirect admission, but not sockets, TLS, metadata authentication, minisign verification or installation +- `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 @@ -72,8 +72,8 @@ Last updated: 2026-09-15 - 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 and invalid release-identity syntax; 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`. Before any network response is admitted it requires the selected Tauri signature field to be canonical RFC 4648 standard base64, including canonical padding bits; this validates only Tauri's outer textual signature envelope and does not parse or cryptographically verify minisign. 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. +- `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. From 26ff403041958c433239a2359e6cfc32a2b633b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 20:05:05 +0900 Subject: [PATCH 208/308] test(distribution): use admitted signature envelopes --- .../distribution-runtime/tests/provisional_artifact.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/desktop/distribution-runtime/tests/provisional_artifact.rs b/apps/desktop/distribution-runtime/tests/provisional_artifact.rs index 9dfe8a208..1015ca4a0 100644 --- a/apps/desktop/distribution-runtime/tests/provisional_artifact.rs +++ b/apps/desktop/distribution-runtime/tests/provisional_artifact.rs @@ -2,10 +2,14 @@ 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":"sig-win-x64","url":"https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-windows-x86_64.zip"}},"windows-aarch64":{{"signature":"sig-win-arm64","url":"https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-windows-aarch64.zip"}},"darwin-x86_64":{{"signature":"sig-mac-x64","url":"https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-darwin-x86_64.tar.gz"}},"darwin-aarch64":{{"signature":"sig-mac-arm64","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}"}}}}}}}}"# + 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() } @@ -21,5 +25,5 @@ fn selected_transport_fields_remain_bound_to_strict_admission() { metadata.artifact_url(), "https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-darwin-aarch64.tar.gz" ); - assert_eq!(metadata.artifact_signature(), "sig-mac-arm64"); + assert_eq!(metadata.artifact_signature(), DARWIN_AARCH64_SIGNATURE); } From 11a5a47784a405e5cad973d3c40aa8fe18b40940 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 20:06:08 +0900 Subject: [PATCH 209/308] test(distribution): bind redirect decisions to policy identity --- .../tests/transport_policy.rs | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/desktop/distribution-transport/tests/transport_policy.rs b/apps/desktop/distribution-transport/tests/transport_policy.rs index 01e246ce2..f109805ab 100644 --- a/apps/desktop/distribution-transport/tests/transport_policy.rs +++ b/apps/desktop/distribution-transport/tests/transport_policy.rs @@ -22,12 +22,19 @@ fn updater_document() -> Vec { updater_document_with_signature("c2ln") } -fn policy() -> ReleaseTransportPolicy { - let metadata = admit_untrusted_raw_json(&updater_document(), "windows-x86_64") - .expect("fixture must satisfy provisional metadata admission"); +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) @@ -85,6 +92,24 @@ fn github_release_redirect_is_one_hop_and_streams_through_bounded_staging() { 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(); From 35b851e4724ca625351a14df80f78e121a4f3d6a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 20:06:45 +0900 Subject: [PATCH 210/308] fix(distribution): bind redirect state to policy identity --- .../desktop/distribution-transport/src/lib.rs | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/apps/desktop/distribution-transport/src/lib.rs b/apps/desktop/distribution-transport/src/lib.rs index 10cdd3370..3b0d12866 100644 --- a/apps/desktop/distribution-transport/src/lib.rs +++ b/apps/desktop/distribution-transport/src/lib.rs @@ -45,6 +45,8 @@ pub enum TransportPolicyError { 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. @@ -69,6 +71,9 @@ pub enum TransportDownloadError { pub struct AdmittedRedirect { source_url: String, location: String, + expected_size_bytes: u64, + expected_artifact_sha256: String, + artifact_signature: String, } impl fmt::Debug for AdmittedRedirect { @@ -259,6 +264,9 @@ impl ReleaseTransportPolicy { 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)), @@ -267,15 +275,26 @@ impl ReleaseTransportPolicy { /// Admit the response produced by one previously admitted redirect. /// - /// A second redirect is never followed. Only a final `200` at the exact - /// admitted Location can expose a body to `distribution-download`. + /// 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 || effective_url != redirect.location { + 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) { From 3f7daa8c9a2b70942f9442f5cc10faf20486a8fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 20:07:30 +0900 Subject: [PATCH 211/308] docs(distribution): trace redirect policy binding repair --- docs/traceability/updater-transport-policy.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/traceability/updater-transport-policy.md b/docs/traceability/updater-transport-policy.md index 781f3a053..54ba2a8d1 100644 --- a/docs/traceability/updater-transport-policy.md +++ b/docs/traceability/updater-transport-policy.md @@ -12,6 +12,8 @@ Tauri's updater CLI writes the textual minisign signature box as standard-base64 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. @@ -23,6 +25,7 @@ Updater signatures and SHA-256 evidence are defined over the exact published art - 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. @@ -33,6 +36,8 @@ Updater signatures and SHA-256 evidence are defined over the exact published art 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. @@ -41,7 +46,7 @@ Deferring all signature syntax checking to Tauri's post-download verifier was re `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. `admit_redirect_response` requires that the second request terminate in `200` at that exact 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. +`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. @@ -59,12 +64,15 @@ The transport API intentionally contains no socket/client, JSON parser, installe - `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, 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`. +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. From a418640ae4c3775f967bab695422c77ee3d6f32e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 21:04:03 +0900 Subject: [PATCH 212/308] test(release): reject same-size identity mutation during read --- .../test_release_identity_file_admission.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/services/analysis-engine/tests/test_release_identity_file_admission.py b/services/analysis-engine/tests/test_release_identity_file_admission.py index 42bd125f5..6d89e60b5 100644 --- a/services/analysis-engine/tests/test_release_identity_file_admission.py +++ b/services/analysis-engine/tests/test_release_identity_file_admission.py @@ -52,6 +52,38 @@ def test_release_identity_rejects_nonstandard_json_constants( verifier.verify_release_identity(tmp_path) +def test_release_identity_rejects_same_size_mutation_during_descriptor_read( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A same-length rewrite during admission must not yield a mixed file snapshot.""" + verifier = load_module( + "scripts/checks/verify_release_identity.py", + "verify_release_identity_same_size_mutation", + ) + _write_minimal_identity_tree(tmp_path) + package_path = tmp_path / "package.json" + original = b'{"version":"1.2.3"}\n' + replacement = b'{"version":"9.9.9"}\n' + assert len(original) == len(replacement) + + real_read = verifier.os.read + mutated = False + + def mutate_after_first_package_read(descriptor: int, size: int) -> bytes: + nonlocal mutated + chunk = real_read(descriptor, size) + if not mutated and chunk == original: + package_path.write_bytes(replacement) + mutated = True + return chunk + + monkeypatch.setattr(verifier.os, "read", mutate_after_first_package_read) + + with pytest.raises(ValueError, match="changed while being read"): + verifier.verify_release_identity(tmp_path) + assert mutated + + def test_release_identity_rejects_symlinked_version_authority(tmp_path: Path) -> None: """VERSION must be the repository file itself rather than a followed link.""" verifier = load_module( From 438dca03c6cfcd2f1d2a1fd6084483d98879270f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 21:04:40 +0900 Subject: [PATCH 213/308] fix(release): verify stable identity bytes across descriptor reads --- scripts/checks/verify_release_identity.py | 53 +++++++++++++++-------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/scripts/checks/verify_release_identity.py b/scripts/checks/verify_release_identity.py index a2fd15378..a347a6593 100644 --- a/scripts/checks/verify_release_identity.py +++ b/scripts/checks/verify_release_identity.py @@ -4,10 +4,10 @@ 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. +- VERSION and JSON projections are read twice from the same bounded regular + non-link file descriptor; both byte snapshots plus descriptor identity/size + must remain stable, and 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 @@ -102,25 +102,42 @@ def _read_bounded_regular_text( 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) + def read_snapshot() -> bytes: + chunks: list[bytes] = [] + remaining = maximum_bytes + 1 + while remaining > 0: + chunk = os.read(descriptor, min(64 * 1024, remaining)) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + payload = b"".join(chunks) + if len(payload) > maximum_bytes: + raise ValueError(f"{label} exceeds its bounded size policy") + return payload + + payload = read_snapshot() + after_first = os.fstat(descriptor) if ( (before.st_dev, before.st_ino, before.st_size) - != (after.st_dev, after.st_ino, after.st_size) + != (after_first.st_dev, after_first.st_ino, after_first.st_size) or len(payload) != before.st_size ): raise ValueError(f"{label} changed while being read") + + try: + os.lseek(descriptor, 0, os.SEEK_SET) + except OSError as seek_error: + raise ValueError(f"{label} changed while being read") from seek_error + verification_payload = read_snapshot() + after_second = os.fstat(descriptor) + if ( + (before.st_dev, before.st_ino, before.st_size) + != (after_second.st_dev, after_second.st_ino, after_second.st_size) + or len(verification_payload) != before.st_size + or verification_payload != payload + ): + raise ValueError(f"{label} changed while being read") try: return payload.decode("utf-8") except UnicodeError as decode_error: From 24f8135495ac0900951adaa00d9b793f23601036 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 21:05:23 +0900 Subject: [PATCH 214/308] docs(release): trace stable descriptor snapshot admission --- docs/traceability/release-version-identity.md | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/traceability/release-version-identity.md b/docs/traceability/release-version-identity.md index b17182958..b466d8898 100644 --- a/docs/traceability/release-version-identity.md +++ b/docs/traceability/release-version-identity.md @@ -10,13 +10,17 @@ Fresh review then found a separate file-admission problem in the same release ga A further parser-alignment review found that Python's `json.loads()` also accepts the JavaScript-style numeric constants `NaN`, `Infinity`, and `-Infinity` by default. RFC 8259 explicitly excludes those values from the JSON number grammar. A release projection containing the correct `version` plus one of those constants could therefore pass BandScope preflight while remaining invalid JSON for stricter package, signing, or release consumers. Release identity admission must reject parser extensions that are outside the interchange format rather than treating Python's permissive decoder as the format authority. +The descriptor repair still had one concurrency hole. `_read_bounded_regular_text()` compared descriptor identity and file length before and after one read, but a writer could replace bytes in place without changing inode or length. A same-size rewrite of `package.json` immediately after the read could therefore leave preflight holding the old bytes while the repository path already exposed different release metadata. Size stability is not content stability. + ## 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. +Release identity inputs are admitted from one opened descriptor each. `VERSION` is capped at 128 bytes; repository JSON projections are capped at 256 KiB. The opened object must be a regular file, its path must resolve to the same non-link file identity at admission, and descriptor device/inode/size must remain stable. The guard now reads the bounded descriptor twice from offset zero and requires byte-for-byte equality between the two snapshots as well as exact length agreement with the descriptor. 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. + +The double read is deliberately small and deterministic: the largest projection is 256 KiB, there is no network or subprocess boundary, and release preflight runs before packaging rather than in a latency-sensitive product path. It detects same-size in-place mutation during admission without adding a new lock owner or relying on filesystem timestamp granularity. JSON decoding also supplies an explicit `parse_constant` rejection hook. `NaN`, `Infinity`, and `-Infinity` therefore fail as malformed release metadata instead of entering the object graph as Python floating-point extensions. This keeps the gate aligned with RFC 8259 and with stricter downstream JSON consumers while preserving the existing duplicate-member error path. @@ -30,6 +34,8 @@ JSON decoding also supplies an explicit `parse_constant` rejection hook. `NaN`, - Causal file-admission fix `42d34b5e0b0bb01ebc8c6801552e10b9857ab1f0` replaces pathname reads with bounded descriptor reads, rejects non-regular/link identities, verifies descriptor identity/size stability, and rejects duplicate JSON members before projection comparison. - Strict-JSON RED `68f1bb5531879b8f75d08f1a7e0db84f0b4a36c6` adds `NaN`, `Infinity`, and `-Infinity` fixtures alongside an otherwise-correct release version. Python's default decoder accepts all three even though they are not valid JSON numbers. - Causal strict-JSON fix `494aa0f5d0b966a1a6e2c5fcc64ab5655c56d5d0` supplies an explicit `parse_constant` rejection hook so non-standard constants fail before any release version projection is consumed. +- Same-size mutation RED `a418640ae4c3775f967bab695422c77ee3d6f32e` rewrites `package.json` from `1.2.3` to `9.9.9` immediately after the first descriptor read while preserving byte length. The predecessor identity/size-only check can return the old bytes even though the repository file has already changed. +- Causal snapshot fix `438dca03c6cfcd2f1d2a1fd6084483d98879270f` performs a second bounded read on the same descriptor from offset zero and requires exact byte equality and stable descriptor identity/size before decoding. - The checked-in current authority remains `0.1.3`; these repairs change future admission, not the identity of the current source tree. ## Alternatives rejected @@ -54,9 +60,17 @@ Rejected. Git can represent symlinks, and JSON duplicate-member behavior is pars 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 +### Read once and compare only inode and size + +Rejected. Same-length in-place writes leave inode and size unchanged. The release gate must know that the bytes it parsed remained stable while it admitted them, not merely that the same file object retained the same length. + +### Use timestamps as the content-stability authority + +Rejected. Filesystem timestamp precision and update semantics vary by platform, and timestamps are metadata rather than the release bytes themselves. Two bounded reads of at most 256 KiB compare the actual content with negligible release-time cost. + +### Add a long-lived repository lock -Rejected. 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. +Rejected. Release preflight does not own Git checkout mutation or the entire packaging transaction. A new lock would create a second repository writer protocol and still would not make later consumers immutable. The guard instead proves stability for its own bounded read boundary and leaves post-gate repository immutability to the release workflow/commit identity contract. ### Validate only in the updater-manifest builder @@ -68,7 +82,7 @@ Rejected. Prerelease precedence and build metadata semantics would then differ a ## 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. +This repair proves that repository-controlled stable release preflight and the native updater decision core agree on version syntax and numeric component range, and that the three release-identity projection files are admitted through bounded, non-link, duplicate-rejecting, strict-standard-JSON descriptors whose two read snapshots must agree byte-for-byte. It does not make the entire checked-out repository immutable against a privileged local actor after the gate completes, prevent a later post-gate checkout mutation, authenticate remote updater metadata, verify updater signatures, provision signing authority, prove packaged update/recovery behavior, or make the current blocked updater/model policies commercially releasable. Hosted exact-head CI and independent review remain required before merge. Version/file-admission agreement does not substitute for Windows/macOS signing, updater-key authority, immutable release publication, or rights-cleared real-audio scientific acceptance. @@ -80,4 +94,4 @@ Preston-Werner, T. (n.d.). *Semantic Versioning 2.0.0*. https://semver.org/spec/ 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. +Python Software Foundation. (2026). *os — Miscellaneous operating system interfaces: `open`, `fstat`, `lseek`, and `lstat`*. Python 3 standard library documentation. From f0b38c0a1b880afc6a219a05195301ba7fc477ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 22:06:21 +0900 Subject: [PATCH 215/308] test(release): reject path replacement during identity read --- .../test_release_identity_file_admission.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/services/analysis-engine/tests/test_release_identity_file_admission.py b/services/analysis-engine/tests/test_release_identity_file_admission.py index 6d89e60b5..c85d7132f 100644 --- a/services/analysis-engine/tests/test_release_identity_file_admission.py +++ b/services/analysis-engine/tests/test_release_identity_file_admission.py @@ -84,6 +84,38 @@ def mutate_after_first_package_read(descriptor: int, size: int) -> bytes: assert mutated +def test_release_identity_rejects_path_replacement_during_descriptor_read( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A stable old descriptor must not authorize a path replaced during admission.""" + verifier = load_module( + "scripts/checks/verify_release_identity.py", + "verify_release_identity_path_replacement", + ) + _write_minimal_identity_tree(tmp_path) + package_path = tmp_path / "package.json" + original = b'{"version":"1.2.3"}\n' + replacement_path = tmp_path / "replacement-package.json" + replacement_path.write_bytes(b'{"version":"9.9.9"}\n') + + real_read = verifier.os.read + replaced = False + + def replace_after_first_package_read(descriptor: int, size: int) -> bytes: + nonlocal replaced + chunk = real_read(descriptor, size) + if not replaced and chunk == original: + replacement_path.replace(package_path) + replaced = True + return chunk + + monkeypatch.setattr(verifier.os, "read", replace_after_first_package_read) + + with pytest.raises(ValueError, match="changed while being read"): + verifier.verify_release_identity(tmp_path) + assert replaced + + def test_release_identity_rejects_symlinked_version_authority(tmp_path: Path) -> None: """VERSION must be the repository file itself rather than a followed link.""" verifier = load_module( From 906b4f42f065dd92c9d8274f4e198f5a3d33ed49 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 22:06:56 +0900 Subject: [PATCH 216/308] fix(release): revalidate identity path after stable read --- scripts/checks/verify_release_identity.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/scripts/checks/verify_release_identity.py b/scripts/checks/verify_release_identity.py index a347a6593..8ba6f159a 100644 --- a/scripts/checks/verify_release_identity.py +++ b/scripts/checks/verify_release_identity.py @@ -6,8 +6,10 @@ reads only the fixed ``VERSION``, ``package.json``, and Tauri configuration. - VERSION and JSON projections are read twice from the same bounded regular non-link file descriptor; both byte snapshots plus descriptor identity/size - must remain stable, and JSON duplicate members and non-standard numeric - constants are rejected before any version value is compared. + must remain stable, and the repository path is revalidated against that same + descriptor after the stable read before JSON/version values are trusted. +- JSON duplicate members and non-standard numeric constants are rejected before + any version value is compared. - The CLI composes the sibling Distribution model-policy and updater-policy guards. Normal branch/PR checks validate both policies; version-tag checks additionally require exact commercially admitted model and updater release authority before @@ -138,6 +140,19 @@ def read_snapshot() -> bytes: or verification_payload != payload ): raise ValueError(f"{label} changed while being read") + + try: + final_path_identity = os.lstat(path) + except OSError as identity_error: + raise ValueError(f"{label} changed while being read") from identity_error + if ( + stat.S_ISLNK(final_path_identity.st_mode) + or not stat.S_ISREG(final_path_identity.st_mode) + or (final_path_identity.st_dev, final_path_identity.st_ino) + != (after_second.st_dev, after_second.st_ino) + ): + raise ValueError(f"{label} changed while being read") + try: return payload.decode("utf-8") except UnicodeError as decode_error: From 9671144c884fe8aa1b4bb3271be0a79d7a9e8630 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 22:08:12 +0900 Subject: [PATCH 217/308] docs(traceability): bind release reads back to repository paths --- docs/traceability/release-version-identity.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/traceability/release-version-identity.md b/docs/traceability/release-version-identity.md index b466d8898..cfb548023 100644 --- a/docs/traceability/release-version-identity.md +++ b/docs/traceability/release-version-identity.md @@ -12,15 +12,17 @@ A further parser-alignment review found that Python's `json.loads()` also accept The descriptor repair still had one concurrency hole. `_read_bounded_regular_text()` compared descriptor identity and file length before and after one read, but a writer could replace bytes in place without changing inode or length. A same-size rewrite of `package.json` immediately after the read could therefore leave preflight holding the old bytes while the repository path already exposed different release metadata. Size stability is not content stability. +The double-read repair closed that in-place mutation class but left a separate pathname race. After the initial `lstat()` matched the opened descriptor, another writer could atomically replace the repository path with a different regular file. The still-open descriptor would continue returning the original bytes twice with stable inode and size, so preflight could authorize the old file while `package.json` at the repository path already named a different inode and different release metadata. Descriptor stability alone is not enough when the contract names a repository path as the release projection. + ## Decision `verify_release_identity.py` is the release-pipeline version gate because `package_desktop_artifact.py` invokes release preflight before creating release artifacts. Stable-channel `VERSION` must match exact numeric `MAJOR.MINOR.PATCH`; each component is `0` or a non-zero decimal without leading zeros and must also fit the same unsigned 64-bit range consumed by `distribution-core::StableVersion`. The Python guard compares decimal text against the exact `u64::MAX` decimal boundary instead of converting arbitrary-length input to Python integers. This keeps the accepted domain explicit and avoids a second numeric interpretation. The rule intentionally does not broaden the runtime to prerelease/build SemVer. A future beta/prerelease channel requires a separate release decision and one canonical ordering implementation. -Release identity inputs are admitted from one opened descriptor each. `VERSION` is capped at 128 bytes; repository JSON projections are capped at 256 KiB. The opened object must be a regular file, its path must resolve to the same non-link file identity at admission, and descriptor device/inode/size must remain stable. The guard now reads the bounded descriptor twice from offset zero and requires byte-for-byte equality between the two snapshots as well as exact length agreement with the descriptor. 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. +Release identity inputs are admitted from one opened descriptor each. `VERSION` is capped at 128 bytes; repository JSON projections are capped at 256 KiB. The opened object must be a regular file, its path must resolve to the same non-link file identity at admission, and descriptor device/inode/size must remain stable. The guard reads the bounded descriptor twice from offset zero and requires byte-for-byte equality between the two snapshots as well as exact length agreement with the descriptor. After that stable read, it `lstat()`s the repository path again and requires the path to still name the same regular non-link device/inode as the descriptor before decoding any release value. JSON decoding uses an object-pairs hook that rejects duplicate members before `version` is read. -The double read is deliberately small and deterministic: the largest projection is 256 KiB, there is no network or subprocess boundary, and release preflight runs before packaging rather than in a latency-sensitive product path. It detects same-size in-place mutation during admission without adding a new lock owner or relying on filesystem timestamp granularity. +The double read plus final pathname identity check is deliberately small and deterministic: the largest projection is 256 KiB, there is no network or subprocess boundary, and release preflight runs before packaging rather than in a latency-sensitive product path. It detects same-size in-place mutation and path replacement during admission without adding a new lock owner or relying on filesystem timestamp granularity. JSON decoding also supplies an explicit `parse_constant` rejection hook. `NaN`, `Infinity`, and `-Infinity` therefore fail as malformed release metadata instead of entering the object graph as Python floating-point extensions. This keeps the gate aligned with RFC 8259 and with stricter downstream JSON consumers while preserving the existing duplicate-member error path. @@ -36,6 +38,8 @@ JSON decoding also supplies an explicit `parse_constant` rejection hook. `NaN`, - Causal strict-JSON fix `494aa0f5d0b966a1a6e2c5fcc64ab5655c56d5d0` supplies an explicit `parse_constant` rejection hook so non-standard constants fail before any release version projection is consumed. - Same-size mutation RED `a418640ae4c3775f967bab695422c77ee3d6f32e` rewrites `package.json` from `1.2.3` to `9.9.9` immediately after the first descriptor read while preserving byte length. The predecessor identity/size-only check can return the old bytes even though the repository file has already changed. - Causal snapshot fix `438dca03c6cfcd2f1d2a1fd6084483d98879270f` performs a second bounded read on the same descriptor from offset zero and requires exact byte equality and stable descriptor identity/size before decoding. +- Path-replacement RED `f0b38c0a1b880afc6a219a05195301ba7fc477ba` atomically replaces `package.json` with a different regular inode immediately after the first descriptor read. The predecessor double-read sees two identical snapshots from the still-open old descriptor and can therefore authorize bytes no longer named by the repository path. +- Causal path-identity fix `906b4f42f065dd92c9d8274f4e198f5a3d33ed49` revalidates the repository path after the stable second read and requires it to still be a regular non-link file with the same device/inode as the admitted descriptor. - The checked-in current authority remains `0.1.3`; these repairs change future admission, not the identity of the current source tree. ## Alternatives rejected @@ -64,6 +68,10 @@ Rejected. RFC 8259 does not permit `NaN` or infinities as JSON numbers. Allowing Rejected. Same-length in-place writes leave inode and size unchanged. The release gate must know that the bytes it parsed remained stable while it admitted them, not merely that the same file object retained the same length. +### Trust a stable descriptor without rechecking the named path + +Rejected. Atomic pathname replacement does not change the already-open descriptor. Two identical reads can therefore prove the old file is stable while saying nothing about whether the repository path still names that file. A bounded final `lstat()` ties the admitted bytes back to the release projection path without reopening and parsing a second file object. + ### Use timestamps as the content-stability authority Rejected. Filesystem timestamp precision and update semantics vary by platform, and timestamps are metadata rather than the release bytes themselves. Two bounded reads of at most 256 KiB compare the actual content with negligible release-time cost. @@ -82,7 +90,7 @@ Rejected. Prerelease precedence and build metadata semantics would then differ a ## Claim boundary -This repair proves that repository-controlled stable release preflight and the native updater decision core agree on version syntax and numeric component range, and that the three release-identity projection files are admitted through bounded, non-link, duplicate-rejecting, strict-standard-JSON descriptors whose two read snapshots must agree byte-for-byte. It does not make the entire checked-out repository immutable against a privileged local actor after the gate completes, prevent a later post-gate checkout mutation, authenticate remote updater metadata, verify updater signatures, provision signing authority, prove packaged update/recovery behavior, or make the current blocked updater/model policies commercially releasable. +This repair proves that repository-controlled stable release preflight and the native updater decision core agree on version syntax and numeric component range, and that the three release-identity projection files are admitted through bounded, non-link, duplicate-rejecting, strict-standard-JSON descriptors whose two read snapshots must agree byte-for-byte and whose repository paths must still name the same descriptor identity at the end of admission. It does not make the entire checked-out repository immutable against a privileged local actor after the gate completes, prevent a later post-gate checkout mutation, authenticate remote updater metadata, verify updater signatures, provision signing authority, prove packaged update/recovery behavior, or make the current blocked updater/model policies commercially releasable. Hosted exact-head CI and independent review remain required before merge. Version/file-admission agreement does not substitute for Windows/macOS signing, updater-key authority, immutable release publication, or rights-cleared real-audio scientific acceptance. From e25fd44daa62f994d40a378de8c06a87eed5fb86 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 23:06:28 +0900 Subject: [PATCH 218/308] test(distribution): reject concurrent freshness-state writer --- .../tests/concurrent_writer.rs | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 apps/desktop/distribution-state/tests/concurrent_writer.rs diff --git a/apps/desktop/distribution-state/tests/concurrent_writer.rs b/apps/desktop/distribution-state/tests/concurrent_writer.rs new file mode 100644 index 000000000..3bba329a4 --- /dev/null +++ b/apps/desktop/distribution-state/tests/concurrent_writer.rs @@ -0,0 +1,83 @@ +//! Cross-process lease contract for Distribution highest-seen freshness state. + +use bandscope_distribution_core::ReleaseIdentity; +use bandscope_distribution_state::{ + load_highest_seen, remember_highest_seen, RememberOutcome, StateError, +}; +use std::fs::OpenOptions; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +const SOURCE: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const STATE_LEASE_FILE_NAME: &str = ".bandscope-highest-seen.lock"; +static NEXT_TEST_ID: AtomicU64 = AtomicU64::new(1); + +struct TestDirectory(PathBuf); + +impl TestDirectory { + fn new() -> Self { + let id = NEXT_TEST_ID.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "bandscope-distribution-state-lease-{}-{id}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("test directory should be created"); + Self(path) + } + + fn state_path(&self) -> PathBuf { + self.0.join("highest-seen.log") + } +} + +impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn lease_path(state_path: &Path) -> PathBuf { + state_path + .parent() + .expect("test state has a parent") + .join(STATE_LEASE_FILE_NAME) +} + +fn identity(version: &str) -> ReleaseIdentity { + ReleaseIdentity::new(version, SOURCE, DIGEST).expect("fixture identity should be valid") +} + +#[test] +fn active_cross_process_lease_blocks_stale_read_and_competing_append() { + let directory = TestDirectory::new(); + let state_path = directory.state_path(); + let lease_path = lease_path(&state_path); + let lease = OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(&lease_path) + .expect("lease fixture should open"); + lease.try_lock().expect("fixture should own the state lease"); + + assert_eq!( + load_highest_seen(&state_path), + Err(StateError::ConcurrentMutation) + ); + assert_eq!( + remember_highest_seen(&state_path, &identity("1.0.0")), + Err(StateError::ConcurrentMutation) + ); + assert!( + !state_path.exists(), + "competing writer must not create freshness state while the lease is held" + ); + + drop(lease); + assert_eq!( + remember_highest_seen(&state_path, &identity("1.0.0")), + Ok(RememberOutcome::Remembered) + ); + assert_eq!(load_highest_seen(&state_path), Ok(Some(identity("1.0.0")))); +} From dd2ae7f45468ad06bf50a83d279dcb8facf1d783 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 23:07:30 +0900 Subject: [PATCH 219/308] fix(distribution): serialize highest-seen state access --- apps/desktop/distribution-state/src/lib.rs | 58 ++++++++++++++++++---- 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/apps/desktop/distribution-state/src/lib.rs b/apps/desktop/distribution-state/src/lib.rs index a8f5105e1..7bb67b419 100644 --- a/apps/desktop/distribution-state/src/lib.rs +++ b/apps/desktop/distribution-state/src/lib.rs @@ -9,7 +9,7 @@ #![forbid(unsafe_code)] use bandscope_distribution_core::ReleaseIdentity; -use std::fs::{File, OpenOptions}; +use std::fs::{File, OpenOptions, TryLockError}; use std::io::{Read, Write}; use std::path::Path; @@ -18,6 +18,7 @@ pub const MAX_STATE_BYTES: usize = 64 * 1024; const RECORD_PREFIX: &str = "v1|"; const MAX_RECORD_BYTES: usize = 192; +const STATE_LEASE_FILE_NAME: &str = ".bandscope-highest-seen.lock"; /// Successful result of remembering an authenticated release identity. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -43,17 +44,20 @@ pub enum StateError { Replay, /// The same release version was presented with different immutable identity. Equivocation, - /// State bytes changed between admission and append under the single-writer contract. + /// Another process owns the state lease or bytes changed during admission. 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. +/// Access is serialized through a sibling OS file lease so a reader cannot +/// observe a stale highest-seen snapshot while another process is appending a +/// newer authenticated release. A final non-newline-terminated fragment is +/// treated as recoverable only when every byte is a valid prefix of one state +/// record. This is the sole torn-write case accepted. Malformed committed +/// records fail closed rather than silently discarding anti-replay evidence. pub fn load_highest_seen(path: &Path) -> Result, StateError> { + let _lease = acquire_state_lease(path)?; let bytes = read_state_bytes(path)?.unwrap_or_default(); parse_state_bytes(&bytes).map(|parsed| parsed.highest) } @@ -61,14 +65,17 @@ pub fn load_highest_seen(path: &Path) -> Result, StateEr /// 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. +/// authenticity have been established. One sibling OS file lease is held from +/// the first state read through tail repair, append, and synchronization so two +/// app processes cannot append from the same stale snapshot. 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 _lease = acquire_state_lease(path)?; let original = read_state_bytes(path)?; let bytes = original.as_deref().unwrap_or(&[]); let parsed = parse_state_bytes(bytes)?; @@ -132,6 +139,37 @@ struct ParsedState { committed_len: usize, } +fn acquire_state_lease(path: &Path) -> Result { + let parent = path.parent().ok_or(StateError::Io)?; + let lease_path = parent.join(STATE_LEASE_FILE_NAME); + let lease_file = match OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(&lease_path) + { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + let metadata = std::fs::symlink_metadata(&lease_path).map_err(|_| StateError::Io)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(StateError::NotRegularFile); + } + OpenOptions::new() + .read(true) + .write(true) + .open(&lease_path) + .map_err(|_| StateError::Io)? + } + Err(_) => return Err(StateError::Io), + }; + + match lease_file.try_lock() { + Ok(()) => Ok(lease_file), + Err(TryLockError::WouldBlock) => Err(StateError::ConcurrentMutation), + Err(TryLockError::Error(_)) => Err(StateError::Io), + } +} + fn read_state_bytes(path: &Path) -> Result>, StateError> { let metadata = match std::fs::symlink_metadata(path) { Ok(metadata) => metadata, From 027963a58dd0f8084ca239a20e0196bff545c8de Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 23:08:35 +0900 Subject: [PATCH 220/308] docs(distribution): trace highest-seen state lease --- .../updater-highest-seen-concurrency.md | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/traceability/updater-highest-seen-concurrency.md diff --git a/docs/traceability/updater-highest-seen-concurrency.md b/docs/traceability/updater-highest-seen-concurrency.md new file mode 100644 index 000000000..8d905d466 --- /dev/null +++ b/docs/traceability/updater-highest-seen-concurrency.md @@ -0,0 +1,43 @@ +# Updater highest-seen state concurrency traceability + +## 문제 + +`bandscope-distribution-state`의 append-only log는 torn final write와 단일 호출 내부의 길이 변화는 검사했지만, 두 BandScope 프로세스가 같은 freshness state를 동시에 갱신하는 경우를 직렬화하지 않았습니다. 두 writer가 같은 committed snapshot을 읽은 뒤 각각 다음 release record를 append하면, append 자체는 모두 성공할 수 있지만 이후 parser는 동일 version의 두 committed record를 equivocation/corruption으로 거부합니다. 더 위험한 경우에는 reader가 writer의 append 직전 snapshot을 정상 값으로 반환해 anti-replay 판단이 이미 진행 중인 다른 프로세스의 최신 authority보다 뒤처질 수 있습니다. + +이 문제는 네트워크 metadata나 updater signature의 진위와 별개입니다. `distribution-state`가 실제 flow에 연결되는 시점에는 이미 인증된 release identity만 받더라도, 로컬 freshness authority 자체가 다중 프로세스에서 일관되어야 합니다. + +## RED와 수정 + +- RED `e25fd44daa62f994d40a378de8c06a87eed5fb86`은 sibling lease를 다른 handle이 보유한 동안 `load_highest_seen()`과 `remember_highest_seen()`이 진행되면 안 된다는 cross-process contract를 추가했습니다. 선행 구현은 lease를 전혀 확인하지 않아 write를 수행하고 state file까지 만들 수 있었습니다. +- Causal fix `dd2ae7f45468ad06bf50a83d279dcb8facf1d783`은 state file과 같은 directory의 `.bandscope-highest-seen.lock`을 열고 `File::try_lock()`의 exclusive OS lock을 획득한 뒤에만 read/repair/append를 시작합니다. `remember_highest_seen()`은 최초 state read부터 recoverable-tail truncation, append, `sync_all()` 및 final length 확인까지 같은 lease를 유지합니다. `load_highest_seen()`도 같은 lease를 사용해 concurrent writer와 stale read가 겹치지 않게 합니다. +- 기존 `StateError::ConcurrentMutation`을 lease contention에도 사용합니다. 호출자는 이를 freshness authority를 읽거나 갱신할 수 없는 fail-closed 상태로 이미 취급할 수 있으므로 별도 public error family를 만들지 않았습니다. + +## 제약과 대안 + +State log 자체를 lock 대상으로 쓰는 방식은 최초 state file이 아직 없을 때와 torn-tail repair에서 lifecycle이 복잡해져 기각했습니다. Process-local mutex만 두는 방식도 별도 desktop process를 직렬화하지 못하므로 기각했습니다. Blocking lock으로 무기한 대기하는 방식 대신 non-blocking `try_lock()`을 사용합니다. updater freshness mutation은 UI hot path가 아니며, lock contention은 다른 프로세스가 authority를 갱신 중이라는 명시적 상태이므로 bounded failure 후 상위 orchestration에서 재시도 여부를 결정하는 편이 낫습니다. + +Lock file은 crash 뒤에도 남을 수 있지만 lock ownership은 open file handle에 결합되므로 stale pathname 자체를 writer ownership으로 간주하지 않습니다. Existing regular lock file을 다시 열어 OS lock 획득을 시도합니다. Symlink 또는 non-regular lease path는 fail closed합니다. + +## Claim boundary와 위험 + +이 변경은 cooperating BandScope processes의 highest-seen read/write를 직렬화합니다. Advisory file locking을 무시하고 app-local-data directory를 직접 변조할 수 있는 동일 사용자/관리자 프로세스로부터 state를 보호한다고 주장하지 않습니다. Rust 표준 라이브러리도 filesystem operation 전반의 TOCTOU 가능성을 명시하고 있으므로, state/lease pathname 자체의 hostile replacement 문제는 별도 descriptor-relative hardening 대상으로 남습니다. + +`try_lock()`은 2026-09 현재 Rust stable 표준 라이브러리에서 제공되며, 다른 handle/process가 lock을 보유하면 `TryLockError::WouldBlock`을 반환합니다. BandScope가 지원하는 desktop build는 repository의 cross-platform CI에서 이 계약을 다시 실행해야 합니다. 네트워크 filesystem/SMB/NFS는 lock semantics가 달라질 수 있으므로 commercial acceptance는 app-local state가 실제 supported local profile/storage에서 동작하는 조건으로 검증합니다. + +## 효과와 후속조치 + +두 앱 인스턴스가 같은 authenticated release에서 출발해 동일 또는 서로 다른 다음 release를 동시에 append하여 durable log를 자가-corrupt시키는 경로를 닫았습니다. Remote metadata authenticity, updater signature verification, exact sealed-descriptor digest/size binding, verified-artifact promotion이 완료되기 전에는 이 state writer를 production updater flow에 연결하지 않는 기존 trust order는 그대로입니다. + +후속 acceptance는 Windows/macOS에서 실제 두 프로세스 contention, process-kill 직후 lock release, torn tail + contention, power-loss/restart를 packaged build로 검증해야 합니다. Production HTTP adapter와 metadata authentication은 별도 Distribution vertical입니다. + +## Security Notes + +Attack surface는 app-local freshness log와 sibling lease pathname입니다. Lease contention은 성공으로 우회하지 않고 `ConcurrentMutation`으로 fail closed합니다. Lock file에는 release identity, project/audio path, credential, signature나 PII를 기록하지 않습니다. State log의 기존 regular-file/resource/record validation과 append durability는 유지됩니다. + +## 참고문헌 + +Rust Project. (2026). *std::fs: Filesystem manipulation operations*. Rust 1.98.1 standard library documentation. https://doc.rust-lang.org/std/fs/ + +Rust Project. (2026). *TryLockError in std::fs*. Rust 1.98.1 standard library documentation. https://doc.rust-lang.org/std/fs/enum.TryLockError.html + +Kerrisk, M. (2026). *flock(2) — apply or remove an advisory lock on an open file*. Linux man-pages project. https://man7.org/linux/man-pages/man2/flock.2.html From e57864425816959910950052c3f9e40a7cadb8a4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 00:03:53 +0900 Subject: [PATCH 221/308] test(distribution-state): reject impossible torn version prefixes --- .../tests/impossible_torn_tail.rs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 apps/desktop/distribution-state/tests/impossible_torn_tail.rs diff --git a/apps/desktop/distribution-state/tests/impossible_torn_tail.rs b/apps/desktop/distribution-state/tests/impossible_torn_tail.rs new file mode 100644 index 000000000..b5181d575 --- /dev/null +++ b/apps/desktop/distribution-state/tests/impossible_torn_tail.rs @@ -0,0 +1,58 @@ +use bandscope_distribution_state::{load_highest_seen, StateError}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; + +static NEXT_TEST_ID: AtomicU64 = AtomicU64::new(1); + +struct TestDirectory(PathBuf); + +impl TestDirectory { + fn new() -> Self { + let id = NEXT_TEST_ID.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "bandscope-distribution-state-impossible-tail-{}-{id}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("test directory should be created"); + Self(path) + } + + fn state_path(&self) -> PathBuf { + self.0.join("highest-seen.log") + } +} + +impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[test] +fn impossible_u64_version_component_is_not_recoverable_torn_tail() { + let directory = TestDirectory::new(); + let path = directory.state_path(); + + for tail in [ + b"v1|18446744073709551616".as_slice(), + b"v1|1.18446744073709551616".as_slice(), + b"v1|1.2.18446744073709551616".as_slice(), + ] { + std::fs::write(&path, tail).expect("fixture should write"); + assert_eq!(load_highest_seen(&path), Err(StateError::Corrupt)); + } +} + +#[test] +fn still_extendable_version_prefix_remains_recoverable() { + let directory = TestDirectory::new(); + let path = directory.state_path(); + + for tail in [ + b"v1|2.0".as_slice(), + b"v1|18446744073709551615".as_slice(), + ] { + std::fs::write(&path, tail).expect("fixture should write"); + assert_eq!(load_highest_seen(&path), Ok(None)); + } +} From e793d0018db3cc58a0c929485e9fccc8072bd57a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 00:05:16 +0900 Subject: [PATCH 222/308] fix(distribution-state): reject impossible torn version prefixes --- apps/desktop/distribution-state/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/desktop/distribution-state/src/lib.rs b/apps/desktop/distribution-state/src/lib.rs index 7bb67b419..8096bcd66 100644 --- a/apps/desktop/distribution-state/src/lib.rs +++ b/apps/desktop/distribution-state/src/lib.rs @@ -317,6 +317,9 @@ fn is_version_prefix(value: &str) -> bool { if part.is_empty() && index + 1 != parts.len() { return false; } + if !part.is_empty() && part.parse::().is_err() { + return false; + } } true } From e8a064c6a6ff2244988dd36857240859c9dd5278 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 00:05:41 +0900 Subject: [PATCH 223/308] docs(distribution-state): trace impossible torn-tail admission --- .../updater-state-torn-tail-admission.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/traceability/updater-state-torn-tail-admission.md diff --git a/docs/traceability/updater-state-torn-tail-admission.md b/docs/traceability/updater-state-torn-tail-admission.md new file mode 100644 index 000000000..f30972294 --- /dev/null +++ b/docs/traceability/updater-state-torn-tail-admission.md @@ -0,0 +1,49 @@ +# Updater highest-seen torn-tail admission + +## Decision + +BandScope treats a non-newline-terminated highest-seen state tail as recoverable only when every byte can still be extended into a release record that `distribution-core::StableVersion` can admit. + +The state owner therefore applies the same `u64` component bound used by the canonical stable-version parser while checking an incomplete `MAJOR.MINOR.PATCH` prefix. A decimal component that already exceeds `u64::MAX` is corruption, not a recoverable torn write, even when it is only 20 digits long. + +## Problem + +`distribution-state` intentionally distinguishes a recoverable final torn append from committed-state corruption. Before this repair, `is_version_prefix()` checked digit/dot grammar, component count, leading zeroes, and a 20-character component ceiling, but did not verify that a non-empty component could fit the `u64` representation owned by `distribution-core`. + +That admitted impossible prefixes such as `v1|18446744073709551616` as recoverable. `load_highest_seen()` could consequently return `Ok(None)` (or the previous committed identity when a valid record preceded the tail), and the next writer could truncate the impossible tail as crash residue. Because `18446744073709551616` can never become a valid `StableVersion` component, silently repairing it would discard corruption rather than complete an interrupted valid record. + +## RED evidence + +Commit `e57864425816959910950052c3f9e40a7cadb8a4` adds `apps/desktop/distribution-state/tests/impossible_torn_tail.rs`. + +The regression contract covers overflow in each stable-version component: + +- `v1|18446744073709551616` +- `v1|1.18446744073709551616` +- `v1|1.2.18446744073709551616` + +All must return `StateError::Corrupt`. The same test preserves two positive boundaries: `v1|2.0` remains a recoverable partial version, and `v1|18446744073709551615` remains recoverable because the component is exactly `u64::MAX` and can still be extended with the remaining separators/components. + +## Causal fix + +Commit `e793d0018db3cc58a0c929485e9fccc8072bd57a` adds one condition to the existing version-prefix admission loop: every non-empty decimal component must parse as `u64`. + +This keeps the repair in the durable-state owner and reuses the representation already established by `distribution-core`; it does not introduce another SemVer implementation or widen the accepted release grammar. + +## Alternatives considered + +A 20-digit length check alone was rejected because the decimal range `18446744073709551616..=99999999999999999999` is 20 digits but outside `u64` and can never become valid by appending more bytes. + +Treating every non-newline tail as recoverable was rejected because it converts arbitrary state corruption into destructive truncation and weakens anti-replay evidence. + +Calling the complete `StableVersion::parse()` directly was rejected for incomplete forms such as `2.0`, which are intentionally valid crash prefixes but are not complete `MAJOR.MINOR.PATCH` values. Prefix admission therefore remains a separate state-format concern while sharing the canonical numeric bound. + +## Claim boundary + +This repair proves only that an incomplete state tail classified as recoverable is numerically extendable within BandScope's current `u64` stable-version representation. It does not authenticate state bytes, make advisory leases mandatory for non-cooperating processes, or prove packaged process-kill/power-loss behavior. + +The production trust order remains unchanged: authenticated updater metadata and release identity, verified updater artifact signature, exact sealed-descriptor digest/size binding, explicit verified-artifact promotion, anti-replay decision, then durable highest-seen mutation. + +## Follow-up + +Keep the new regression in the exact-head Rust test gate. Packaged Windows/macOS acceptance must still cover process kill, restart, power loss, competing-process lease behavior, and last-known-good rollback after the production updater flow is wired. From b6334829b474a60ba2ad0e7a7d77ec93822b20f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 01:07:21 +0900 Subject: [PATCH 224/308] test(distribution): reject hard-linked freshness state --- .../tests/hard_link_admission.rs | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 apps/desktop/distribution-state/tests/hard_link_admission.rs diff --git a/apps/desktop/distribution-state/tests/hard_link_admission.rs b/apps/desktop/distribution-state/tests/hard_link_admission.rs new file mode 100644 index 000000000..8b167a2e6 --- /dev/null +++ b/apps/desktop/distribution-state/tests/hard_link_admission.rs @@ -0,0 +1,61 @@ +use bandscope_distribution_core::ReleaseIdentity; +use bandscope_distribution_state::{load_highest_seen, remember_highest_seen, StateError}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +const SOURCE: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const DIGEST: &str = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +static NEXT_TEST_ID: AtomicU64 = AtomicU64::new(1); + +struct TestDirectory(PathBuf); + +impl TestDirectory { + fn new() -> Self { + let id = NEXT_TEST_ID.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "bandscope-distribution-state-hard-link-{}-{id}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("test directory should be created"); + Self(path) + } + + fn join(&self, name: &str) -> PathBuf { + self.0.join(name) + } +} + +impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn identity(version: &str) -> ReleaseIdentity { + ReleaseIdentity::new(version, SOURCE, DIGEST).expect("fixture identity should be valid") +} + +fn bytes(path: &Path) -> Vec { + std::fs::read(path).expect("fixture should remain readable") +} + +#[test] +fn hard_linked_state_is_not_accepted_or_mutated() { + let directory = TestDirectory::new(); + let authority_target = directory.join("authority-target.log"); + let state_path = directory.join("highest-seen.log"); + let first = identity("1.0.0"); + let second = identity("2.0.0"); + + remember_highest_seen(&authority_target, &first).expect("fixture state should be created"); + let original = bytes(&authority_target); + std::fs::hard_link(&authority_target, &state_path).expect("hard-link fixture should be created"); + + assert_eq!(load_highest_seen(&state_path), Err(StateError::NotRegularFile)); + assert_eq!( + remember_highest_seen(&state_path, &second), + Err(StateError::NotRegularFile) + ); + assert_eq!(bytes(&authority_target), original); +} From 4f8c9336141456ad9f669f88fa5373aa7902c9ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 01:08:14 +0900 Subject: [PATCH 225/308] fix(distribution): reject hard-linked freshness authority --- apps/desktop/distribution-state/src/lib.rs | 28 ++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/desktop/distribution-state/src/lib.rs b/apps/desktop/distribution-state/src/lib.rs index 8096bcd66..2a5f1b041 100644 --- a/apps/desktop/distribution-state/src/lib.rs +++ b/apps/desktop/distribution-state/src/lib.rs @@ -176,7 +176,10 @@ fn read_state_bytes(path: &Path) -> Result>, StateError> { 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() { + if metadata.file_type().is_symlink() + || !metadata.is_file() + || !has_single_filesystem_link(&metadata) + { return Err(StateError::NotRegularFile); } if metadata.len() as usize > MAX_STATE_BYTES { @@ -185,7 +188,10 @@ fn read_state_bytes(path: &Path) -> Result>, StateError> { 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() { + if !opened.is_file() + || !has_single_filesystem_link(&opened) + || opened.len() != metadata.len() + { return Err(StateError::ConcurrentMutation); } @@ -203,6 +209,24 @@ fn read_state_bytes(path: &Path) -> Result>, StateError> { Ok(Some(bytes)) } +fn has_single_filesystem_link(metadata: &std::fs::Metadata) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + return metadata.nlink() == 1; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + return metadata.number_of_links() == Some(1); + } + #[cfg(not(any(unix, windows)))] + { + let _ = metadata; + false + } +} + fn parse_state_bytes(bytes: &[u8]) -> Result { let committed_len = match bytes.iter().rposition(|byte| *byte == b'\n') { Some(index) => index + 1, From bd694dea014fd1d4576ba0cad918e40f5609ff21 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 01:09:26 +0900 Subject: [PATCH 226/308] docs(distribution): trace hard-link state admission --- .../updater-highest-seen-concurrency.md | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/traceability/updater-highest-seen-concurrency.md b/docs/traceability/updater-highest-seen-concurrency.md index 8d905d466..91bfb404d 100644 --- a/docs/traceability/updater-highest-seen-concurrency.md +++ b/docs/traceability/updater-highest-seen-concurrency.md @@ -6,11 +6,15 @@ 이 문제는 네트워크 metadata나 updater signature의 진위와 별개입니다. `distribution-state`가 실제 flow에 연결되는 시점에는 이미 인증된 release identity만 받더라도, 로컬 freshness authority 자체가 다중 프로세스에서 일관되어야 합니다. +추가 review에서 pathname admission에도 별도 결함이 확인됐습니다. Symlink와 non-regular entry만 거부하면 hard link는 정상 regular file로 보입니다. 공격 또는 잘못된 local-data 조작으로 `highest-seen.log`가 다른 파일과 같은 inode/file record를 공유하면, BandScope의 recoverable-tail truncation이나 monotonic append가 그 다른 pathname이 가리키는 동일 파일 내용까지 변경할 수 있습니다. Anti-replay authority는 app-owned 단일 state object여야 하므로 pre-existing multi-link state는 정상 authority로 인정하지 않습니다. + ## RED와 수정 - RED `e25fd44daa62f994d40a378de8c06a87eed5fb86`은 sibling lease를 다른 handle이 보유한 동안 `load_highest_seen()`과 `remember_highest_seen()`이 진행되면 안 된다는 cross-process contract를 추가했습니다. 선행 구현은 lease를 전혀 확인하지 않아 write를 수행하고 state file까지 만들 수 있었습니다. - Causal fix `dd2ae7f45468ad06bf50a83d279dcb8facf1d783`은 state file과 같은 directory의 `.bandscope-highest-seen.lock`을 열고 `File::try_lock()`의 exclusive OS lock을 획득한 뒤에만 read/repair/append를 시작합니다. `remember_highest_seen()`은 최초 state read부터 recoverable-tail truncation, append, `sync_all()` 및 final length 확인까지 같은 lease를 유지합니다. `load_highest_seen()`도 같은 lease를 사용해 concurrent writer와 stale read가 겹치지 않게 합니다. - 기존 `StateError::ConcurrentMutation`을 lease contention에도 사용합니다. 호출자는 이를 freshness authority를 읽거나 갱신할 수 없는 fail-closed 상태로 이미 취급할 수 있으므로 별도 public error family를 만들지 않았습니다. +- RED `b6334829b474a60ba2ad0e7a7d77ec93822b20f2`는 정상 highest-seen 파일의 hard link를 app-owned state pathname에 만든 뒤, read와 다음-release append가 모두 `StateError::NotRegularFile`로 거부되고 원본 alias bytes가 바뀌지 않아야 한다는 contract를 추가했습니다. 선행 구현은 hard link를 regular file로 받아들여 이 계약을 만족하지 못했습니다. +- Causal fix `4f8c9336141456ad9f669f88fa5373aa7902c9ca`는 state pathname metadata와 실제 opened descriptor metadata 양쪽에서 filesystem link count가 정확히 1인지 확인합니다. Unix는 `MetadataExt::nlink()`, Windows는 `MetadataExt::number_of_links()`를 사용하고, 이 정보를 제공하지 않는 target은 freshness authority를 추측하지 않고 fail closed합니다. ## 제약과 대안 @@ -18,26 +22,32 @@ State log 자체를 lock 대상으로 쓰는 방식은 최초 state file이 아 Lock file은 crash 뒤에도 남을 수 있지만 lock ownership은 open file handle에 결합되므로 stale pathname 자체를 writer ownership으로 간주하지 않습니다. Existing regular lock file을 다시 열어 OS lock 획득을 시도합니다. Symlink 또는 non-regular lease path는 fail closed합니다. +Hard-link alias를 pathname canonicalization으로 찾는 방식은 기각했습니다. Canonical path는 동일 inode의 다른 directory entry 존재 여부를 증명하지 못합니다. App-local tree를 순회해 모든 alias를 찾는 방식도 동일 filesystem 전체를 증명할 수 없고 race가 남습니다. State authority 자체에서 OS metadata의 link count를 검사하는 것이 더 작고 직접적인 invariant입니다. + ## Claim boundary와 위험 -이 변경은 cooperating BandScope processes의 highest-seen read/write를 직렬화합니다. Advisory file locking을 무시하고 app-local-data directory를 직접 변조할 수 있는 동일 사용자/관리자 프로세스로부터 state를 보호한다고 주장하지 않습니다. Rust 표준 라이브러리도 filesystem operation 전반의 TOCTOU 가능성을 명시하고 있으므로, state/lease pathname 자체의 hostile replacement 문제는 별도 descriptor-relative hardening 대상으로 남습니다. +이 변경은 cooperating BandScope processes의 highest-seen read/write를 직렬화하고, admission 시점에 이미 여러 pathname을 가진 state object를 freshness authority로 사용하지 않게 합니다. Advisory file locking을 무시하고 app-local-data directory를 직접 변조할 수 있는 동일 사용자/관리자 프로세스로부터 state를 완전히 보호한다고 주장하지 않습니다. 특히 정상 single-link file이 검사된 뒤 hostile actor가 hard link를 추가하거나 pathname을 교체하는 TOCTOU까지 이번 변경이 제거하지는 않습니다. Rust 표준 라이브러리도 filesystem operation 전반의 TOCTOU 가능성을 명시하므로 descriptor-relative/path-identity hardening과 packaged hostile-race acceptance는 후속 범위입니다. -`try_lock()`은 2026-09 현재 Rust stable 표준 라이브러리에서 제공되며, 다른 handle/process가 lock을 보유하면 `TryLockError::WouldBlock`을 반환합니다. BandScope가 지원하는 desktop build는 repository의 cross-platform CI에서 이 계약을 다시 실행해야 합니다. 네트워크 filesystem/SMB/NFS는 lock semantics가 달라질 수 있으므로 commercial acceptance는 app-local state가 실제 supported local profile/storage에서 동작하는 조건으로 검증합니다. +`try_lock()`은 2026-09 현재 Rust stable 표준 라이브러리에서 제공되며, 다른 handle/process가 lock을 보유하면 `TryLockError::WouldBlock`을 반환합니다. Rust 1.98.1의 Unix `MetadataExt`는 `nlink()`를, Windows `MetadataExt`는 `number_of_links()`를 제공하므로 supported desktop targets에서 pre-existing hard-link alias를 direct metadata로 검사할 수 있습니다. BandScope가 지원하는 desktop build는 repository의 cross-platform CI에서 이 계약을 다시 실행해야 합니다. 네트워크 filesystem/SMB/NFS는 lock/link semantics가 달라질 수 있으므로 commercial acceptance는 app-local state가 실제 supported local profile/storage에서 동작하는 조건으로 검증합니다. ## 효과와 후속조치 -두 앱 인스턴스가 같은 authenticated release에서 출발해 동일 또는 서로 다른 다음 release를 동시에 append하여 durable log를 자가-corrupt시키는 경로를 닫았습니다. Remote metadata authenticity, updater signature verification, exact sealed-descriptor digest/size binding, verified-artifact promotion이 완료되기 전에는 이 state writer를 production updater flow에 연결하지 않는 기존 trust order는 그대로입니다. +두 앱 인스턴스가 같은 authenticated release에서 출발해 동일 또는 서로 다른 다음 release를 동시에 append하여 durable log를 자가-corrupt시키는 경로와, pre-existing hard-linked state pathname을 통해 다른 alias의 bytes를 freshness repair/append가 변경하는 경로를 닫았습니다. Remote metadata authenticity, updater signature verification, exact sealed-descriptor digest/size binding, verified-artifact promotion이 완료되기 전에는 이 state writer를 production updater flow에 연결하지 않는 기존 trust order는 그대로입니다. -후속 acceptance는 Windows/macOS에서 실제 두 프로세스 contention, process-kill 직후 lock release, torn tail + contention, power-loss/restart를 packaged build로 검증해야 합니다. Production HTTP adapter와 metadata authentication은 별도 Distribution vertical입니다. +후속 acceptance는 Windows/macOS에서 실제 두 프로세스 contention, process-kill 직후 lock release, torn tail + contention, hard-link fixture, pathname replacement race, power-loss/restart를 packaged build로 검증해야 합니다. Production HTTP adapter와 metadata authentication은 별도 Distribution vertical입니다. ## Security Notes -Attack surface는 app-local freshness log와 sibling lease pathname입니다. Lease contention은 성공으로 우회하지 않고 `ConcurrentMutation`으로 fail closed합니다. Lock file에는 release identity, project/audio path, credential, signature나 PII를 기록하지 않습니다. State log의 기존 regular-file/resource/record validation과 append durability는 유지됩니다. +Attack surface는 app-local freshness log와 sibling lease pathname입니다. Lease contention은 성공으로 우회하지 않고 `ConcurrentMutation`으로 fail closed합니다. Existing state는 symlink/non-regular뿐 아니라 multi-link object도 거부합니다. Lock file에는 release identity, project/audio path, credential, signature나 PII를 기록하지 않습니다. State log의 resource/record validation과 append durability는 유지됩니다. 이번 link-count admission은 static multi-link alias를 차단하는 통제이며 privileged/same-user hostile filesystem race에 대한 완전한 sandbox 경계로 취급하지 않습니다. ## 참고문헌 Rust Project. (2026). *std::fs: Filesystem manipulation operations*. Rust 1.98.1 standard library documentation. https://doc.rust-lang.org/std/fs/ +Rust Project. (2026). *MetadataExt in std::os::unix::fs*. Rust 1.98.1 standard library documentation. https://doc.rust-lang.org/std/os/unix/fs/trait.MetadataExt.html + +Rust Project. (2026). *MetadataExt in std::os::windows::fs*. Rust 1.98.1 standard library documentation. https://doc.rust-lang.org/std/os/windows/fs/trait.MetadataExt.html + Rust Project. (2026). *TryLockError in std::fs*. Rust 1.98.1 standard library documentation. https://doc.rust-lang.org/std/fs/enum.TryLockError.html Kerrisk, M. (2026). *flock(2) — apply or remove an advisory lock on an open file*. Linux man-pages project. https://man7.org/linux/man-pages/man2/flock.2.html From 8832e62fcc2e711a325d1468f836c06ba91bca33 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 01:12:29 +0900 Subject: [PATCH 227/308] test(ci): require hosted coverage for Distribution owners --- .../tests/test_distribution_ci_coverage.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 services/analysis-engine/tests/test_distribution_ci_coverage.py diff --git a/services/analysis-engine/tests/test_distribution_ci_coverage.py b/services/analysis-engine/tests/test_distribution_ci_coverage.py new file mode 100644 index 000000000..1ae74ae3d --- /dev/null +++ b/services/analysis-engine/tests/test_distribution_ci_coverage.py @@ -0,0 +1,27 @@ +"""Hosted-CI contracts for standalone Distribution Rust owners.""" + +from __future__ import annotations + +from pathlib import Path + + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_CI_WORKFLOW = _REPO_ROOT / ".github" / "workflows" / "ci.yml" + + +def test_hosted_ci_executes_every_standalone_distribution_owner() -> None: + """Current-head CI must execute tests for each Distribution-owned Rust crate.""" + workflow = _CI_WORKFLOW.read_text(encoding="utf-8") + manifests = ( + "apps/desktop/distribution-download/Cargo.toml", + "apps/desktop/distribution-state/Cargo.toml", + "apps/desktop/distribution-runtime/Cargo.toml", + "apps/desktop/distribution-transport/Cargo.toml", + ) + + for manifest in manifests: + command = f"cargo +stable test --manifest-path {manifest} --locked --all-targets" + assert command in workflow, f"hosted CI does not execute {manifest}" + + assert "distribution-owned-platform" in workflow + assert "- distribution-owned-platform" in workflow From c5a09d5c0d14dbdba776997ba920dd99dbbba2cf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 01:12:54 +0900 Subject: [PATCH 228/308] fix(ci): execute standalone Distribution owner tests --- .github/workflows/ci.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d44bf800..cbc6e6087 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,11 +68,36 @@ jobs: - name: Test Distribution download staging and lease contracts run: cargo +stable test --manifest-path apps/desktop/distribution-download/Cargo.toml --locked --all-targets + distribution-owned-platform: + name: gate / ci / distribution-owned / ${{ matrix.os }} + needs: lock-validation + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-2025 + - macos-15 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Install stable Rust toolchain + run: rustup toolchain install stable --profile minimal + - name: Test Distribution state authority contracts + run: cargo +stable test --manifest-path apps/desktop/distribution-state/Cargo.toml --locked --all-targets + - name: Test Distribution metadata admission contracts + run: cargo +stable test --manifest-path apps/desktop/distribution-runtime/Cargo.toml --locked --all-targets + - name: Test Distribution transport contracts + run: cargo +stable test --manifest-path apps/desktop/distribution-transport/Cargo.toml --locked --all-targets + verify: name: ci / build-and-test needs: - lock-validation - distribution-download-platform + - distribution-owned-platform runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 From 835bfb5b062e9721510662ffb34797633b144e65 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 01:13:45 +0900 Subject: [PATCH 229/308] docs(ci): bind Distribution owners to hosted platform tests --- .../traceability/updater-highest-seen-concurrency.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/traceability/updater-highest-seen-concurrency.md b/docs/traceability/updater-highest-seen-concurrency.md index 91bfb404d..b9768c7af 100644 --- a/docs/traceability/updater-highest-seen-concurrency.md +++ b/docs/traceability/updater-highest-seen-concurrency.md @@ -8,6 +8,8 @@ 추가 review에서 pathname admission에도 별도 결함이 확인됐습니다. Symlink와 non-regular entry만 거부하면 hard link는 정상 regular file로 보입니다. 공격 또는 잘못된 local-data 조작으로 `highest-seen.log`가 다른 파일과 같은 inode/file record를 공유하면, BandScope의 recoverable-tail truncation이나 monotonic append가 그 다른 pathname이 가리키는 동일 파일 내용까지 변경할 수 있습니다. Anti-replay authority는 app-owned 단일 state object여야 하므로 pre-existing multi-link state는 정상 authority로 인정하지 않습니다. +또한 이 수리 직후 hosted verification graph를 다시 추적한 결과, 기존 `ci.yml`의 explicit cross-platform Rust job은 `distribution-download`만 실행하고 있었습니다. `distribution-state`, `distribution-runtime`, `distribution-transport`는 독립 Cargo crate인데 `quickcheck.sh`의 기본 경로와 `src-tauri` cargo test에도 포함되지 않아, exact-head hosted GREEN이 이 세 owner의 tests를 실행했다는 뜻이 아니었습니다. 특히 이번 hard-link RED는 source에 존재해도 hosted merge gate에서 실행되지 않는 상태였습니다. + ## RED와 수정 - RED `e25fd44daa62f994d40a378de8c06a87eed5fb86`은 sibling lease를 다른 handle이 보유한 동안 `load_highest_seen()`과 `remember_highest_seen()`이 진행되면 안 된다는 cross-process contract를 추가했습니다. 선행 구현은 lease를 전혀 확인하지 않아 write를 수행하고 state file까지 만들 수 있었습니다. @@ -15,6 +17,8 @@ - 기존 `StateError::ConcurrentMutation`을 lease contention에도 사용합니다. 호출자는 이를 freshness authority를 읽거나 갱신할 수 없는 fail-closed 상태로 이미 취급할 수 있으므로 별도 public error family를 만들지 않았습니다. - RED `b6334829b474a60ba2ad0e7a7d77ec93822b20f2`는 정상 highest-seen 파일의 hard link를 app-owned state pathname에 만든 뒤, read와 다음-release append가 모두 `StateError::NotRegularFile`로 거부되고 원본 alias bytes가 바뀌지 않아야 한다는 contract를 추가했습니다. 선행 구현은 hard link를 regular file로 받아들여 이 계약을 만족하지 못했습니다. - Causal fix `4f8c9336141456ad9f669f88fa5373aa7902c9ca`는 state pathname metadata와 실제 opened descriptor metadata 양쪽에서 filesystem link count가 정확히 1인지 확인합니다. Unix는 `MetadataExt::nlink()`, Windows는 `MetadataExt::number_of_links()`를 사용하고, 이 정보를 제공하지 않는 target은 freshness authority를 추측하지 않고 fail closed합니다. +- Hosted-evidence RED `8832e62fcc2e711a325d1468f836c06ba91bca33`은 `ci.yml`이 `distribution-download`, `distribution-state`, `distribution-runtime`, `distribution-transport` 네 standalone owner의 exact `cargo +stable test --locked --all-targets` command를 포함하고 최종 `ci / build-and-test`가 추가 Distribution platform lane에 의존해야 한다고 요구합니다. 선행 workflow에는 download 외 세 owner가 없었습니다. +- Causal CI fix `c5a09d5c0d14dbdba776997ba920dd99dbbba2cf`은 기존 download platform lane을 보존하면서 Ubuntu, Windows 2025, macOS 15에서 state/runtime/transport tests를 실행하는 `distribution-owned-platform` matrix를 추가하고 `ci / build-and-test`가 이 lane 성공에 의존하도록 연결했습니다. 따라서 standalone Distribution source가 바뀌어도 protected CI의 최종 status가 해당 tests를 건너뛸 수 없습니다. ## 제약과 대안 @@ -24,22 +28,28 @@ Lock file은 crash 뒤에도 남을 수 있지만 lock ownership은 open file ha Hard-link alias를 pathname canonicalization으로 찾는 방식은 기각했습니다. Canonical path는 동일 inode의 다른 directory entry 존재 여부를 증명하지 못합니다. App-local tree를 순회해 모든 alias를 찾는 방식도 동일 filesystem 전체를 증명할 수 없고 race가 남습니다. State authority 자체에서 OS metadata의 link count를 검사하는 것이 더 작고 직접적인 invariant입니다. +Hosted verification에서는 `src-tauri` build 성공을 standalone Distribution crates의 test evidence로 간주하는 방식을 기각했습니다. 현재 이 crate들은 별도 manifests/workspaces이고, shell graph에 우연히 포함되는지 여부와 owner tests 실행 여부는 다른 계약입니다. Linux 한 플랫폼만 돌리는 방식도 state의 OS lock/link-count API와 transport/download desktop behavior를 증명하지 못하므로 기각했습니다. 기존 download platform lane을 삭제하거나 check를 약화하지 않고 별도 세 owner를 같은 3-OS matrix에 추가했습니다. + ## Claim boundary와 위험 이 변경은 cooperating BandScope processes의 highest-seen read/write를 직렬화하고, admission 시점에 이미 여러 pathname을 가진 state object를 freshness authority로 사용하지 않게 합니다. Advisory file locking을 무시하고 app-local-data directory를 직접 변조할 수 있는 동일 사용자/관리자 프로세스로부터 state를 완전히 보호한다고 주장하지 않습니다. 특히 정상 single-link file이 검사된 뒤 hostile actor가 hard link를 추가하거나 pathname을 교체하는 TOCTOU까지 이번 변경이 제거하지는 않습니다. Rust 표준 라이브러리도 filesystem operation 전반의 TOCTOU 가능성을 명시하므로 descriptor-relative/path-identity hardening과 packaged hostile-race acceptance는 후속 범위입니다. `try_lock()`은 2026-09 현재 Rust stable 표준 라이브러리에서 제공되며, 다른 handle/process가 lock을 보유하면 `TryLockError::WouldBlock`을 반환합니다. Rust 1.98.1의 Unix `MetadataExt`는 `nlink()`를, Windows `MetadataExt`는 `number_of_links()`를 제공하므로 supported desktop targets에서 pre-existing hard-link alias를 direct metadata로 검사할 수 있습니다. BandScope가 지원하는 desktop build는 repository의 cross-platform CI에서 이 계약을 다시 실행해야 합니다. 네트워크 filesystem/SMB/NFS는 lock/link semantics가 달라질 수 있으므로 commercial acceptance는 app-local state가 실제 supported local profile/storage에서 동작하는 조건으로 검증합니다. +새 CI lane은 repository-hosted test execution coverage를 보장하는 source contract입니다. Exact current-head Actions가 실제로 terminal GREEN이 되기 전에는 이 문서나 workflow source만으로 Windows/macOS/Linux parity가 통과했다고 주장하지 않습니다. Packaged executable에서 process-kill/power-loss/filesystem fault를 통과했다는 증거도 아닙니다. + ## 효과와 후속조치 두 앱 인스턴스가 같은 authenticated release에서 출발해 동일 또는 서로 다른 다음 release를 동시에 append하여 durable log를 자가-corrupt시키는 경로와, pre-existing hard-linked state pathname을 통해 다른 alias의 bytes를 freshness repair/append가 변경하는 경로를 닫았습니다. Remote metadata authenticity, updater signature verification, exact sealed-descriptor digest/size binding, verified-artifact promotion이 완료되기 전에는 이 state writer를 production updater flow에 연결하지 않는 기존 trust order는 그대로입니다. -후속 acceptance는 Windows/macOS에서 실제 두 프로세스 contention, process-kill 직후 lock release, torn tail + contention, hard-link fixture, pathname replacement race, power-loss/restart를 packaged build로 검증해야 합니다. Production HTTP adapter와 metadata authentication은 별도 Distribution vertical입니다. +Hosted merge evidence도 이제 네 standalone Distribution owner tests를 명시적으로 포함합니다. `distribution-download`는 기존 3-OS lane을 유지하고 state/runtime/transport는 새 3-OS lane에서 실행되며, 둘 모두 final `ci / build-and-test`의 prerequisite입니다. 후속 acceptance는 Windows/macOS에서 실제 두 프로세스 contention, process-kill 직후 lock release, torn tail + contention, hard-link fixture, pathname replacement race, power-loss/restart를 packaged build로 검증해야 합니다. Production HTTP adapter와 metadata authentication은 별도 Distribution vertical입니다. ## Security Notes Attack surface는 app-local freshness log와 sibling lease pathname입니다. Lease contention은 성공으로 우회하지 않고 `ConcurrentMutation`으로 fail closed합니다. Existing state는 symlink/non-regular뿐 아니라 multi-link object도 거부합니다. Lock file에는 release identity, project/audio path, credential, signature나 PII를 기록하지 않습니다. State log의 resource/record validation과 append durability는 유지됩니다. 이번 link-count admission은 static multi-link alias를 차단하는 통제이며 privileged/same-user hostile filesystem race에 대한 완전한 sandbox 경계로 취급하지 않습니다. +CI 변경은 기존 dependency/security/release gate를 우회하지 않고, 추가 Rust owner tests를 `ci / build-and-test`의 dependency로 붙입니다. Third-party Action ref나 권한을 추가하지 않았고, secret/network authority를 새로 요구하지 않습니다. + ## 참고문헌 Rust Project. (2026). *std::fs: Filesystem manipulation operations*. Rust 1.98.1 standard library documentation. https://doc.rust-lang.org/std/fs/ From 4aca07e177198b75cfe166208b48808704b170e4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 02:05:51 +0900 Subject: [PATCH 230/308] test(distribution): reject replacement-path cleanup --- .../tests/path_replacement_cleanup.rs | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 apps/desktop/distribution-download/tests/path_replacement_cleanup.rs diff --git a/apps/desktop/distribution-download/tests/path_replacement_cleanup.rs b/apps/desktop/distribution-download/tests/path_replacement_cleanup.rs new file mode 100644 index 000000000..4e9031caa --- /dev/null +++ b/apps/desktop/distribution-download/tests/path_replacement_cleanup.rs @@ -0,0 +1,80 @@ +#![cfg(unix)] + +use bandscope_distribution_download::{ArtifactDownloadAdmission, StagedArtifactFile}; +use std::fs; +use std::io::ErrorKind; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn scratch_dir(label: &str) -> std::path::PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock after unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "bandscope-distribution-download-path-replacement-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir(&path).expect("create isolated staging directory"); + path +} + +fn remove_file_if_present(path: &Path) { + match fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => panic!("remove fixture {}: {error}", path.display()), + } +} + +fn cleanup(directory: &Path, paths: &[&Path]) { + for path in paths { + remove_file_if_present(path); + } + remove_file_if_present(&directory.join(".bandscope-staging.lock")); + fs::remove_dir(directory).expect("remove staging directory"); +} + +#[test] +fn cancelled_attempt_does_not_delete_replacement_path() { + let directory = scratch_dir("cancelled"); + let staged = StagedArtifactFile::create(&directory, "update.bin").expect("stage file"); + let original_path = staged.path().to_path_buf(); + let moved_original = directory.join("moved-original.bin"); + + fs::rename(&original_path, &moved_original).expect("move owned staging inode away"); + fs::write(&original_path, b"replacement-must-survive").expect("create unrelated replacement"); + + drop(staged); + + assert_eq!( + fs::read(&original_path).expect("replacement pathname must not be deleted"), + b"replacement-must-survive" + ); + cleanup(&directory, &[&original_path, &moved_original]); +} + +#[test] +fn sealed_attempt_does_not_delete_replacement_path() { + let directory = scratch_dir("sealed"); + let mut staged = StagedArtifactFile::create(&directory, "update.bin").expect("stage file"); + let mut admission = ArtifactDownloadAdmission::new(4, Some(4)).expect("admission"); + staged + .admit_chunk(&mut admission, b"data") + .expect("write admitted bytes"); + let receipt = admission.finish().expect("exact receipt"); + let sealed = staged.seal(receipt).expect("seal exact artifact"); + let original_path = sealed.path().to_path_buf(); + let moved_original = directory.join("moved-sealed-original.bin"); + + fs::rename(&original_path, &moved_original).expect("move sealed staging inode away"); + fs::write(&original_path, b"replacement-must-survive").expect("create unrelated replacement"); + + drop(sealed); + + assert_eq!( + fs::read(&original_path).expect("replacement pathname must not be deleted"), + b"replacement-must-survive" + ); + cleanup(&directory, &[&original_path, &moved_original]); +} From bc72745df92fc048b25a84cca349a6ecf0d9daf1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 02:08:28 +0900 Subject: [PATCH 231/308] fix(distribution): preserve replaced staging paths --- apps/desktop/distribution-download/src/lib.rs | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/apps/desktop/distribution-download/src/lib.rs b/apps/desktop/distribution-download/src/lib.rs index 48cd6db5c..fb5d74b58 100644 --- a/apps/desktop/distribution-download/src/lib.rs +++ b/apps/desktop/distribution-download/src/lib.rs @@ -320,9 +320,8 @@ impl Drop for StagedArtifactFile { return; } if let Some(file) = self.file.take() { - drop(file); + cleanup_owned_staging_path(file, &self.path); } - let _ = fs::remove_file(&self.path); let _ = self.staging_lease.take(); } } @@ -330,10 +329,12 @@ impl Drop for StagedArtifactFile { /// 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. +/// verification. Dropping this value removes the staging pathname only when it +/// still resolves to the descriptor-owned file on Unix; a replacement pathname +/// is left untouched. Other desktop targets retain the historical best-effort +/// cleanup until an equally strong stable file-identity primitive is available. +/// 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, @@ -417,13 +418,37 @@ impl SealedArtifactFile { impl Drop for SealedArtifactFile { fn drop(&mut self) { if let Some(file) = self.file.take() { - drop(file); + cleanup_owned_staging_path(file, &self.path); } - let _ = fs::remove_file(&self.path); let _ = self.staging_lease.take(); } } +#[cfg(unix)] +fn cleanup_owned_staging_path(file: File, path: &Path) { + use std::os::unix::fs::MetadataExt; + + let descriptor_identity = file.metadata().ok().map(|metadata| (metadata.dev(), metadata.ino())); + if let (Some((descriptor_dev, descriptor_ino)), Ok(path_metadata)) = + (descriptor_identity, fs::symlink_metadata(path)) + { + if !path_metadata.file_type().is_symlink() + && path_metadata.is_file() + && path_metadata.dev() == descriptor_dev + && path_metadata.ino() == descriptor_ino + { + let _ = fs::remove_file(path); + } + } + drop(file); +} + +#[cfg(not(unix))] +fn cleanup_owned_staging_path(file: File, path: &Path) { + drop(file); + let _ = fs::remove_file(path); +} + fn acquire_staging_lease(staging_directory: &Path) -> Result { let lease_path = staging_directory.join(STAGING_LEASE_FILE_NAME); let lease_file = match OpenOptions::new() @@ -639,4 +664,4 @@ mod tests { assert!(!is_portable_artifact_name("../escape")); assert!(!is_portable_artifact_name("name%2fescape")); } -} \ No newline at end of file +} From ecec9978bb6c4f3f3a66ac342d7a150fb1243aea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 02:09:47 +0900 Subject: [PATCH 232/308] docs(distribution): trace staging cleanup identity --- .../updater-staging-path-identity.md | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 docs/traceability/updater-staging-path-identity.md diff --git a/docs/traceability/updater-staging-path-identity.md b/docs/traceability/updater-staging-path-identity.md new file mode 100644 index 000000000..50fc8dfcc --- /dev/null +++ b/docs/traceability/updater-staging-path-identity.md @@ -0,0 +1,45 @@ +# Updater staging cleanup path identity + +## Problem + +`distribution-download` retained the staging descriptor and lease through cancellation or sealing, but cleanup was pathname-only: `Drop` closed the owned descriptor and then unconditionally removed the remembered pathname. If the owned staging entry was renamed away while the descriptor remained open and an unrelated regular file was created at the original basename, cleanup could delete that replacement object even though BandScope never opened or wrote it. + +The staging lease serializes cooperating BandScope attempts. It is not a filesystem namespace capability and does not stop another same-user process from renaming or replacing a pathname. Cleanup therefore must not infer object ownership from a stale path string. + +## RED evidence + +Commit `4aca07e177198b75cfe166208b48808704b170e4` adds `apps/desktop/distribution-download/tests/path_replacement_cleanup.rs` for Unix desktop semantics. + +The regression covers both lifecycle paths: + +- a cancelled `StagedArtifactFile` whose owned inode is renamed away before `Drop`; +- a `SealedArtifactFile` whose owned inode is renamed away before verifier-owner cleanup. + +Each case creates an unrelated replacement at the original staging basename before dropping the BandScope owner and requires those replacement bytes to survive. The previous close-then-`remove_file(path)` implementation deletes the replacement and violates the contract. + +## Causal repair + +Commit `bc72745df92fc048b25a84cca349a6ecf0d9daf1` routes both staged and sealed cleanup through one owner helper. + +On Unix, cleanup reads the still-open descriptor identity (`dev`, `ino`) and compares it with `symlink_metadata` for the current pathname. The pathname is removed only when it is a direct regular non-symlink object with the same device/inode identity as the owned descriptor. A missing, symlinked, non-regular, or replaced pathname is left untouched. The descriptor is then closed and the staging lease is released. + +The helper deliberately keeps the descriptor open until the identity comparison and optional unlink have completed; closing first would discard the strongest object reference available to the owner. + +## Alternatives considered + +- **Close then unconditionally remove the remembered path** — rejected because the pathname may now identify another filesystem object. +- **Check only that the pathname exists and is a regular file** — rejected because type equality is not object identity. +- **Never clean up on `Drop`** — rejected because cancelled and unverified updater artifacts would accumulate and make crash-safe restart semantics unreliable. +- **Claim equivalent Windows identity from unstable standard-library metadata extensions** — rejected. The stable implementation must not depend on nightly-only Windows by-handle metadata APIs merely to preserve a source-level parity claim. + +## Claim boundary and residual risk + +This repair closes the deterministic Unix/macOS/Linux case where a replacement pathname is already present when cleanup performs its identity check. It does not claim to defeat a malicious process that can win the remaining metadata-check-to-unlink race after the comparison. Descriptor-relative unlink or an equivalent OS capability would be required for that stronger hostile same-user guarantee. + +Windows retains the prior best-effort close-then-remove behavior in this commit because stable Rust does not expose an equivalent file-index identity through the same portable metadata API used here. Windows path replacement, hard-link identity, and delete-sharing behavior therefore remain explicit Distribution acceptance gaps rather than being reported as parity-complete. + +## Product effect + +Cancellation and unverified-artifact cleanup no longer intentionally deletes a pathname merely because it has the same basename as the staging object BandScope originally created on Unix desktop targets. This protects unrelated local data from a stale cleanup action without promoting staging bytes to trusted release artifacts or changing the updater trust order. + +The release trust order remains: provisional metadata and transport admission → authenticated release identity → cryptographic updater signature verification → sealed-descriptor digest/authenticated-size binding → explicit verified-artifact promotion → anti-replay decision and durable highest-seen mutation. From e07a50ddc343a43e525ccd9c9e6c621f3abfe8df Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 02:14:50 +0900 Subject: [PATCH 233/308] fix(distribution): publish Windows freshness state without alias mutation --- apps/desktop/distribution-state/src/lib.rs | 185 +++++++++++++++------ 1 file changed, 138 insertions(+), 47 deletions(-) diff --git a/apps/desktop/distribution-state/src/lib.rs b/apps/desktop/distribution-state/src/lib.rs index 2a5f1b041..9f3045b9f 100644 --- a/apps/desktop/distribution-state/src/lib.rs +++ b/apps/desktop/distribution-state/src/lib.rs @@ -3,8 +3,10 @@ //! 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. +//! project data. Unix keeps the bounded state log append-only. Windows publishes +//! an equivalent committed log snapshot through a synchronized sibling file and +//! pathname replacement so updating state cannot mutate a pre-existing hard-link +//! alias when stable Rust cannot inspect link count safely. #![forbid(unsafe_code)] @@ -19,11 +21,13 @@ pub const MAX_STATE_BYTES: usize = 64 * 1024; const RECORD_PREFIX: &str = "v1|"; const MAX_RECORD_BYTES: usize = 192; const STATE_LEASE_FILE_NAME: &str = ".bandscope-highest-seen.lock"; +#[cfg(windows)] +const STATE_NEXT_FILE_NAME: &str = ".bandscope-highest-seen.next"; /// Successful result of remembering an authenticated release identity. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum RememberOutcome { - /// The new highest authenticated identity was appended and synchronized. + /// The new highest authenticated identity was durably committed. Remembered, /// The exact identity was already the committed highest-seen release. AlreadyRemembered, @@ -34,7 +38,7 @@ pub enum RememberOutcome { pub enum StateError { /// A local filesystem operation failed. Io, - /// The configured state path is a link or is not a regular file. + /// The configured state path is a link or is not an admissible regular file. NotRegularFile, /// The state log exceeded its bounded storage budget. TooLarge, @@ -51,26 +55,29 @@ pub enum StateError { /// Load the highest committed authenticated release identity from local state. /// /// Access is serialized through a sibling OS file lease so a reader cannot -/// observe a stale highest-seen snapshot while another process is appending a -/// newer authenticated release. A final non-newline-terminated fragment is -/// treated as recoverable only when every byte is a valid prefix of one state -/// record. This is the sole torn-write case accepted. Malformed committed -/// records fail closed rather than silently discarding anti-replay evidence. +/// observe a stale highest-seen snapshot while another cooperating process is +/// committing a newer authenticated release. A final non-newline-terminated +/// fragment is treated as recoverable only when every byte is a valid prefix of +/// one state record. This is the sole torn-write case accepted. Malformed +/// committed records fail closed rather than silently discarding anti-replay +/// evidence. pub fn load_highest_seen(path: &Path) -> Result, StateError> { let _lease = acquire_state_lease(path)?; let bytes = read_state_bytes(path)?.unwrap_or_default(); parse_state_bytes(&bytes).map(|parsed| parsed.highest) } -/// Remember a newly authenticated release identity in an append-only state log. +/// Remember a newly authenticated release identity in durable local state. /// /// The caller must invoke this only after updater metadata and artifact /// authenticity have been established. One sibling OS file lease is held from -/// the first state read through tail repair, append, and synchronization so two -/// app processes cannot append from the same stale snapshot. 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. +/// the first state read through repair and synchronization so two app processes +/// cannot commit from the same stale snapshot. Unix appends to the single-link +/// log and truncates only a validated torn tail. Windows constructs the same +/// committed log bytes in a sibling scratch file, synchronizes them, and +/// replaces only the state pathname; this avoids in-place mutation of a +/// pre-existing hard-link alias without relying on nightly-only Windows +/// metadata APIs. pub fn remember_highest_seen( path: &Path, identity: &ReleaseIdentity, @@ -89,48 +96,59 @@ pub fn remember_highest_seen( return Err(StateError::Equivocation); } if parsed.committed_len != bytes.len() { - truncate_recoverable_tail(path, parsed.committed_len)?; + repair_recoverable_tail(path, bytes, 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 + let expected_len = parsed .committed_len .checked_add(record.len()) - .is_none_or(|next_len| next_len > MAX_STATE_BYTES) - { + .ok_or(StateError::TooLarge)?; + if expected_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); + #[cfg(windows)] + { + let mut committed = Vec::with_capacity(expected_len); + committed.extend_from_slice(&bytes[..parsed.committed_len]); + committed.extend_from_slice(record.as_bytes()); + publish_state_snapshot(path, &committed)?; + return Ok(RememberOutcome::Remembered); } - 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(not(windows))] + { + if parsed.committed_len != bytes.len() { + repair_recoverable_tail(path, bytes, parsed.committed_len)?; + } - #[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)?; - } + 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); + } - Ok(RememberOutcome::Remembered) + file.write_all(record.as_bytes()).map_err(|_| StateError::Io)?; + file.sync_all().map_err(|_| StateError::Io)?; + if file.metadata().map_err(|_| StateError::Io)?.len() as usize != expected_len { + return Err(StateError::ConcurrentMutation); + } + + #[cfg(unix)] + if !existed { + let parent = path.parent().ok_or(StateError::Io)?; + File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|_| StateError::Io)?; + } + + Ok(RememberOutcome::Remembered) + } } #[derive(Debug)] @@ -178,7 +196,7 @@ fn read_state_bytes(path: &Path) -> Result>, StateError> { }; if metadata.file_type().is_symlink() || !metadata.is_file() - || !has_single_filesystem_link(&metadata) + || !has_admissible_filesystem_link_count(&metadata) { return Err(StateError::NotRegularFile); } @@ -189,7 +207,7 @@ fn read_state_bytes(path: &Path) -> Result>, StateError> { let mut file = File::open(path).map_err(|_| StateError::Io)?; let opened = file.metadata().map_err(|_| StateError::Io)?; if !opened.is_file() - || !has_single_filesystem_link(&opened) + || !has_admissible_filesystem_link_count(&opened) || opened.len() != metadata.len() { return Err(StateError::ConcurrentMutation); @@ -209,7 +227,7 @@ fn read_state_bytes(path: &Path) -> Result>, StateError> { Ok(Some(bytes)) } -fn has_single_filesystem_link(metadata: &std::fs::Metadata) -> bool { +fn has_admissible_filesystem_link_count(metadata: &std::fs::Metadata) -> bool { #[cfg(unix)] { use std::os::unix::fs::MetadataExt; @@ -217,8 +235,12 @@ fn has_single_filesystem_link(metadata: &std::fs::Metadata) -> bool { } #[cfg(windows)] { - use std::os::windows::fs::MetadataExt; - return metadata.number_of_links() == Some(1); + // Windows never mutates an admitted existing state file in place; it + // publishes a synchronized sibling snapshot and replaces only this + // pathname. Stable Rust therefore does not need the nightly-only + // `windows_by_handle` link-count API for safe alias preservation. + let _ = metadata; + return true; } #[cfg(not(any(unix, windows)))] { @@ -352,6 +374,23 @@ fn is_lower_hex(byte: u8) -> bool { byte.is_ascii_digit() || matches!(byte, b'a'..=b'f') } +fn repair_recoverable_tail( + path: &Path, + bytes: &[u8], + committed_len: usize, +) -> Result<(), StateError> { + #[cfg(windows)] + { + return publish_state_snapshot(path, &bytes[..committed_len]); + } + #[cfg(not(windows))] + { + let _ = bytes; + truncate_recoverable_tail(path, committed_len) + } +} + +#[cfg(not(windows))] fn truncate_recoverable_tail(path: &Path, committed_len: usize) -> Result<(), StateError> { let metadata = std::fs::symlink_metadata(path).map_err(|_| StateError::Io)?; if metadata.file_type().is_symlink() || !metadata.is_file() { @@ -369,6 +408,7 @@ fn truncate_recoverable_tail(path: &Path, committed_len: usize) -> Result<(), St file.sync_all().map_err(|_| StateError::Io) } +#[cfg(not(windows))] fn open_for_append(path: &Path, existed: bool) -> Result { if existed { let metadata = std::fs::symlink_metadata(path).map_err(|_| StateError::Io)?; @@ -388,6 +428,57 @@ fn open_for_append(path: &Path, existed: bool) -> Result { } } +#[cfg(windows)] +fn publish_state_snapshot(path: &Path, committed: &[u8]) -> Result<(), StateError> { + let parent = path.parent().ok_or(StateError::Io)?; + let next_path = parent.join(STATE_NEXT_FILE_NAME); + + match std::fs::symlink_metadata(&next_path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(StateError::NotRegularFile); + } + std::fs::remove_file(&next_path).map_err(|_| StateError::Io)?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err(StateError::Io), + } + + let mut next = OpenOptions::new() + .write(true) + .create_new(true) + .open(&next_path) + .map_err(|_| StateError::Io)?; + if let Err(error) = next.write_all(committed) { + drop(next); + let _ = std::fs::remove_file(&next_path); + let _ = error; + return Err(StateError::Io); + } + if next.sync_all().is_err() { + drop(next); + let _ = std::fs::remove_file(&next_path); + return Err(StateError::Io); + } + if next.metadata().map_err(|_| StateError::Io)?.len() as usize != committed.len() { + drop(next); + let _ = std::fs::remove_file(&next_path); + return Err(StateError::ConcurrentMutation); + } + drop(next); + + if std::fs::rename(&next_path, path).is_err() { + let _ = std::fs::remove_file(&next_path); + return Err(StateError::Io); + } + + let published = std::fs::read(path).map_err(|_| StateError::Io)?; + if published != committed { + return Err(StateError::ConcurrentMutation); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; From 53037926b0d09ccc2b05cebc5b864c47e20ae620 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 02:15:06 +0900 Subject: [PATCH 234/308] test(distribution): scope link-count rejection to Unix --- apps/desktop/distribution-state/tests/hard_link_admission.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/distribution-state/tests/hard_link_admission.rs b/apps/desktop/distribution-state/tests/hard_link_admission.rs index 8b167a2e6..65ad9ff1c 100644 --- a/apps/desktop/distribution-state/tests/hard_link_admission.rs +++ b/apps/desktop/distribution-state/tests/hard_link_admission.rs @@ -1,3 +1,5 @@ +#![cfg(unix)] + use bandscope_distribution_core::ReleaseIdentity; use bandscope_distribution_state::{load_highest_seen, remember_highest_seen, StateError}; use std::path::{Path, PathBuf}; From 2f231d65c0736abfb83feb9223c008357457e5f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 02:15:19 +0900 Subject: [PATCH 235/308] test(distribution): prove Windows state alias preservation --- .../tests/hard_link_snapshot_windows.rs | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 apps/desktop/distribution-state/tests/hard_link_snapshot_windows.rs diff --git a/apps/desktop/distribution-state/tests/hard_link_snapshot_windows.rs b/apps/desktop/distribution-state/tests/hard_link_snapshot_windows.rs new file mode 100644 index 000000000..c068f4de0 --- /dev/null +++ b/apps/desktop/distribution-state/tests/hard_link_snapshot_windows.rs @@ -0,0 +1,96 @@ +#![cfg(windows)] + +use bandscope_distribution_core::ReleaseIdentity; +use bandscope_distribution_state::{load_highest_seen, remember_highest_seen, RememberOutcome}; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +const SOURCE: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const DIGEST: &str = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +static NEXT_TEST_ID: AtomicU64 = AtomicU64::new(1); + +struct TestDirectory(PathBuf); + +impl TestDirectory { + fn new() -> Self { + let id = NEXT_TEST_ID.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "bandscope-distribution-state-windows-hard-link-{}-{id}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("test directory should be created"); + Self(path) + } + + fn join(&self, name: &str) -> PathBuf { + self.0.join(name) + } +} + +impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn identity(version: &str) -> ReleaseIdentity { + ReleaseIdentity::new(version, SOURCE, DIGEST).expect("fixture identity should be valid") +} + +fn bytes(path: &Path) -> Vec { + std::fs::read(path).expect("fixture should remain readable") +} + +#[test] +fn monotonic_update_replaces_state_path_without_mutating_hard_link_alias() { + let directory = TestDirectory::new(); + let authority_target = directory.join("authority-target.log"); + let state_path = directory.join("highest-seen.log"); + let first = identity("1.0.0"); + let second = identity("2.0.0"); + + remember_highest_seen(&authority_target, &first).expect("fixture state should be created"); + let original_alias_bytes = bytes(&authority_target); + std::fs::hard_link(&authority_target, &state_path).expect("hard-link fixture should be created"); + + assert_eq!(load_highest_seen(&state_path), Ok(Some(first.clone()))); + assert_eq!( + remember_highest_seen(&state_path, &second), + Ok(RememberOutcome::Remembered) + ); + assert_eq!(load_highest_seen(&state_path), Ok(Some(second))); + assert_eq!(bytes(&authority_target), original_alias_bytes); +} + +#[test] +fn torn_tail_repair_replaces_state_path_without_truncating_hard_link_alias() { + let directory = TestDirectory::new(); + let authority_target = directory.join("authority-target.log"); + let state_path = directory.join("highest-seen.log"); + let first = identity("1.0.0"); + + remember_highest_seen(&authority_target, &first).expect("fixture state should be created"); + let mut alias_writer = std::fs::OpenOptions::new() + .append(true) + .open(&authority_target) + .expect("fixture alias should open"); + alias_writer + .write_all(b"v1|2.0") + .expect("recoverable tail should be written"); + alias_writer.sync_all().expect("fixture tail should sync"); + drop(alias_writer); + + let alias_with_tail = bytes(&authority_target); + std::fs::hard_link(&authority_target, &state_path).expect("hard-link fixture should be created"); + + assert_eq!(load_highest_seen(&state_path), Ok(Some(first.clone()))); + assert_eq!( + remember_highest_seen(&state_path, &first), + Ok(RememberOutcome::AlreadyRemembered) + ); + assert_eq!(load_highest_seen(&state_path), Ok(Some(first))); + assert_eq!(bytes(&authority_target), alias_with_tail); + assert!(!bytes(&state_path).ends_with(b"v1|2.0")); +} From c2b61b079bd722f76b6a2026a48c837c88c2586b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 02:16:04 +0900 Subject: [PATCH 236/308] docs(distribution): correct Windows freshness-state authority --- .../updater-highest-seen-concurrency.md | 63 +++++++++++-------- 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/docs/traceability/updater-highest-seen-concurrency.md b/docs/traceability/updater-highest-seen-concurrency.md index b9768c7af..586fdc621 100644 --- a/docs/traceability/updater-highest-seen-concurrency.md +++ b/docs/traceability/updater-highest-seen-concurrency.md @@ -2,62 +2,73 @@ ## 문제 -`bandscope-distribution-state`의 append-only log는 torn final write와 단일 호출 내부의 길이 변화는 검사했지만, 두 BandScope 프로세스가 같은 freshness state를 동시에 갱신하는 경우를 직렬화하지 않았습니다. 두 writer가 같은 committed snapshot을 읽은 뒤 각각 다음 release record를 append하면, append 자체는 모두 성공할 수 있지만 이후 parser는 동일 version의 두 committed record를 equivocation/corruption으로 거부합니다. 더 위험한 경우에는 reader가 writer의 append 직전 snapshot을 정상 값으로 반환해 anti-replay 판단이 이미 진행 중인 다른 프로세스의 최신 authority보다 뒤처질 수 있습니다. +`bandscope-distribution-state`는 authenticated release의 highest-seen identity를 app-local state로 보존합니다. 이 authority가 두 desktop process 사이에서 직렬화되지 않으면 두 writer가 같은 snapshot에서 출발해 중복/충돌 record를 만들거나 reader가 concurrent update 직전 값을 정상 authority로 사용할 수 있습니다. 그래서 최초 read부터 tail repair, commit, sync까지 sibling OS lease가 필요합니다. -이 문제는 네트워크 metadata나 updater signature의 진위와 별개입니다. `distribution-state`가 실제 flow에 연결되는 시점에는 이미 인증된 release identity만 받더라도, 로컬 freshness authority 자체가 다중 프로세스에서 일관되어야 합니다. +별도 filesystem finding도 있습니다. Unix에서 `highest-seen.log`가 다른 pathname과 hard link로 같은 inode를 공유한 채 in-place truncate/append되면 BandScope가 다른 alias의 bytes까지 변경할 수 있습니다. Windows에서는 같은 문제를 해결하려고 `std::os::windows::fs::MetadataExt::number_of_links()`를 직접 사용했던 선행 수리가 있었지만, Rust 1.98.1은 이 API를 nightly-only `windows_by_handle` 기능으로 문서화합니다. Hosted Distribution lane은 의도적으로 `cargo +stable`을 사용하므로 그 구현은 commercial evidence가 될 수 없었습니다. -추가 review에서 pathname admission에도 별도 결함이 확인됐습니다. Symlink와 non-regular entry만 거부하면 hard link는 정상 regular file로 보입니다. 공격 또는 잘못된 local-data 조작으로 `highest-seen.log`가 다른 파일과 같은 inode/file record를 공유하면, BandScope의 recoverable-tail truncation이나 monotonic append가 그 다른 pathname이 가리키는 동일 파일 내용까지 변경할 수 있습니다. Anti-replay authority는 app-owned 단일 state object여야 하므로 pre-existing multi-link state는 정상 authority로 인정하지 않습니다. +## 결정 -또한 이 수리 직후 hosted verification graph를 다시 추적한 결과, 기존 `ci.yml`의 explicit cross-platform Rust job은 `distribution-download`만 실행하고 있었습니다. `distribution-state`, `distribution-runtime`, `distribution-transport`는 독립 Cargo crate인데 `quickcheck.sh`의 기본 경로와 `src-tauri` cargo test에도 포함되지 않아, exact-head hosted GREEN이 이 세 owner의 tests를 실행했다는 뜻이 아니었습니다. 특히 이번 hard-link RED는 source에 존재해도 hosted merge gate에서 실행되지 않는 상태였습니다. +Process serialization은 기존 sibling lease를 유지합니다. `load_highest_seen()`과 `remember_highest_seen()`은 `.bandscope-highest-seen.lock`에 `File::try_lock()`을 획득한 뒤에만 state를 읽거나 갱신합니다. Contention은 `StateError::ConcurrentMutation`으로 fail closed합니다. -## RED와 수정 +Filesystem alias 통제는 OS capability에 맞게 다르게 구현합니다. -- RED `e25fd44daa62f994d40a378de8c06a87eed5fb86`은 sibling lease를 다른 handle이 보유한 동안 `load_highest_seen()`과 `remember_highest_seen()`이 진행되면 안 된다는 cross-process contract를 추가했습니다. 선행 구현은 lease를 전혀 확인하지 않아 write를 수행하고 state file까지 만들 수 있었습니다. -- Causal fix `dd2ae7f45468ad06bf50a83d279dcb8facf1d783`은 state file과 같은 directory의 `.bandscope-highest-seen.lock`을 열고 `File::try_lock()`의 exclusive OS lock을 획득한 뒤에만 read/repair/append를 시작합니다. `remember_highest_seen()`은 최초 state read부터 recoverable-tail truncation, append, `sync_all()` 및 final length 확인까지 같은 lease를 유지합니다. `load_highest_seen()`도 같은 lease를 사용해 concurrent writer와 stale read가 겹치지 않게 합니다. -- 기존 `StateError::ConcurrentMutation`을 lease contention에도 사용합니다. 호출자는 이를 freshness authority를 읽거나 갱신할 수 없는 fail-closed 상태로 이미 취급할 수 있으므로 별도 public error family를 만들지 않았습니다. -- RED `b6334829b474a60ba2ad0e7a7d77ec93822b20f2`는 정상 highest-seen 파일의 hard link를 app-owned state pathname에 만든 뒤, read와 다음-release append가 모두 `StateError::NotRegularFile`로 거부되고 원본 alias bytes가 바뀌지 않아야 한다는 contract를 추가했습니다. 선행 구현은 hard link를 regular file로 받아들여 이 계약을 만족하지 못했습니다. -- Causal fix `4f8c9336141456ad9f669f88fa5373aa7902c9ca`는 state pathname metadata와 실제 opened descriptor metadata 양쪽에서 filesystem link count가 정확히 1인지 확인합니다. Unix는 `MetadataExt::nlink()`, Windows는 `MetadataExt::number_of_links()`를 사용하고, 이 정보를 제공하지 않는 target은 freshness authority를 추측하지 않고 fail closed합니다. -- Hosted-evidence RED `8832e62fcc2e711a325d1468f836c06ba91bca33`은 `ci.yml`이 `distribution-download`, `distribution-state`, `distribution-runtime`, `distribution-transport` 네 standalone owner의 exact `cargo +stable test --locked --all-targets` command를 포함하고 최종 `ci / build-and-test`가 추가 Distribution platform lane에 의존해야 한다고 요구합니다. 선행 workflow에는 download 외 세 owner가 없었습니다. -- Causal CI fix `c5a09d5c0d14dbdba776997ba920dd99dbbba2cf`은 기존 download platform lane을 보존하면서 Ubuntu, Windows 2025, macOS 15에서 state/runtime/transport tests를 실행하는 `distribution-owned-platform` matrix를 추가하고 `ci / build-and-test`가 이 lane 성공에 의존하도록 연결했습니다. 따라서 standalone Distribution source가 바뀌어도 protected CI의 최종 status가 해당 tests를 건너뛸 수 없습니다. +- **Unix/macOS/Linux**: pathname metadata와 opened descriptor 모두 stable `MetadataExt::nlink() == 1`이어야 합니다. Existing multi-link state는 `NotRegularFile`로 거부하고 기존 append/torn-tail truncation semantics를 유지합니다. +- **Windows**: stable Rust에서 link count를 읽는다고 가장하지 않습니다. Existing state는 read-only admission 후, mutation이 필요할 때 current committed log bytes를 sibling `.bandscope-highest-seen.next`에 `create_new`로 작성하고 `sync_all()`한 다음 `std::fs::rename()`으로 state pathname만 교체합니다. 따라서 pre-existing hard-link alias가 있더라도 alias가 가리키는 기존 file record를 truncate/append하지 않습니다. Exact-repeat with no torn tail is write-free. Recoverable tail repair도 same-path truncation 대신 synchronized snapshot replacement를 사용합니다. -## 제약과 대안 +Rust `std::fs::rename`은 destination이 존재하면 replacement semantics를 제공하며, Windows 10 1607+에서는 지원 filesystem에서 `FileRenameInfoEx` 기반으로 Unix와 같은 replacement behavior를 사용합니다. 이 선택은 nightly toolchain, shell command, unsafe FFI를 도입하지 않고 stable owner code에서 alias mutation을 피하기 위한 것입니다. -State log 자체를 lock 대상으로 쓰는 방식은 최초 state file이 아직 없을 때와 torn-tail repair에서 lifecycle이 복잡해져 기각했습니다. Process-local mutex만 두는 방식도 별도 desktop process를 직렬화하지 못하므로 기각했습니다. Blocking lock으로 무기한 대기하는 방식 대신 non-blocking `try_lock()`을 사용합니다. updater freshness mutation은 UI hot path가 아니며, lock contention은 다른 프로세스가 authority를 갱신 중이라는 명시적 상태이므로 bounded failure 후 상위 orchestration에서 재시도 여부를 결정하는 편이 낫습니다. +## RED와 수리 lineage -Lock file은 crash 뒤에도 남을 수 있지만 lock ownership은 open file handle에 결합되므로 stale pathname 자체를 writer ownership으로 간주하지 않습니다. Existing regular lock file을 다시 열어 OS lock 획득을 시도합니다. Symlink 또는 non-regular lease path는 fail closed합니다. +- `e25fd44daa62f994d40a378de8c06a87eed5fb86`: sibling lease contention에서 reader/writer가 진행하면 안 된다는 cross-process RED. +- `dd2ae7f45468ad06bf50a83d279dcb8facf1d783`: state read/repair/append 전체를 sibling OS lease로 직렬화. +- `b6334829b474a60ba2ad0e7a7d77ec93822b20f2`: pre-existing hard-link alias가 다른 pathname bytes를 변경할 수 있다는 filesystem RED. +- `4f8c9336141456ad9f669f88fa5373aa7902c9ca`: Unix/Windows link-count admission을 처음 도입했으나, Windows `number_of_links()`가 stable API가 아니라는 후속 finding이 남음. +- #1220: stable Windows CI와 nightly-only `windows_by_handle` 사용 불일치를 repair finding으로 승격. +- `e07a50ddc343a43e525ccd9c9e6c621f3abfe8df`: Windows mutation을 in-place append/truncate에서 synchronized sibling snapshot + pathname replacement로 전환하고 nightly-only metadata call을 제거. +- `53037926b0d09ccc2b05cebc5b864c47e20ae620`: direct link-count rejection contract를 Unix로 한정. +- `2f231d65c0736abfb83feb9223c008357457e5f7`: Windows에서 hard-linked existing state의 monotonic update와 torn-tail repair가 alias bytes를 변경하지 않고 state pathname만 새 committed snapshot으로 전진해야 한다는 platform contract 추가. +- `8832e62fcc2e711a325d1468f836c06ba91bca33` → `c5a09d5c0d14dbdba776997ba920dd99dbbba2cf`: state/runtime/transport standalone owners를 Ubuntu, Windows 2025, macOS 15에서 exact stable cargo tests로 실행하고 final `ci / build-and-test`가 이 lane에 의존하도록 hosted evidence graph를 수리. -Hard-link alias를 pathname canonicalization으로 찾는 방식은 기각했습니다. Canonical path는 동일 inode의 다른 directory entry 존재 여부를 증명하지 못합니다. App-local tree를 순회해 모든 alias를 찾는 방식도 동일 filesystem 전체를 증명할 수 없고 race가 남습니다. State authority 자체에서 OS metadata의 link count를 검사하는 것이 더 작고 직접적인 invariant입니다. +## 대안과 기각 이유 -Hosted verification에서는 `src-tauri` build 성공을 standalone Distribution crates의 test evidence로 간주하는 방식을 기각했습니다. 현재 이 crate들은 별도 manifests/workspaces이고, shell graph에 우연히 포함되는지 여부와 owner tests 실행 여부는 다른 계약입니다. Linux 한 플랫폼만 돌리는 방식도 state의 OS lock/link-count API와 transport/download desktop behavior를 증명하지 못하므로 기각했습니다. 기존 download platform lane을 삭제하거나 check를 약화하지 않고 별도 세 owner를 같은 3-OS matrix에 추가했습니다. +Windows CI를 nightly로 바꾸는 방식은 repository toolchain contract를 한 platform metadata helper 때문에 약화하므로 기각했습니다. Windows test만 제외하는 방식은 protected evidence를 줄이므로 기각했습니다. Link count를 확인하지 못하면서 unconditional `true`를 반환하고 기존 in-place append/truncation을 유지하는 방식도 alias mutation을 그대로 남기므로 기각했습니다. + +`fsutil`/PowerShell을 runtime에서 호출하는 방식은 locale, binary availability, process execution surface와 operational dependency를 freshness state에 추가하므로 기각했습니다. Win32 FFI를 owner code에 직접 추가하는 방식도 crate의 `unsafe_code = "forbid"` 경계를 깨므로 채택하지 않았습니다. 새로운 safe dependency를 도입하는 방안은 가능하지만 현재 문제는 std-only snapshot publication으로 해결할 수 있어 dependency-policy 비용을 만들 이유가 없습니다. + +Windows에서도 물리적 append-only file을 고집하는 방식보다, logical committed log bytes를 synchronized sibling에 작성하고 pathname replacement하는 방식을 선택했습니다. Anti-replay 판단에 필요한 것은 ordered committed records와 crash-safe previous authority 보존이지, 동일 file record에 대한 in-place append 자체가 아닙니다. ## Claim boundary와 위험 -이 변경은 cooperating BandScope processes의 highest-seen read/write를 직렬화하고, admission 시점에 이미 여러 pathname을 가진 state object를 freshness authority로 사용하지 않게 합니다. Advisory file locking을 무시하고 app-local-data directory를 직접 변조할 수 있는 동일 사용자/관리자 프로세스로부터 state를 완전히 보호한다고 주장하지 않습니다. 특히 정상 single-link file이 검사된 뒤 hostile actor가 hard link를 추가하거나 pathname을 교체하는 TOCTOU까지 이번 변경이 제거하지는 않습니다. Rust 표준 라이브러리도 filesystem operation 전반의 TOCTOU 가능성을 명시하므로 descriptor-relative/path-identity hardening과 packaged hostile-race acceptance는 후속 범위입니다. +Sibling lease는 cooperating BandScope processes를 직렬화합니다. Advisory lock을 무시하고 app-local directory를 직접 조작할 수 있는 동일 사용자/관리자 actor에 대한 sandbox 경계로 보지 않습니다. + +Unix link-count admission은 검사 시점의 pre-existing alias를 차단하지만 검사 이후 hostile hard-link/path replacement TOCTOU를 완전히 제거하지 않습니다. Windows snapshot publication은 pre-existing hard-link alias를 in-place 변경하지 않지만, sibling scratch pathname과 final rename 주변의 hostile same-user race를 cryptographic filesystem capability로 제거한 것은 아닙니다. -`try_lock()`은 2026-09 현재 Rust stable 표준 라이브러리에서 제공되며, 다른 handle/process가 lock을 보유하면 `TryLockError::WouldBlock`을 반환합니다. Rust 1.98.1의 Unix `MetadataExt`는 `nlink()`를, Windows `MetadataExt`는 `number_of_links()`를 제공하므로 supported desktop targets에서 pre-existing hard-link alias를 direct metadata로 검사할 수 있습니다. BandScope가 지원하는 desktop build는 repository의 cross-platform CI에서 이 계약을 다시 실행해야 합니다. 네트워크 filesystem/SMB/NFS는 lock/link semantics가 달라질 수 있으므로 commercial acceptance는 app-local state가 실제 supported local profile/storage에서 동작하는 조건으로 검증합니다. +Windows에서 `File::sync_all()`은 snapshot file content durability를 요청하지만 Rust std는 이번 owner에서 parent-directory durability를 Unix와 동일 방식으로 증명하지 않습니다. 따라서 packaged Windows process-kill/power-loss acceptance가 통과하기 전에는 full crash/power-loss durability를 주장하지 않습니다. Network filesystem/SMB/NFS semantics도 commercial local-profile acceptance 범위 밖에서 별도 검증이 필요합니다. -새 CI lane은 repository-hosted test execution coverage를 보장하는 source contract입니다. Exact current-head Actions가 실제로 terminal GREEN이 되기 전에는 이 문서나 workflow source만으로 Windows/macOS/Linux parity가 통과했다고 주장하지 않습니다. Packaged executable에서 process-kill/power-loss/filesystem fault를 통과했다는 증거도 아닙니다. +`load_highest_seen()`은 Windows hard-linked state를 read-only로 받아들일 수 있습니다. BandScope-owned mutation은 alias file record를 수정하지 않으며 다음 real update 또는 torn-tail repair에서 state pathname을 독립 snapshot으로 교체합니다. Same-user external alias writer가 lease를 무시하고 bytes를 동시에 바꾸는 공격은 별도 hostile-filesystem boundary입니다. ## 효과와 후속조치 -두 앱 인스턴스가 같은 authenticated release에서 출발해 동일 또는 서로 다른 다음 release를 동시에 append하여 durable log를 자가-corrupt시키는 경로와, pre-existing hard-linked state pathname을 통해 다른 alias의 bytes를 freshness repair/append가 변경하는 경로를 닫았습니다. Remote metadata authenticity, updater signature verification, exact sealed-descriptor digest/size binding, verified-artifact promotion이 완료되기 전에는 이 state writer를 production updater flow에 연결하지 않는 기존 trust order는 그대로입니다. +정상 cooperating desktop processes는 하나의 freshness authority를 순차적으로 읽고 갱신합니다. Unix는 existing multi-link state를 거부하고, Windows는 stable Rust에서 alias file record를 직접 변경하지 않는 publication semantics를 사용합니다. Replay/equivocation parser와 bounded state format은 그대로 유지됩니다. -Hosted merge evidence도 이제 네 standalone Distribution owner tests를 명시적으로 포함합니다. `distribution-download`는 기존 3-OS lane을 유지하고 state/runtime/transport는 새 3-OS lane에서 실행되며, 둘 모두 final `ci / build-and-test`의 prerequisite입니다. 후속 acceptance는 Windows/macOS에서 실제 두 프로세스 contention, process-kill 직후 lock release, torn tail + contention, hard-link fixture, pathname replacement race, power-loss/restart를 packaged build로 검증해야 합니다. Production HTTP adapter와 metadata authentication은 별도 Distribution vertical입니다. +Exact current-head Windows/macOS/Linux hosted tests가 terminal GREEN이 되기 전에는 source와 이 문서만으로 platform parity 완료를 주장하지 않습니다. #1220은 exact-head stable Windows evidence가 확인된 뒤에만 resolved 처리합니다. + +이 state owner를 production updater flow에 연결하는 순서도 바뀌지 않습니다. Remote metadata authenticity → updater artifact cryptographic signature verification → exact sealed-descriptor digest/authenticated-size binding → explicit verified-artifact promotion → anti-replay decision → highest-seen durable mutation 순입니다. Packaged multi-process contention, process-kill, restart, power-loss, disk-full과 last-known-good rollback은 별도 release acceptance가 필요합니다. ## Security Notes -Attack surface는 app-local freshness log와 sibling lease pathname입니다. Lease contention은 성공으로 우회하지 않고 `ConcurrentMutation`으로 fail closed합니다. Existing state는 symlink/non-regular뿐 아니라 multi-link object도 거부합니다. Lock file에는 release identity, project/audio path, credential, signature나 PII를 기록하지 않습니다. State log의 resource/record validation과 append durability는 유지됩니다. 이번 link-count admission은 static multi-link alias를 차단하는 통제이며 privileged/same-user hostile filesystem race에 대한 완전한 sandbox 경계로 취급하지 않습니다. +State log와 sibling lease/snapshot path에는 release identity만 저장하며 project/audio path, credential, PII, private key를 넣지 않습니다. Lease contention은 성공으로 우회하지 않습니다. Sibling snapshot은 `create_new`를 사용하고 pre-existing symlink/non-regular scratch path를 fail closed합니다. Windows fix는 stable Rust 표준 library 범위에서 동작하며 unsafe owner code, shell execution, nightly feature, 새 dependency를 추가하지 않습니다. -CI 변경은 기존 dependency/security/release gate를 우회하지 않고, 추가 Rust owner tests를 `ci / build-and-test`의 dependency로 붙입니다. Third-party Action ref나 권한을 추가하지 않았고, secret/network authority를 새로 요구하지 않습니다. +Hosted CI는 기존 gate를 제거하지 않고 standalone Distribution tests를 final `ci / build-and-test` prerequisite로 유지합니다. ## 참고문헌 Rust Project. (2026). *std::fs: Filesystem manipulation operations*. Rust 1.98.1 standard library documentation. https://doc.rust-lang.org/std/fs/ +Rust Project. (2026). *rename in std::fs*. Rust 1.98.1 standard library documentation. https://doc.rust-lang.org/std/fs/fn.rename.html + Rust Project. (2026). *MetadataExt in std::os::unix::fs*. Rust 1.98.1 standard library documentation. https://doc.rust-lang.org/std/os/unix/fs/trait.MetadataExt.html Rust Project. (2026). *MetadataExt in std::os::windows::fs*. Rust 1.98.1 standard library documentation. https://doc.rust-lang.org/std/os/windows/fs/trait.MetadataExt.html Rust Project. (2026). *TryLockError in std::fs*. Rust 1.98.1 standard library documentation. https://doc.rust-lang.org/std/fs/enum.TryLockError.html - -Kerrisk, M. (2026). *flock(2) — apply or remove an advisory lock on an open file*. Linux man-pages project. https://man7.org/linux/man-pages/man2/flock.2.html From e3313b922cc9645c03a0231cec0f78447be07019 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 02:20:44 +0900 Subject: [PATCH 237/308] docs(architecture): align updater state publication semantics --- ARCHITECTURE.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 63eea8415..73a89592b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # ARCHITECTURE.md -Last updated: 2026-09-15 +Last updated: 2026-09-16 ## Brand source @@ -62,7 +62,7 @@ Last updated: 2026-09-15 - `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 +- `apps/desktop/distribution-state` - Distribution-owned bounded highest-authenticated-release state; Unix uses a single-link append/sync log, while Windows publishes synchronized replacement snapshots to avoid mutating pre-existing hard-link aliases on stable Rust; it consumes `distribution-core` identity and never project bytes - `packages/shared-types` - stable cross-layer types shared by the UI and orchestration layer - `services/analysis-engine` - Python analysis service for source separation and music analysis - `scripts/harness` - fail-fast repo verification @@ -75,8 +75,8 @@ Last updated: 2026-09-15 - `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. +- `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. Unix cleanup unlinks the staging pathname only when the current direct regular-file `(dev, ino)` still matches the open descriptor, so an already-replaced basename is not deleted as if it were the owned artifact. It does not perform network I/O, authenticate metadata, verify signatures/digests, run installers or mutate freshness state. Commercial completion requires the production HTTP adapter to disable implicit redirects, report response state through `distribution-transport`, and route actual response chunks through this boundary rather than relying on Tauri's full-response buffering. +- `apps/desktop/distribution-state` persists only the highest authenticated release identity as a bounded ordered log under a sibling OS lease. It revalidates committed identities, rejects local version regression/equivocation, and recovers only a syntactically valid torn final-record prefix. Unix admits only a single-link state object and uses synchronized append/truncate repair. Windows does not depend on nightly-only link-count metadata: when mutation is required it writes the complete committed bounded log to a `create_new` sibling snapshot, synchronizes it, and replaces only the state pathname, preserving any pre-existing hard-link alias file record. This source-level design is not packaged Windows power-loss proof and does not claim protection from a same-user actor that ignores the advisory lease and races filesystem namespace changes. - Tauri updater signatures authenticate downloaded updater artifact bytes. They do not, by themselves, authenticate the whole `Update.raw_json` response or BandScope's `sourceCommit`/digest extensions. Remote metadata therefore stays provisional until a canonical metadata-authentication path binds its release identity to trusted authority. - Only after metadata authentication and updater artifact signature/digest/size binding may exact `version`, `sourceCommit`, updater SHA-256, target, and compatibility floor enter `distribution-core` and `distribution-state` as freshness authority. - Stable-channel automatic update decisions use canonical numeric `MAJOR.MINOR.PATCH`. Prerelease/build ordering is not approximated; a future beta channel requires a separate ADR and canonical SemVer implementation. @@ -84,7 +84,7 @@ Last updated: 2026-09-15 - 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`. +- Traceability and claim boundaries live in `docs/traceability/updater-release-admission.md`, `docs/traceability/release-artifact-receipt.md`, `docs/traceability/updater-security-metadata.md`, `docs/traceability/updater-bounded-download.md`, `docs/traceability/updater-staging-path-identity.md`, `docs/traceability/updater-highest-seen-concurrency.md`, and `docs/traceability/updater-transport-policy.md`. ## Product capability scope From 7904f1097484657083bc340a00e403b2f60c03f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 03:09:30 +0900 Subject: [PATCH 238/308] test(distribution): reject vulnerable HTTP TLS admission --- ..._distribution_http_dependency_admission.py | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 services/analysis-engine/tests/test_distribution_http_dependency_admission.py diff --git a/services/analysis-engine/tests/test_distribution_http_dependency_admission.py b/services/analysis-engine/tests/test_distribution_http_dependency_admission.py new file mode 100644 index 000000000..11b90d5b6 --- /dev/null +++ b/services/analysis-engine/tests/test_distribution_http_dependency_admission.py @@ -0,0 +1,98 @@ +"""Regression tests for Distribution HTTP dependency admission.""" + +from __future__ import annotations + +from pathlib import Path + +from conftest import load_module + +POLICY = load_module( + "scripts/checks/verify_distribution_http_dependencies.py", + "verify_distribution_http_dependencies", +) + + +def _write_fixture( + root: Path, + *, + reqwest: str | None, + rustls_version: str | None, +) -> None: + crate = root / "apps/desktop/distribution-transport" + crate.mkdir(parents=True) + dependency = reqwest or "" + (crate / "Cargo.toml").write_text( + "[package]\nname = \"fixture\"\nversion = \"0.0.0\"\n\n" + "[dependencies]\n" + f"{dependency}", + encoding="utf-8", + ) + packages = [] + if reqwest is not None: + packages.append( + '[[package]]\nname = "reqwest"\nversion = "0.13.5"\n' + 'source = "registry+https://github.com/rust-lang/crates.io-index"\n' + 'checksum = "fixture"\n' + ) + if rustls_version is not None: + packages.append( + f'[[package]]\nname = "rustls"\nversion = "{rustls_version}"\n' + 'source = "registry+https://github.com/rust-lang/crates.io-index"\n' + 'checksum = "fixture"\n' + ) + (crate / "Cargo.lock").write_text( + "version = 4\n\n" + "\n".join(packages), + encoding="utf-8", + ) + + +def test_direct_reqwest_rejects_rustls_advisory_range(tmp_path: Path) -> None: + _write_fixture( + tmp_path, + reqwest='reqwest = { version = "0.13.5", default-features = false, features = ["rustls"] }\n', + rustls_version="0.23.44", + ) + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any("RUSTSEC-2026-0285" in violation for violation in violations) + assert any("rustls >=0.23.45" in violation for violation in violations) + + +def test_direct_reqwest_accepts_patched_rustls(tmp_path: Path) -> None: + _write_fixture( + tmp_path, + reqwest='reqwest = { version = "0.13.5", default-features = false, features = ["rustls"] }\n', + rustls_version="0.23.45", + ) + + assert POLICY.verify_distribution_http_dependency_admission(tmp_path) == [] + + +def test_direct_reqwest_accepts_later_unaffected_rustls_line(tmp_path: Path) -> None: + _write_fixture( + tmp_path, + reqwest='reqwest = { version = "0.13.5", default-features = false, features = ["rustls"] }\n', + rustls_version="0.24.0", + ) + + assert POLICY.verify_distribution_http_dependency_admission(tmp_path) == [] + + +def test_unrelated_transitive_rustls_does_not_activate_distribution_gate(tmp_path: Path) -> None: + _write_fixture(tmp_path, reqwest=None, rustls_version="0.23.44") + + assert POLICY.verify_distribution_http_dependency_admission(tmp_path) == [] + + +def test_direct_reqwest_requires_explicit_tls_feature_ownership(tmp_path: Path) -> None: + _write_fixture( + tmp_path, + reqwest='reqwest = { version = "0.13.5" }\n', + rustls_version="0.23.45", + ) + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any("default features must be disabled" in violation for violation in violations) + assert any("explicitly enable the rustls feature" in violation for violation in violations) From 40496d5589f4903970fc54522256e4c31807ab44 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 03:11:29 +0900 Subject: [PATCH 239/308] fix(distribution): gate HTTP TLS dependency admission --- .github/workflows/ci.yml | 2 + .../verify_distribution_http_dependencies.py | 132 ++++++++++++++++++ ..._distribution_http_dependency_admission.py | 27 +++- 3 files changed, 156 insertions(+), 5 deletions(-) create mode 100755 scripts/checks/verify_distribution_http_dependencies.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbc6e6087..d53eb711b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,8 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false + - name: Validate Distribution HTTP dependency admission + run: python3 scripts/checks/verify_distribution_http_dependencies.py - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: "22.22.3" diff --git a/scripts/checks/verify_distribution_http_dependencies.py b/scripts/checks/verify_distribution_http_dependencies.py new file mode 100755 index 000000000..e27c8fc9b --- /dev/null +++ b/scripts/checks/verify_distribution_http_dependencies.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Fail closed when Distribution HTTP dependency admission would select an unsafe TLS graph.""" + +from __future__ import annotations + +import sys +import tomllib +from pathlib import Path + +DISTRIBUTION_TRANSPORT_MANIFEST = Path("apps/desktop/distribution-transport/Cargo.toml") +DISTRIBUTION_TRANSPORT_LOCK = Path("apps/desktop/distribution-transport/Cargo.lock") +RUSTLS_ENCRYPTION_LEVEL_ADVISORY = "RUSTSEC-2026-0285" +RUSTLS_AFFECTED_MIN = (0, 23, 13) +RUSTLS_PATCHED_MIN = (0, 23, 45) + + +def _version_triplet(raw: str) -> tuple[int, int, int] | None: + """Return the numeric core used by the advisory range.""" + core = raw.split("+", 1)[0].split("-", 1)[0] + parts = core.split(".") + if len(parts) != 3 or any(not part.isdigit() for part in parts): + return None + major, minor, patch = (int(part) for part in parts) + return major, minor, patch + + +def _is_affected_rustls(raw: str) -> bool: + """Return whether a rustls version is inside RUSTSEC-2026-0285's affected range.""" + version = _version_triplet(raw) + return version is not None and RUSTLS_AFFECTED_MIN <= version < RUSTLS_PATCHED_MIN + + +def verify_distribution_http_dependency_admission(repo_root: Path) -> list[str]: + """Verify direct reqwest admission before the production Distribution client compiles.""" + manifest_path = repo_root / DISTRIBUTION_TRANSPORT_MANIFEST + lock_path = repo_root / DISTRIBUTION_TRANSPORT_LOCK + manifest = tomllib.loads(manifest_path.read_text(encoding="utf-8")) + reqwest = manifest.get("dependencies", {}).get("reqwest") + if reqwest is None: + return [] + + violations: list[str] = [] + if not isinstance(reqwest, dict): + violations.append( + f"{DISTRIBUTION_TRANSPORT_MANIFEST}: direct reqwest must use a table with " + 'default-features = false and features = ["rustls"]' + ) + else: + if reqwest.get("default-features") is not False: + violations.append( + f"{DISTRIBUTION_TRANSPORT_MANIFEST}: reqwest default features must be " + "disabled so TLS/backend features are never selected implicitly" + ) + features = reqwest.get("features", []) + if not isinstance(features, list) or "rustls" not in features: + violations.append( + f"{DISTRIBUTION_TRANSPORT_MANIFEST}: reqwest must explicitly enable " + "the rustls feature" + ) + forbidden = { + "default-tls", + "native-tls", + "native-tls-no-alpn", + "native-tls-vendored", + } + selected = sorted(forbidden.intersection(features if isinstance(features, list) else [])) + if selected: + violations.append( + f"{DISTRIBUTION_TRANSPORT_MANIFEST}: reqwest must not enable alternate " + f"or default TLS features: {', '.join(selected)}" + ) + + if not lock_path.exists(): + violations.append( + f"{DISTRIBUTION_TRANSPORT_LOCK}: direct reqwest requires a committed " + "standalone lockfile" + ) + return violations + + lock = tomllib.loads(lock_path.read_text(encoding="utf-8")) + packages = lock.get("package", []) + reqwest_versions = [ + str(package.get("version", "")) + for package in packages + if package.get("name") == "reqwest" + ] + if not reqwest_versions: + violations.append( + f"{DISTRIBUTION_TRANSPORT_LOCK}: direct reqwest is missing from the " + "committed lock graph" + ) + + rustls_versions = [ + str(package.get("version", "")) + for package in packages + if package.get("name") == "rustls" + ] + if not rustls_versions: + violations.append( + f"{DISTRIBUTION_TRANSPORT_LOCK}: reqwest rustls backend is selected but " + "rustls is absent" + ) + return violations + + for version in rustls_versions: + if _version_triplet(version) is None: + violations.append( + f"{DISTRIBUTION_TRANSPORT_LOCK}: cannot parse rustls version {version!r}" + ) + elif _is_affected_rustls(version): + violations.append( + f"{DISTRIBUTION_TRANSPORT_LOCK}: rustls {version} is affected by " + f"{RUSTLS_ENCRYPTION_LEVEL_ADVISORY}; use rustls >=0.23.45 or an " + "unaffected line" + ) + return violations + + +def main() -> int: + """Run the repository-root Distribution dependency admission gate.""" + repo_root = Path(__file__).resolve().parents[2] + violations = verify_distribution_http_dependency_admission(repo_root) + if violations: + for violation in violations: + print(f"ERROR: {violation}", file=sys.stderr) + return 1 + print("Distribution HTTP dependency admission: PASS") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/services/analysis-engine/tests/test_distribution_http_dependency_admission.py b/services/analysis-engine/tests/test_distribution_http_dependency_admission.py index 11b90d5b6..b14b84118 100644 --- a/services/analysis-engine/tests/test_distribution_http_dependency_admission.py +++ b/services/analysis-engine/tests/test_distribution_http_dependency_admission.py @@ -18,11 +18,12 @@ def _write_fixture( reqwest: str | None, rustls_version: str | None, ) -> None: + """Write the smallest standalone Distribution transport dependency graph.""" crate = root / "apps/desktop/distribution-transport" crate.mkdir(parents=True) dependency = reqwest or "" (crate / "Cargo.toml").write_text( - "[package]\nname = \"fixture\"\nversion = \"0.0.0\"\n\n" + '[package]\nname = "fixture"\nversion = "0.0.0"\n\n' "[dependencies]\n" f"{dependency}", encoding="utf-8", @@ -47,9 +48,13 @@ def _write_fixture( def test_direct_reqwest_rejects_rustls_advisory_range(tmp_path: Path) -> None: + """Reject the affected rustls 0.23.13 through 0.23.44 range.""" _write_fixture( tmp_path, - reqwest='reqwest = { version = "0.13.5", default-features = false, features = ["rustls"] }\n', + reqwest=( + 'reqwest = { version = "0.13.5", default-features = false, ' + 'features = ["rustls"] }\n' + ), rustls_version="0.23.44", ) @@ -60,9 +65,13 @@ def test_direct_reqwest_rejects_rustls_advisory_range(tmp_path: Path) -> None: def test_direct_reqwest_accepts_patched_rustls(tmp_path: Path) -> None: + """Accept the first patched rustls 0.23 release.""" _write_fixture( tmp_path, - reqwest='reqwest = { version = "0.13.5", default-features = false, features = ["rustls"] }\n', + reqwest=( + 'reqwest = { version = "0.13.5", default-features = false, ' + 'features = ["rustls"] }\n' + ), rustls_version="0.23.45", ) @@ -70,22 +79,30 @@ def test_direct_reqwest_accepts_patched_rustls(tmp_path: Path) -> None: def test_direct_reqwest_accepts_later_unaffected_rustls_line(tmp_path: Path) -> None: + """Do not freeze the gate to the 0.23 minor line.""" _write_fixture( tmp_path, - reqwest='reqwest = { version = "0.13.5", default-features = false, features = ["rustls"] }\n', + reqwest=( + 'reqwest = { version = "0.13.5", default-features = false, ' + 'features = ["rustls"] }\n' + ), rustls_version="0.24.0", ) assert POLICY.verify_distribution_http_dependency_admission(tmp_path) == [] -def test_unrelated_transitive_rustls_does_not_activate_distribution_gate(tmp_path: Path) -> None: +def test_unrelated_transitive_rustls_does_not_activate_distribution_gate( + tmp_path: Path, +) -> None: + """Scope the gate to the Distribution transport crate's direct HTTP client.""" _write_fixture(tmp_path, reqwest=None, rustls_version="0.23.44") assert POLICY.verify_distribution_http_dependency_admission(tmp_path) == [] def test_direct_reqwest_requires_explicit_tls_feature_ownership(tmp_path: Path) -> None: + """Reject reqwest's implicit default TLS/backend feature selection.""" _write_fixture( tmp_path, reqwest='reqwest = { version = "0.13.5" }\n', From 117962a7d5d80e971d835fc42e96edacbe4b86c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 03:12:18 +0900 Subject: [PATCH 240/308] docs(distribution): trace HTTP TLS dependency admission --- .../distribution-http-dependency-admission.md | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 docs/traceability/distribution-http-dependency-admission.md diff --git a/docs/traceability/distribution-http-dependency-admission.md b/docs/traceability/distribution-http-dependency-admission.md new file mode 100644 index 000000000..c678d2eb9 --- /dev/null +++ b/docs/traceability/distribution-http-dependency-admission.md @@ -0,0 +1,58 @@ +# Distribution HTTP dependency admission traceability + +Status: implemented pre-compilation dependency gate; production HTTP adapter remains pending. + +## Problem + +BandScope's Distribution transport policy is ready to consume real HTTP response state, but the production client dependency has not yet been admitted. That dependency decision became security-relevant on 2026-09-14 when RustSec published `RUSTSEC-2026-0285` for `rustls`: TLS 1.3 handshake messages could be accepted across an encryption-level transition when they followed a key-changing message in the same record. RustSec marks `rustls >=0.23.13,<0.23.45` as affected and `>=0.23.45` as patched. + +The planned client, `reqwest`, enables `default-tls` through its default feature set. In reqwest 0.13.5, the default set also includes HTTP/2 and system-proxy behavior. Reqwest documents that `default-tls` currently selects rustls but is intentionally backend-agnostic and can take precedence when another crate enables it. A Distribution-owned release path therefore cannot treat `reqwest = "..."` or an affected rustls lock as an acceptable implementation shortcut. + +This finding does not prove that an existing transitive `rustls` entry elsewhere in BandScope is exploitable. The gate is deliberately activated when `apps/desktop/distribution-transport/Cargo.toml` acquires a direct `reqwest` dependency, because that is the repository-owned production HTTP boundary being prepared here. + +## Constraints + +- The production updater client must remain in the Distribution bounded context and must not reimplement metadata, project persistence, authentication, or installer ownership. +- A direct `reqwest` dependency must use table syntax with `default-features = false` and explicitly select the `rustls` feature. +- `default-tls` and `native-tls*` features are rejected for this owner because they weaken exact backend evidence across Windows, macOS, and Linux. +- The standalone `apps/desktop/distribution-transport/Cargo.lock` is authoritative for the HTTP adapter's resolved Rust graph. +- If that standalone graph contains `rustls >=0.23.13,<0.23.45`, CI must fail with `RUSTSEC-2026-0285` before any Distribution platform build starts. +- A missing reqwest or rustls lock entry after direct reqwest admission is a failure, not an implicit resolver fallback. +- Later unaffected rustls lines remain admissible; the check encodes the advisory range rather than freezing BandScope to one minor release. +- The dependency gate does not replace `cargo audit`, GitHub dependency review, OSV, SBOM generation, or release-time artifact provenance. +- HTTP behavior remains a separate code contract. The eventual `reqwest::ClientBuilder` must disable automatic redirects and gzip/brotli/zstd/deflate decoding even if Cargo feature unification enables those capabilities elsewhere. + +## Alternatives considered + +Using reqwest defaults was rejected because the default TLS backend is intentionally not a stable backend-selection contract and the default feature set admits network behavior that BandScope has not explicitly accepted. Selecting native TLS was rejected for this owner because it produces materially different TLS stacks on Windows, macOS, and Linux and would make cross-platform release evidence harder to compare. Admitting an affected rustls lock with a local exception was rejected because a patched release already exists, so the repository's vulnerability-exception rule does not apply. + +Rejecting every rustls version below 0.23.45 was also rejected. RustSec explicitly lists versions below 0.23.13 as unaffected by this advisory, and future 0.24+ lines should not fail a check written for a 0.23 advisory. The selected check therefore models the published affected interval exactly. + +Scanning every Cargo.lock in the repository and treating any affected transitive rustls as proof that the new updater client is vulnerable was rejected as an ownership error. The executable admission is tied to the standalone Distribution transport manifest and lock. Existing transitive graphs remain covered by the repository-wide supply-chain controls and require their own exposure analysis if an advisory is reported there. + +## Selected design + +`scripts/checks/verify_distribution_http_dependencies.py` is a dependency-free Python 3 gate using `tomllib`. With no direct reqwest dependency in the Distribution transport manifest it returns success and does not infer exposure from unrelated graphs. Once reqwest is declared directly, it requires explicit rustls backend ownership, a committed standalone lock containing reqwest and rustls, and rejects every locked rustls version inside the `RUSTSEC-2026-0285` affected interval. + +`.github/workflows/ci.yml` runs this check in `lock-validation` immediately after checkout. The Distribution Windows/macOS/Linux Rust jobs depend on that job, so an unsafe future HTTP graph is rejected before those crates compile rather than after a platform matrix has already exercised it. + +The regression suite uses synthetic Cargo manifest/lock fixtures only for policy-unit coverage. It proves rejection of rustls 0.23.44, acceptance of 0.23.45 and 0.24.0, non-activation when Distribution has no direct reqwest dependency, and rejection of implicit reqwest TLS feature selection. Those fixtures are not production networking evidence. + +## RED -> repair evidence + +- `7904f1097484657083bc340a00e403b2f60c03f2` introduced the dependency-admission regression contract before the checker existed. The predecessor therefore had no repository-controlled rule capable of rejecting a future direct reqwest graph on `RUSTSEC-2026-0285` grounds. +- `40496d5589f4903970fc54522256e4c31807ab44` added the fail-closed checker, corrected the regression fixtures for repository docstring/lint policy, and wired the gate before Distribution platform compilation. + +Hosted exact-head checks remain authoritative for repository integration. This source-level gate does not claim that the production HTTP adapter exists, that remote metadata is authenticated, that updater artifact signatures have been verified, or that current packaged Windows/macOS network behavior is release-ready. + +## Follow-up + +The next Distribution implementation may add reqwest only together with a lock graph that passes this admission. The client must then use explicit rustls backend selection, no implicit redirects, no transparent response decompression, bounded response streaming into `distribution-download`, and realistic network/cancel/disk-full/captive-portal evidence. Cryptographic metadata and sealed-descriptor promotion remain subsequent gates. + +## References + +RustSec. (2026, September 14). *RUSTSEC-2026-0285: rustls: TLS 1.3 handshake messages incorrectly accepted across encryption level boundaries*. RustSec Advisory Database. https://rustsec.org/advisories/RUSTSEC-2026-0285.html + +Reqwest project. (2026). *TLS configuration and types (reqwest 0.13.5)*. Docs.rs. https://docs.rs/reqwest/0.13.5/reqwest/tls/ + +Reqwest project. (2026). *ClientBuilder (reqwest 0.13.5)*. Docs.rs. https://docs.rs/reqwest/0.13.5/reqwest/struct.ClientBuilder.html From 3def1e1f65dd30bc72d6b6eb409282aaddac4789 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 03:13:11 +0900 Subject: [PATCH 241/308] fix(ci): run Distribution HTTP gate in quickcheck --- scripts/harness/quickcheck.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/harness/quickcheck.sh b/scripts/harness/quickcheck.sh index 22ba2b31a..b2d6f0a64 100755 --- a/scripts/harness/quickcheck.sh +++ b/scripts/harness/quickcheck.sh @@ -8,6 +8,7 @@ python3 scripts/checks/verify_docs.py python3 scripts/checks/verify_security_notes.py python3 scripts/checks/security_gates.py python3 scripts/checks/verify_supply_chain.py +python3 scripts/checks/verify_distribution_http_dependencies.py python3 scripts/checks/verify_github_bootstrap_policy.py python3 scripts/checks/verify_release_identity.py npm run lint From 5f81143b59456a876e18b2879dc78af6ac2c3c1e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 04:01:57 +0900 Subject: [PATCH 242/308] test(distribution): reject vendored native TLS no-ALPN feature --- ...t_distribution_http_dependency_admission.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/services/analysis-engine/tests/test_distribution_http_dependency_admission.py b/services/analysis-engine/tests/test_distribution_http_dependency_admission.py index b14b84118..ff0b15c98 100644 --- a/services/analysis-engine/tests/test_distribution_http_dependency_admission.py +++ b/services/analysis-engine/tests/test_distribution_http_dependency_admission.py @@ -113,3 +113,21 @@ def test_direct_reqwest_requires_explicit_tls_feature_ownership(tmp_path: Path) assert any("default features must be disabled" in violation for violation in violations) assert any("explicitly enable the rustls feature" in violation for violation in violations) + + +def test_direct_reqwest_rejects_vendored_native_tls_without_alpn(tmp_path: Path) -> None: + """Reject the final reqwest native-TLS feature spelling, not just its siblings.""" + _write_fixture( + tmp_path, + reqwest=( + 'reqwest = { version = "0.13.5", default-features = false, ' + 'features = ["rustls", "native-tls-vendored-no-alpn"] }\n' + ), + rustls_version="0.23.45", + ) + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any( + "native-tls-vendored-no-alpn" in violation for violation in violations + ) From b26fc7ddc8895c06618b25485000b3f97f7619d9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 04:02:12 +0900 Subject: [PATCH 243/308] fix(distribution): reject every native TLS reqwest feature --- scripts/checks/verify_distribution_http_dependencies.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/checks/verify_distribution_http_dependencies.py b/scripts/checks/verify_distribution_http_dependencies.py index e27c8fc9b..75606839b 100755 --- a/scripts/checks/verify_distribution_http_dependencies.py +++ b/scripts/checks/verify_distribution_http_dependencies.py @@ -62,6 +62,7 @@ def verify_distribution_http_dependency_admission(repo_root: Path) -> list[str]: "native-tls", "native-tls-no-alpn", "native-tls-vendored", + "native-tls-vendored-no-alpn", } selected = sorted(forbidden.intersection(features if isinstance(features, list) else [])) if selected: From fc22b4688e7718243d11891a4638bfd1e30081a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 04:02:42 +0900 Subject: [PATCH 244/308] docs(distribution): trace complete reqwest TLS feature admission --- .../distribution-http-dependency-admission.md | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/docs/traceability/distribution-http-dependency-admission.md b/docs/traceability/distribution-http-dependency-admission.md index c678d2eb9..3f9392cf8 100644 --- a/docs/traceability/distribution-http-dependency-admission.md +++ b/docs/traceability/distribution-http-dependency-admission.md @@ -8,51 +8,60 @@ BandScope's Distribution transport policy is ready to consume real HTTP response The planned client, `reqwest`, enables `default-tls` through its default feature set. In reqwest 0.13.5, the default set also includes HTTP/2 and system-proxy behavior. Reqwest documents that `default-tls` currently selects rustls but is intentionally backend-agnostic and can take precedence when another crate enables it. A Distribution-owned release path therefore cannot treat `reqwest = "..."` or an affected rustls lock as an acceptable implementation shortcut. +Reqwest 0.13.5 exposes four native-TLS feature spellings: `native-tls`, `native-tls-no-alpn`, `native-tls-vendored`, and `native-tls-vendored-no-alpn`. The initial admission checker rejected the first three but accidentally omitted `native-tls-vendored-no-alpn`, so a future manifest could satisfy the explicit `rustls` requirement while simultaneously selecting a second TLS backend through that spelling. The gate now treats the published reqwest feature surface as an exhaustive alternate-backend set instead of relying on a partial prefix convention. + This finding does not prove that an existing transitive `rustls` entry elsewhere in BandScope is exploitable. The gate is deliberately activated when `apps/desktop/distribution-transport/Cargo.toml` acquires a direct `reqwest` dependency, because that is the repository-owned production HTTP boundary being prepared here. ## Constraints - The production updater client must remain in the Distribution bounded context and must not reimplement metadata, project persistence, authentication, or installer ownership. - A direct `reqwest` dependency must use table syntax with `default-features = false` and explicitly select the `rustls` feature. -- `default-tls` and `native-tls*` features are rejected for this owner because they weaken exact backend evidence across Windows, macOS, and Linux. +- `default-tls`, `native-tls`, `native-tls-no-alpn`, `native-tls-vendored`, and `native-tls-vendored-no-alpn` are rejected for this owner because they weaken exact backend evidence across Windows, macOS, and Linux. - The standalone `apps/desktop/distribution-transport/Cargo.lock` is authoritative for the HTTP adapter's resolved Rust graph. - If that standalone graph contains `rustls >=0.23.13,<0.23.45`, CI must fail with `RUSTSEC-2026-0285` before any Distribution platform build starts. - A missing reqwest or rustls lock entry after direct reqwest admission is a failure, not an implicit resolver fallback. - Later unaffected rustls lines remain admissible; the check encodes the advisory range rather than freezing BandScope to one minor release. - The dependency gate does not replace `cargo audit`, GitHub dependency review, OSV, SBOM generation, or release-time artifact provenance. -- HTTP behavior remains a separate code contract. The eventual `reqwest::ClientBuilder` must disable automatic redirects and gzip/brotli/zstd/deflate decoding even if Cargo feature unification enables those capabilities elsewhere. +- HTTP behavior remains a separate code contract. The eventual `reqwest::ClientBuilder` must explicitly select the rustls backend at runtime and disable automatic redirects and gzip/brotli/zstd/deflate decoding even if Cargo feature unification enables additional capabilities elsewhere. ## Alternatives considered Using reqwest defaults was rejected because the default TLS backend is intentionally not a stable backend-selection contract and the default feature set admits network behavior that BandScope has not explicitly accepted. Selecting native TLS was rejected for this owner because it produces materially different TLS stacks on Windows, macOS, and Linux and would make cross-platform release evidence harder to compare. Admitting an affected rustls lock with a local exception was rejected because a patched release already exists, so the repository's vulnerability-exception rule does not apply. +Treating `native-tls*` as a documentation shorthand without testing every current reqwest feature spelling was rejected after review of reqwest 0.13.5's published feature table. Cargo features are exact strings and additive; omitting one valid alternate-backend feature makes the admission rule bypassable even when the prose says `native-tls*` is forbidden. + Rejecting every rustls version below 0.23.45 was also rejected. RustSec explicitly lists versions below 0.23.13 as unaffected by this advisory, and future 0.24+ lines should not fail a check written for a 0.23 advisory. The selected check therefore models the published affected interval exactly. Scanning every Cargo.lock in the repository and treating any affected transitive rustls as proof that the new updater client is vulnerable was rejected as an ownership error. The executable admission is tied to the standalone Distribution transport manifest and lock. Existing transitive graphs remain covered by the repository-wide supply-chain controls and require their own exposure analysis if an advisory is reported there. ## Selected design -`scripts/checks/verify_distribution_http_dependencies.py` is a dependency-free Python 3 gate using `tomllib`. With no direct reqwest dependency in the Distribution transport manifest it returns success and does not infer exposure from unrelated graphs. Once reqwest is declared directly, it requires explicit rustls backend ownership, a committed standalone lock containing reqwest and rustls, and rejects every locked rustls version inside the `RUSTSEC-2026-0285` affected interval. +`scripts/checks/verify_distribution_http_dependencies.py` is a dependency-free Python 3 gate using `tomllib`. With no direct reqwest dependency in the Distribution transport manifest it returns success and does not infer exposure from unrelated graphs. Once reqwest is declared directly, it requires explicit rustls backend ownership, rejects every published reqwest 0.13.5 default/native-TLS alternate feature, requires a committed standalone lock containing reqwest and rustls, and rejects every locked rustls version inside the `RUSTSEC-2026-0285` affected interval. -`.github/workflows/ci.yml` runs this check in `lock-validation` immediately after checkout. The Distribution Windows/macOS/Linux Rust jobs depend on that job, so an unsafe future HTTP graph is rejected before those crates compile rather than after a platform matrix has already exercised it. +`.github/workflows/ci.yml` runs this check in `lock-validation` immediately after checkout. The Distribution Windows/macOS/Linux Rust jobs depend on that job, so an unsafe future HTTP graph is rejected before those crates compile rather than after a platform matrix has already exercised it. `scripts/harness/quickcheck.sh` invokes the same checker so local canonical validation and hosted admission share one rule. -The regression suite uses synthetic Cargo manifest/lock fixtures only for policy-unit coverage. It proves rejection of rustls 0.23.44, acceptance of 0.23.45 and 0.24.0, non-activation when Distribution has no direct reqwest dependency, and rejection of implicit reqwest TLS feature selection. Those fixtures are not production networking evidence. +The regression suite uses synthetic Cargo manifest/lock fixtures only for policy-unit coverage. It proves rejection of rustls 0.23.44, acceptance of 0.23.45 and 0.24.0, non-activation when Distribution has no direct reqwest dependency, rejection of implicit reqwest TLS feature selection, and rejection of the previously omitted `native-tls-vendored-no-alpn` spelling. Those fixtures are not production networking evidence. ## RED -> repair evidence - `7904f1097484657083bc340a00e403b2f60c03f2` introduced the dependency-admission regression contract before the checker existed. The predecessor therefore had no repository-controlled rule capable of rejecting a future direct reqwest graph on `RUSTSEC-2026-0285` grounds. - `40496d5589f4903970fc54522256e4c31807ab44` added the fail-closed checker, corrected the regression fixtures for repository docstring/lint policy, and wired the gate before Distribution platform compilation. +- `3def1e1f65dd30bc72d6b6eb409282aaddac4789` wired the same dependency gate into canonical local quickcheck so local and hosted policy could not drift. +- `5f81143b59456a876e18b2879dc78af6ac2c3c1e` added a RED contract for reqwest 0.13.5's `native-tls-vendored-no-alpn` feature, which the then-current forbidden set did not detect. +- `b26fc7ddc8895c06618b25485000b3f97f7619d9` completed the alternate-backend feature set and makes that manifest fail admission. Hosted exact-head checks remain authoritative for repository integration. This source-level gate does not claim that the production HTTP adapter exists, that remote metadata is authenticated, that updater artifact signatures have been verified, or that current packaged Windows/macOS network behavior is release-ready. ## Follow-up -The next Distribution implementation may add reqwest only together with a lock graph that passes this admission. The client must then use explicit rustls backend selection, no implicit redirects, no transparent response decompression, bounded response streaming into `distribution-download`, and realistic network/cancel/disk-full/captive-portal evidence. Cryptographic metadata and sealed-descriptor promotion remain subsequent gates. +The next Distribution implementation may add reqwest only together with a lock graph that passes this admission. The client must then use explicit `ClientBuilder::tls_backend_rustls()` selection, no implicit redirects, no transparent response decompression, bounded response streaming into `distribution-download`, and realistic network/cancel/disk-full/captive-portal evidence. Cryptographic metadata and sealed-descriptor promotion remain subsequent gates. ## References RustSec. (2026, September 14). *RUSTSEC-2026-0285: rustls: TLS 1.3 handshake messages incorrectly accepted across encryption level boundaries*. RustSec Advisory Database. https://rustsec.org/advisories/RUSTSEC-2026-0285.html +Reqwest project. (2026). *Cargo feature table (reqwest 0.13.5)*. Docs.rs. https://docs.rs/crate/reqwest/0.13.5/source/Cargo.toml.orig + Reqwest project. (2026). *TLS configuration and types (reqwest 0.13.5)*. Docs.rs. https://docs.rs/reqwest/0.13.5/reqwest/tls/ Reqwest project. (2026). *ClientBuilder (reqwest 0.13.5)*. Docs.rs. https://docs.rs/reqwest/0.13.5/reqwest/struct.ClientBuilder.html From 621bac6b543255e1fd6c01ce03068c8870a43773 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 05:03:20 +0900 Subject: [PATCH 245/308] test(distribution): reject implicit reqwest decoding features --- ...t_distribution_http_dependency_admission.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/services/analysis-engine/tests/test_distribution_http_dependency_admission.py b/services/analysis-engine/tests/test_distribution_http_dependency_admission.py index ff0b15c98..2c4ba2c32 100644 --- a/services/analysis-engine/tests/test_distribution_http_dependency_admission.py +++ b/services/analysis-engine/tests/test_distribution_http_dependency_admission.py @@ -131,3 +131,21 @@ def test_direct_reqwest_rejects_vendored_native_tls_without_alpn(tmp_path: Path) assert any( "native-tls-vendored-no-alpn" in violation for violation in violations ) + + +def test_direct_reqwest_rejects_transparent_response_decoding_feature( + tmp_path: Path, +) -> None: + """Reject feature-level body transforms before exact updater bytes reach admission.""" + _write_fixture( + tmp_path, + reqwest=( + 'reqwest = { version = "0.13.5", default-features = false, ' + 'features = ["rustls", "gzip"] }\n' + ), + rustls_version="0.23.45", + ) + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any("gzip" in violation for violation in violations) From 43d786362a15afa23824878091c2edea07b474bd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 05:03:41 +0900 Subject: [PATCH 246/308] fix(distribution): allow-list reqwest transport features --- .../verify_distribution_http_dependencies.py | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/scripts/checks/verify_distribution_http_dependencies.py b/scripts/checks/verify_distribution_http_dependencies.py index 75606839b..68a01c70a 100755 --- a/scripts/checks/verify_distribution_http_dependencies.py +++ b/scripts/checks/verify_distribution_http_dependencies.py @@ -12,6 +12,7 @@ RUSTLS_ENCRYPTION_LEVEL_ADVISORY = "RUSTSEC-2026-0285" RUSTLS_AFFECTED_MIN = (0, 23, 13) RUSTLS_PATCHED_MIN = (0, 23, 45) +REQWEST_APPROVED_FEATURES = frozenset({"rustls"}) def _version_triplet(raw: str) -> tuple[int, int, int] | None: @@ -52,24 +53,25 @@ def verify_distribution_http_dependency_admission(repo_root: Path) -> list[str]: "disabled so TLS/backend features are never selected implicitly" ) features = reqwest.get("features", []) - if not isinstance(features, list) or "rustls" not in features: + if ( + not isinstance(features, list) + or not all(isinstance(feature, str) for feature in features) + or "rustls" not in features + ): violations.append( f"{DISTRIBUTION_TRANSPORT_MANIFEST}: reqwest must explicitly enable " "the rustls feature" ) - forbidden = { - "default-tls", - "native-tls", - "native-tls-no-alpn", - "native-tls-vendored", - "native-tls-vendored-no-alpn", - } - selected = sorted(forbidden.intersection(features if isinstance(features, list) else [])) - if selected: - violations.append( - f"{DISTRIBUTION_TRANSPORT_MANIFEST}: reqwest must not enable alternate " - f"or default TLS features: {', '.join(selected)}" - ) + if isinstance(features, list) and all( + isinstance(feature, str) for feature in features + ): + unapproved = sorted(set(features).difference(REQWEST_APPROVED_FEATURES)) + if unapproved: + violations.append( + f"{DISTRIBUTION_TRANSPORT_MANIFEST}: reqwest must use only the " + "approved Distribution feature set (rustls); unapproved features: " + f"{', '.join(unapproved)}" + ) if not lock_path.exists(): violations.append( From eb10409b07d26247d6060466b93ad37ec02f9870 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 05:04:06 +0900 Subject: [PATCH 247/308] test(distribution): cover reqwest feature allow-list --- ..._distribution_http_dependency_admission.py | 37 ++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/services/analysis-engine/tests/test_distribution_http_dependency_admission.py b/services/analysis-engine/tests/test_distribution_http_dependency_admission.py index 2c4ba2c32..4f24669f9 100644 --- a/services/analysis-engine/tests/test_distribution_http_dependency_admission.py +++ b/services/analysis-engine/tests/test_distribution_http_dependency_admission.py @@ -133,19 +133,30 @@ def test_direct_reqwest_rejects_vendored_native_tls_without_alpn(tmp_path: Path) ) -def test_direct_reqwest_rejects_transparent_response_decoding_feature( - tmp_path: Path, -) -> None: - """Reject feature-level body transforms before exact updater bytes reach admission.""" - _write_fixture( - tmp_path, - reqwest=( - 'reqwest = { version = "0.13.5", default-features = false, ' - 'features = ["rustls", "gzip"] }\n' - ), - rustls_version="0.23.45", +def test_direct_reqwest_rejects_unapproved_transport_features(tmp_path: Path) -> None: + """Keep updater transport semantics explicit instead of activating optional behavior.""" + unapproved_features = ( + "gzip", + "brotli", + "zstd", + "deflate", + "system-proxy", + "socks", + "hickory-dns", + "http2", + "http3", ) + for feature in unapproved_features: + fixture = tmp_path / feature + _write_fixture( + fixture, + reqwest=( + 'reqwest = { version = "0.13.5", default-features = false, ' + f'features = ["rustls", "{feature}"] }}\n' + ), + rustls_version="0.23.45", + ) - violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + violations = POLICY.verify_distribution_http_dependency_admission(fixture) - assert any("gzip" in violation for violation in violations) + assert any(feature in violation for violation in violations), feature From 372dd9ddf3679cc1be07c98ef3540dde68a15176 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 05:04:34 +0900 Subject: [PATCH 248/308] docs(distribution): make reqwest feature admission explicit --- .../distribution-http-dependency-admission.md | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/docs/traceability/distribution-http-dependency-admission.md b/docs/traceability/distribution-http-dependency-admission.md index 3f9392cf8..9d51e408c 100644 --- a/docs/traceability/distribution-http-dependency-admission.md +++ b/docs/traceability/distribution-http-dependency-admission.md @@ -8,27 +8,32 @@ BandScope's Distribution transport policy is ready to consume real HTTP response The planned client, `reqwest`, enables `default-tls` through its default feature set. In reqwest 0.13.5, the default set also includes HTTP/2 and system-proxy behavior. Reqwest documents that `default-tls` currently selects rustls but is intentionally backend-agnostic and can take precedence when another crate enables it. A Distribution-owned release path therefore cannot treat `reqwest = "..."` or an affected rustls lock as an acceptable implementation shortcut. -Reqwest 0.13.5 exposes four native-TLS feature spellings: `native-tls`, `native-tls-no-alpn`, `native-tls-vendored`, and `native-tls-vendored-no-alpn`. The initial admission checker rejected the first three but accidentally omitted `native-tls-vendored-no-alpn`, so a future manifest could satisfy the explicit `rustls` requirement while simultaneously selecting a second TLS backend through that spelling. The gate now treats the published reqwest feature surface as an exhaustive alternate-backend set instead of relying on a partial prefix convention. +Reqwest 0.13.5 exposes optional behavior that changes updater transport semantics before BandScope sees exact response bytes: gzip, Brotli, Zstandard and deflate response decoding; system/SOCKS proxy routing; alternate DNS resolution; and HTTP/2 or experimental HTTP/3 protocol activation. `Response::chunk()` is available without reqwest's optional `stream` feature, so the updater transport has no current requirement for any direct reqwest feature beyond `rustls`. The dependency gate therefore uses an allow-list rather than trying to maintain independent deny-lists for TLS, decompression, proxy and protocol features. + +The preceding checker used a narrower TLS deny-list. It correctly rejected the affected rustls interval and the known native-TLS feature spellings, but a future manifest such as `features = ["rustls", "gzip"]` would still have passed pre-compilation admission. Reqwest documents that enabling `gzip`, `brotli`, `zstd` or `deflate` turns automatic response decompression on by default and can remove `Content-Encoding` and `Content-Length` before application code observes the response. That is incompatible with BandScope's requirement that `distribution-transport` admit the exact wire response metadata and that `distribution-download` receive the exact updater artifact bytes. This finding does not prove that an existing transitive `rustls` entry elsewhere in BandScope is exploitable. The gate is deliberately activated when `apps/desktop/distribution-transport/Cargo.toml` acquires a direct `reqwest` dependency, because that is the repository-owned production HTTP boundary being prepared here. ## Constraints - The production updater client must remain in the Distribution bounded context and must not reimplement metadata, project persistence, authentication, or installer ownership. -- A direct `reqwest` dependency must use table syntax with `default-features = false` and explicitly select the `rustls` feature. -- `default-tls`, `native-tls`, `native-tls-no-alpn`, `native-tls-vendored`, and `native-tls-vendored-no-alpn` are rejected for this owner because they weaken exact backend evidence across Windows, macOS, and Linux. +- A direct `reqwest` dependency must use table syntax with `default-features = false` and exactly the approved direct feature set: `features = ["rustls"]`. +- Additional direct reqwest features are rejected until a concrete Distribution requirement, threat analysis, tests and traceability justify widening the allow-list. This currently rejects native/default TLS alternatives, transparent decompression, proxy, alternate DNS and HTTP/2/HTTP/3 feature activation at the owner manifest. +- Runtime code must still call `ClientBuilder::tls_backend_rustls()`. Cargo features are additive across the dependency graph, so the manifest allow-list is not a substitute for explicit runtime backend selection. +- Runtime code must also call `no_gzip()`, `no_brotli()`, `no_zstd()`, `no_deflate()`, `redirect(Policy::none())` and `no_proxy()`. Reqwest intentionally provides the `no_*` decompression methods even when the corresponding optional feature is not selected so an additive transitive feature cannot silently change client behavior. - The standalone `apps/desktop/distribution-transport/Cargo.lock` is authoritative for the HTTP adapter's resolved Rust graph. - If that standalone graph contains `rustls >=0.23.13,<0.23.45`, CI must fail with `RUSTSEC-2026-0285` before any Distribution platform build starts. - A missing reqwest or rustls lock entry after direct reqwest admission is a failure, not an implicit resolver fallback. - Later unaffected rustls lines remain admissible; the check encodes the advisory range rather than freezing BandScope to one minor release. - The dependency gate does not replace `cargo audit`, GitHub dependency review, OSV, SBOM generation, or release-time artifact provenance. -- HTTP behavior remains a separate code contract. The eventual `reqwest::ClientBuilder` must explicitly select the rustls backend at runtime and disable automatic redirects and gzip/brotli/zstd/deflate decoding even if Cargo feature unification enables additional capabilities elsewhere. ## Alternatives considered Using reqwest defaults was rejected because the default TLS backend is intentionally not a stable backend-selection contract and the default feature set admits network behavior that BandScope has not explicitly accepted. Selecting native TLS was rejected for this owner because it produces materially different TLS stacks on Windows, macOS, and Linux and would make cross-platform release evidence harder to compare. Admitting an affected rustls lock with a local exception was rejected because a patched release already exists, so the repository's vulnerability-exception rule does not apply. -Treating `native-tls*` as a documentation shorthand without testing every current reqwest feature spelling was rejected after review of reqwest 0.13.5's published feature table. Cargo features are exact strings and additive; omitting one valid alternate-backend feature makes the admission rule bypassable even when the prose says `native-tls*` is forbidden. +Maintaining separate forbidden-feature sets for native TLS, decompression, proxies and protocols was rejected after the feature review. Reqwest features are additive and its public feature surface can grow; a deny-list fails open whenever a newly relevant feature is omitted. The selected allow-list has the inverse property: a new direct feature requires an explicit repository decision before it can enter the release transport. + +Enabling reqwest's optional `stream` feature was rejected for the current adapter design because `Response::chunk()` already provides bounded asynchronous chunk retrieval without that feature. Avoiding `stream` also avoids an unnecessary `futures`/`tokio-util` surface in this small security-sensitive owner. Rejecting every rustls version below 0.23.45 was also rejected. RustSec explicitly lists versions below 0.23.13 as unaffected by this advisory, and future 0.24+ lines should not fail a check written for a 0.23 advisory. The selected check therefore models the published affected interval exactly. @@ -36,11 +41,11 @@ Scanning every Cargo.lock in the repository and treating any affected transitive ## Selected design -`scripts/checks/verify_distribution_http_dependencies.py` is a dependency-free Python 3 gate using `tomllib`. With no direct reqwest dependency in the Distribution transport manifest it returns success and does not infer exposure from unrelated graphs. Once reqwest is declared directly, it requires explicit rustls backend ownership, rejects every published reqwest 0.13.5 default/native-TLS alternate feature, requires a committed standalone lock containing reqwest and rustls, and rejects every locked rustls version inside the `RUSTSEC-2026-0285` affected interval. +`scripts/checks/verify_distribution_http_dependencies.py` is a dependency-free Python 3 gate using `tomllib`. With no direct reqwest dependency in the Distribution transport manifest it returns success and does not infer exposure from unrelated graphs. Once reqwest is declared directly, it requires explicit rustls backend ownership, requires the direct reqwest feature set to be exactly `rustls`, requires a committed standalone lock containing reqwest and rustls, and rejects every locked rustls version inside the `RUSTSEC-2026-0285` affected interval. `.github/workflows/ci.yml` runs this check in `lock-validation` immediately after checkout. The Distribution Windows/macOS/Linux Rust jobs depend on that job, so an unsafe future HTTP graph is rejected before those crates compile rather than after a platform matrix has already exercised it. `scripts/harness/quickcheck.sh` invokes the same checker so local canonical validation and hosted admission share one rule. -The regression suite uses synthetic Cargo manifest/lock fixtures only for policy-unit coverage. It proves rejection of rustls 0.23.44, acceptance of 0.23.45 and 0.24.0, non-activation when Distribution has no direct reqwest dependency, rejection of implicit reqwest TLS feature selection, and rejection of the previously omitted `native-tls-vendored-no-alpn` spelling. Those fixtures are not production networking evidence. +The regression suite uses synthetic Cargo manifest/lock fixtures only for policy-unit coverage. It proves rejection of rustls 0.23.44, acceptance of 0.23.45 and 0.24.0, non-activation when Distribution has no direct reqwest dependency, rejection of implicit reqwest TLS feature selection, and rejection of unapproved direct transport features including gzip/Brotli/Zstandard/deflate decoding, system/SOCKS proxy, alternate DNS, HTTP/2 and HTTP/3. Those fixtures are not production networking evidence. ## RED -> repair evidence @@ -48,20 +53,25 @@ The regression suite uses synthetic Cargo manifest/lock fixtures only for policy - `40496d5589f4903970fc54522256e4c31807ab44` added the fail-closed checker, corrected the regression fixtures for repository docstring/lint policy, and wired the gate before Distribution platform compilation. - `3def1e1f65dd30bc72d6b6eb409282aaddac4789` wired the same dependency gate into canonical local quickcheck so local and hosted policy could not drift. - `5f81143b59456a876e18b2879dc78af6ac2c3c1e` added a RED contract for reqwest 0.13.5's `native-tls-vendored-no-alpn` feature, which the then-current forbidden set did not detect. -- `b26fc7ddc8895c06618b25485000b3f97f7619d9` completed the alternate-backend feature set and makes that manifest fail admission. +- `b26fc7ddc8895c06618b25485000b3f97f7619d9` completed that alternate-backend deny-list. +- `621bac6b543255e1fd6c01ce03068c8870a43773` added a RED contract proving that the narrower checker still admitted `features = ["rustls", "gzip"]`, even though gzip activation can transparently transform response bytes and strip transport headers before BandScope admission. +- `43d786362a15afa23824878091c2edea07b474bd` replaced feature-specific deny-listing with the exact direct reqwest allow-list `{"rustls"}`. +- `eb10409b07d26247d6060466b93ad37ec02f9870` expanded edge coverage across transparent decompression, proxy, DNS and HTTP protocol feature classes so a future widening of the allow-list is an explicit contract change. Hosted exact-head checks remain authoritative for repository integration. This source-level gate does not claim that the production HTTP adapter exists, that remote metadata is authenticated, that updater artifact signatures have been verified, or that current packaged Windows/macOS network behavior is release-ready. ## Follow-up -The next Distribution implementation may add reqwest only together with a lock graph that passes this admission. The client must then use explicit `ClientBuilder::tls_backend_rustls()` selection, no implicit redirects, no transparent response decompression, bounded response streaming into `distribution-download`, and realistic network/cancel/disk-full/captive-portal evidence. Cryptographic metadata and sealed-descriptor promotion remain subsequent gates. +The next Distribution implementation may add reqwest only together with a lock graph that passes this admission. The client must then use explicit `ClientBuilder::tls_backend_rustls()`, `redirect(Policy::none())`, `no_gzip()`, `no_brotli()`, `no_zstd()`, `no_deflate()` and `no_proxy()`, preserve exact status/effective URL/Location/Content-Encoding, stream bounded response chunks through `Response::chunk()` into `distribution-download`, and produce realistic network/cancel/disk-full/captive-portal evidence. Cryptographic metadata and sealed-descriptor promotion remain subsequent gates. ## References RustSec. (2026, September 14). *RUSTSEC-2026-0285: rustls: TLS 1.3 handshake messages incorrectly accepted across encryption level boundaries*. RustSec Advisory Database. https://rustsec.org/advisories/RUSTSEC-2026-0285.html -Reqwest project. (2026). *Cargo feature table (reqwest 0.13.5)*. Docs.rs. https://docs.rs/crate/reqwest/0.13.5/source/Cargo.toml.orig +Reqwest project. (2026). *Cargo feature table (reqwest 0.13.5)*. Docs.rs. https://docs.rs/crate/reqwest/0.13.5/features Reqwest project. (2026). *TLS configuration and types (reqwest 0.13.5)*. Docs.rs. https://docs.rs/reqwest/0.13.5/reqwest/tls/ +Reqwest project. (2026). *Response (reqwest 0.13.5)*. Docs.rs. https://docs.rs/reqwest/0.13.5/reqwest/struct.Response.html + Reqwest project. (2026). *ClientBuilder (reqwest 0.13.5)*. Docs.rs. https://docs.rs/reqwest/0.13.5/reqwest/struct.ClientBuilder.html From d36c4d647ace77d8a0d68a2e3690460cadd980ed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 05:08:51 +0900 Subject: [PATCH 249/308] test(distribution): reject target-specific reqwest bypass --- ..._distribution_http_dependency_admission.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/services/analysis-engine/tests/test_distribution_http_dependency_admission.py b/services/analysis-engine/tests/test_distribution_http_dependency_admission.py index 4f24669f9..9b451cbf6 100644 --- a/services/analysis-engine/tests/test_distribution_http_dependency_admission.py +++ b/services/analysis-engine/tests/test_distribution_http_dependency_admission.py @@ -160,3 +160,33 @@ def test_direct_reqwest_rejects_unapproved_transport_features(tmp_path: Path) -> violations = POLICY.verify_distribution_http_dependency_admission(fixture) assert any(feature in violation for violation in violations), feature + + +def test_target_specific_reqwest_cannot_bypass_direct_dependency_admission( + tmp_path: Path, +) -> None: + """Treat target-scoped runtime reqwest declarations as direct owner dependencies.""" + _write_fixture(tmp_path, reqwest=None, rustls_version="0.23.45") + manifest = ( + tmp_path / "apps/desktop/distribution-transport/Cargo.toml" + ) + manifest.write_text( + manifest.read_text(encoding="utf-8") + + '\n[target.\'cfg(windows)\'.dependencies]\n' + + 'reqwest = { version = "0.13.5", default-features = false, ' + + 'features = ["rustls", "gzip"] }\n', + encoding="utf-8", + ) + lock = tmp_path / "apps/desktop/distribution-transport/Cargo.lock" + lock.write_text( + lock.read_text(encoding="utf-8") + + '\n[[package]]\nname = "reqwest"\nversion = "0.13.5"\n' + + 'source = "registry+https://github.com/rust-lang/crates.io-index"\n' + + 'checksum = "fixture"\n', + encoding="utf-8", + ) + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any("gzip" in violation for violation in violations) + assert any("cfg(windows)" in violation for violation in violations) From da209f160bcf4e81cc70cf4760ad658c9d78c679 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 05:09:10 +0900 Subject: [PATCH 250/308] fix(distribution): inspect target-scoped reqwest dependencies --- .../verify_distribution_http_dependencies.py | 89 ++++++++++++------- 1 file changed, 56 insertions(+), 33 deletions(-) diff --git a/scripts/checks/verify_distribution_http_dependencies.py b/scripts/checks/verify_distribution_http_dependencies.py index 68a01c70a..3b49d2562 100755 --- a/scripts/checks/verify_distribution_http_dependencies.py +++ b/scripts/checks/verify_distribution_http_dependencies.py @@ -6,6 +6,7 @@ import sys import tomllib from pathlib import Path +from typing import Any DISTRIBUTION_TRANSPORT_MANIFEST = Path("apps/desktop/distribution-transport/Cargo.toml") DISTRIBUTION_TRANSPORT_LOCK = Path("apps/desktop/distribution-transport/Cargo.lock") @@ -31,47 +32,69 @@ def _is_affected_rustls(raw: str) -> bool: return version is not None and RUSTLS_AFFECTED_MIN <= version < RUSTLS_PATCHED_MIN +def _direct_reqwest_declarations(manifest: dict[str, Any]) -> list[tuple[str, Any]]: + """Return runtime reqwest declarations from unconditional and target-scoped dependencies.""" + declarations: list[tuple[str, Any]] = [] + dependencies = manifest.get("dependencies", {}) + if isinstance(dependencies, dict) and "reqwest" in dependencies: + declarations.append(("dependencies.reqwest", dependencies["reqwest"])) + + targets = manifest.get("target", {}) + if isinstance(targets, dict): + for selector, target_table in targets.items(): + if not isinstance(target_table, dict): + continue + target_dependencies = target_table.get("dependencies", {}) + if isinstance(target_dependencies, dict) and "reqwest" in target_dependencies: + declarations.append( + (f"target.{selector}.dependencies.reqwest", target_dependencies["reqwest"]) + ) + return declarations + + +def _validate_reqwest_declaration(location: str, reqwest: Any) -> list[str]: + """Validate one direct runtime reqwest declaration against owner policy.""" + prefix = f"{DISTRIBUTION_TRANSPORT_MANIFEST} [{location}]" + violations: list[str] = [] + if not isinstance(reqwest, dict): + return [ + f"{prefix}: direct reqwest must use a table with " + 'default-features = false and features = ["rustls"]' + ] + + if reqwest.get("default-features") is not False: + violations.append( + f"{prefix}: reqwest default features must be disabled so TLS/backend " + "features are never selected implicitly" + ) + features = reqwest.get("features", []) + valid_feature_list = isinstance(features, list) and all( + isinstance(feature, str) for feature in features + ) + if not valid_feature_list or "rustls" not in features: + violations.append(f"{prefix}: reqwest must explicitly enable the rustls feature") + if valid_feature_list: + unapproved = sorted(set(features).difference(REQWEST_APPROVED_FEATURES)) + if unapproved: + violations.append( + f"{prefix}: reqwest must use only the approved Distribution feature set " + f"(rustls); unapproved features: {', '.join(unapproved)}" + ) + return violations + + def verify_distribution_http_dependency_admission(repo_root: Path) -> list[str]: """Verify direct reqwest admission before the production Distribution client compiles.""" manifest_path = repo_root / DISTRIBUTION_TRANSPORT_MANIFEST lock_path = repo_root / DISTRIBUTION_TRANSPORT_LOCK manifest = tomllib.loads(manifest_path.read_text(encoding="utf-8")) - reqwest = manifest.get("dependencies", {}).get("reqwest") - if reqwest is None: + declarations = _direct_reqwest_declarations(manifest) + if not declarations: return [] violations: list[str] = [] - if not isinstance(reqwest, dict): - violations.append( - f"{DISTRIBUTION_TRANSPORT_MANIFEST}: direct reqwest must use a table with " - 'default-features = false and features = ["rustls"]' - ) - else: - if reqwest.get("default-features") is not False: - violations.append( - f"{DISTRIBUTION_TRANSPORT_MANIFEST}: reqwest default features must be " - "disabled so TLS/backend features are never selected implicitly" - ) - features = reqwest.get("features", []) - if ( - not isinstance(features, list) - or not all(isinstance(feature, str) for feature in features) - or "rustls" not in features - ): - violations.append( - f"{DISTRIBUTION_TRANSPORT_MANIFEST}: reqwest must explicitly enable " - "the rustls feature" - ) - if isinstance(features, list) and all( - isinstance(feature, str) for feature in features - ): - unapproved = sorted(set(features).difference(REQWEST_APPROVED_FEATURES)) - if unapproved: - violations.append( - f"{DISTRIBUTION_TRANSPORT_MANIFEST}: reqwest must use only the " - "approved Distribution feature set (rustls); unapproved features: " - f"{', '.join(unapproved)}" - ) + for location, reqwest in declarations: + violations.extend(_validate_reqwest_declaration(location, reqwest)) if not lock_path.exists(): violations.append( From 2b10fe9922cedddbff81e54337d2f3d3e36b79f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 05:09:42 +0900 Subject: [PATCH 251/308] docs(distribution): cover target-scoped dependency admission --- .../distribution-http-dependency-admission.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/traceability/distribution-http-dependency-admission.md b/docs/traceability/distribution-http-dependency-admission.md index 9d51e408c..c2e94303f 100644 --- a/docs/traceability/distribution-http-dependency-admission.md +++ b/docs/traceability/distribution-http-dependency-admission.md @@ -12,12 +12,15 @@ Reqwest 0.13.5 exposes optional behavior that changes updater transport semantic The preceding checker used a narrower TLS deny-list. It correctly rejected the affected rustls interval and the known native-TLS feature spellings, but a future manifest such as `features = ["rustls", "gzip"]` would still have passed pre-compilation admission. Reqwest documents that enabling `gzip`, `brotli`, `zstd` or `deflate` turns automatic response decompression on by default and can remove `Content-Encoding` and `Content-Length` before application code observes the response. That is incompatible with BandScope's requirement that `distribution-transport` admit the exact wire response metadata and that `distribution-download` receive the exact updater artifact bytes. -This finding does not prove that an existing transitive `rustls` entry elsewhere in BandScope is exploitable. The gate is deliberately activated when `apps/desktop/distribution-transport/Cargo.toml` acquires a direct `reqwest` dependency, because that is the repository-owned production HTTP boundary being prepared here. +A second review of the executable gate found an ownership-location bypass after the feature allow-list was added. The checker originally read only top-level `[dependencies].reqwest`. Cargo permits normal runtime dependencies under target-scoped tables such as `[target.'cfg(windows)'.dependencies]`; a future Windows-only or macOS-only reqwest declaration there would have activated the production client while the checker returned early as if Distribution had no direct HTTP dependency. Target-scoped normal dependencies therefore belong to the same owner admission surface as unconditional normal dependencies. + +This finding does not prove that an existing transitive `rustls` entry elsewhere in BandScope is exploitable. The gate is deliberately activated when `apps/desktop/distribution-transport/Cargo.toml` acquires a direct runtime `reqwest` dependency, unconditional or target-scoped, because that is the repository-owned production HTTP boundary being prepared here. ## Constraints - The production updater client must remain in the Distribution bounded context and must not reimplement metadata, project persistence, authentication, or installer ownership. -- A direct `reqwest` dependency must use table syntax with `default-features = false` and exactly the approved direct feature set: `features = ["rustls"]`. +- Any direct runtime `reqwest` dependency, whether under top-level `[dependencies]` or a Cargo `[target..dependencies]` table, must use table syntax with `default-features = false` and exactly the approved direct feature set: `features = ["rustls"]`. +- `dev-dependencies` and `build-dependencies` do not activate the production client and are not treated as runtime owner declarations by this gate. - Additional direct reqwest features are rejected until a concrete Distribution requirement, threat analysis, tests and traceability justify widening the allow-list. This currently rejects native/default TLS alternatives, transparent decompression, proxy, alternate DNS and HTTP/2/HTTP/3 feature activation at the owner manifest. - Runtime code must still call `ClientBuilder::tls_backend_rustls()`. Cargo features are additive across the dependency graph, so the manifest allow-list is not a substitute for explicit runtime backend selection. - Runtime code must also call `no_gzip()`, `no_brotli()`, `no_zstd()`, `no_deflate()`, `redirect(Policy::none())` and `no_proxy()`. Reqwest intentionally provides the `no_*` decompression methods even when the corresponding optional feature is not selected so an additive transitive feature cannot silently change client behavior. @@ -33,6 +36,8 @@ Using reqwest defaults was rejected because the default TLS backend is intention Maintaining separate forbidden-feature sets for native TLS, decompression, proxies and protocols was rejected after the feature review. Reqwest features are additive and its public feature surface can grow; a deny-list fails open whenever a newly relevant feature is omitted. The selected allow-list has the inverse property: a new direct feature requires an explicit repository decision before it can enter the release transport. +Inspecting only top-level `[dependencies]` was rejected because Cargo target tables can declare the same normal runtime dependency for one platform. Distribution supports Windows and macOS explicitly; platform scoping changes where the dependency is declared, not who owns its network/TLS semantics. The checker therefore enumerates both unconditional and target-scoped normal dependency tables and applies the same declaration policy to each one. + Enabling reqwest's optional `stream` feature was rejected for the current adapter design because `Response::chunk()` already provides bounded asynchronous chunk retrieval without that feature. Avoiding `stream` also avoids an unnecessary `futures`/`tokio-util` surface in this small security-sensitive owner. Rejecting every rustls version below 0.23.45 was also rejected. RustSec explicitly lists versions below 0.23.13 as unaffected by this advisory, and future 0.24+ lines should not fail a check written for a 0.23 advisory. The selected check therefore models the published affected interval exactly. @@ -41,11 +46,11 @@ Scanning every Cargo.lock in the repository and treating any affected transitive ## Selected design -`scripts/checks/verify_distribution_http_dependencies.py` is a dependency-free Python 3 gate using `tomllib`. With no direct reqwest dependency in the Distribution transport manifest it returns success and does not infer exposure from unrelated graphs. Once reqwest is declared directly, it requires explicit rustls backend ownership, requires the direct reqwest feature set to be exactly `rustls`, requires a committed standalone lock containing reqwest and rustls, and rejects every locked rustls version inside the `RUSTSEC-2026-0285` affected interval. +`scripts/checks/verify_distribution_http_dependencies.py` is a dependency-free Python 3 gate using `tomllib`. It enumerates direct runtime reqwest declarations from top-level `[dependencies]` and every `[target..dependencies]` table. If none exists it returns success and does not infer exposure from unrelated graphs. Once any direct runtime reqwest declaration exists, every declaration must explicitly own the rustls backend and use exactly the direct feature set `{rustls}`; the gate then requires a committed standalone lock containing reqwest and rustls and rejects every locked rustls version inside the `RUSTSEC-2026-0285` affected interval. `.github/workflows/ci.yml` runs this check in `lock-validation` immediately after checkout. The Distribution Windows/macOS/Linux Rust jobs depend on that job, so an unsafe future HTTP graph is rejected before those crates compile rather than after a platform matrix has already exercised it. `scripts/harness/quickcheck.sh` invokes the same checker so local canonical validation and hosted admission share one rule. -The regression suite uses synthetic Cargo manifest/lock fixtures only for policy-unit coverage. It proves rejection of rustls 0.23.44, acceptance of 0.23.45 and 0.24.0, non-activation when Distribution has no direct reqwest dependency, rejection of implicit reqwest TLS feature selection, and rejection of unapproved direct transport features including gzip/Brotli/Zstandard/deflate decoding, system/SOCKS proxy, alternate DNS, HTTP/2 and HTTP/3. Those fixtures are not production networking evidence. +The regression suite uses synthetic Cargo manifest/lock fixtures only for policy-unit coverage. It proves rejection of rustls 0.23.44, acceptance of 0.23.45 and 0.24.0, non-activation when Distribution has no direct reqwest dependency, rejection of implicit reqwest TLS feature selection, rejection of unapproved direct transport features including gzip/Brotli/Zstandard/deflate decoding, system/SOCKS proxy, alternate DNS, HTTP/2 and HTTP/3, and rejection of the same invalid reqwest declaration when it is moved under a Windows target dependency table. Those fixtures are not production networking evidence. ## RED -> repair evidence @@ -57,6 +62,8 @@ The regression suite uses synthetic Cargo manifest/lock fixtures only for policy - `621bac6b543255e1fd6c01ce03068c8870a43773` added a RED contract proving that the narrower checker still admitted `features = ["rustls", "gzip"]`, even though gzip activation can transparently transform response bytes and strip transport headers before BandScope admission. - `43d786362a15afa23824878091c2edea07b474bd` replaced feature-specific deny-listing with the exact direct reqwest allow-list `{"rustls"}`. - `eb10409b07d26247d6060466b93ad37ec02f9870` expanded edge coverage across transparent decompression, proxy, DNS and HTTP protocol feature classes so a future widening of the allow-list is an explicit contract change. +- `d36c4d647ace77d8a0d68a2e3690460cadd980ed` added a RED contract moving the invalid `rustls + gzip` declaration under `[target.'cfg(windows)'.dependencies]`; the preceding checker treated the manifest as having no direct reqwest dependency. +- `da209f160bcf4e81cc70cf4760ad658c9d78c679` makes target-scoped normal runtime reqwest declarations enter the same fail-closed owner admission as top-level declarations. Hosted exact-head checks remain authoritative for repository integration. This source-level gate does not claim that the production HTTP adapter exists, that remote metadata is authenticated, that updater artifact signatures have been verified, or that current packaged Windows/macOS network behavior is release-ready. From c58ed766a5875e7fa0af11031ed7300b314ce49c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 05:12:10 +0900 Subject: [PATCH 252/308] style(distribution): keep dependency admission formatter-safe --- scripts/checks/verify_distribution_http_dependencies.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/checks/verify_distribution_http_dependencies.py b/scripts/checks/verify_distribution_http_dependencies.py index 3b49d2562..deb7f5f06 100755 --- a/scripts/checks/verify_distribution_http_dependencies.py +++ b/scripts/checks/verify_distribution_http_dependencies.py @@ -47,7 +47,10 @@ def _direct_reqwest_declarations(manifest: dict[str, Any]) -> list[tuple[str, An target_dependencies = target_table.get("dependencies", {}) if isinstance(target_dependencies, dict) and "reqwest" in target_dependencies: declarations.append( - (f"target.{selector}.dependencies.reqwest", target_dependencies["reqwest"]) + ( + f"target.{selector}.dependencies.reqwest", + target_dependencies["reqwest"], + ) ) return declarations From 7330af6e40a6ba5b4c49c9de32abf291dd7c515b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 06:03:58 +0900 Subject: [PATCH 253/308] test(distribution): reject renamed reqwest admission bypass --- ..._distribution_http_dependency_admission.py | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/services/analysis-engine/tests/test_distribution_http_dependency_admission.py b/services/analysis-engine/tests/test_distribution_http_dependency_admission.py index 9b451cbf6..337ff2437 100644 --- a/services/analysis-engine/tests/test_distribution_http_dependency_admission.py +++ b/services/analysis-engine/tests/test_distribution_http_dependency_admission.py @@ -167,9 +167,7 @@ def test_target_specific_reqwest_cannot_bypass_direct_dependency_admission( ) -> None: """Treat target-scoped runtime reqwest declarations as direct owner dependencies.""" _write_fixture(tmp_path, reqwest=None, rustls_version="0.23.45") - manifest = ( - tmp_path / "apps/desktop/distribution-transport/Cargo.toml" - ) + manifest = tmp_path / "apps/desktop/distribution-transport/Cargo.toml" manifest.write_text( manifest.read_text(encoding="utf-8") + '\n[target.\'cfg(windows)\'.dependencies]\n' @@ -190,3 +188,30 @@ def test_target_specific_reqwest_cannot_bypass_direct_dependency_admission( assert any("gzip" in violation for violation in violations) assert any("cfg(windows)" in violation for violation in violations) + + +def test_renamed_reqwest_cannot_bypass_direct_dependency_admission( + tmp_path: Path, +) -> None: + """Treat Cargo package aliases as direct reqwest ownership.""" + _write_fixture(tmp_path, reqwest=None, rustls_version="0.23.45") + manifest = tmp_path / "apps/desktop/distribution-transport/Cargo.toml" + manifest.write_text( + manifest.read_text(encoding="utf-8") + + 'distribution_http = { package = "reqwest", version = "0.13.5", ' + + 'default-features = false, features = ["rustls", "gzip"] }\n', + encoding="utf-8", + ) + lock = tmp_path / "apps/desktop/distribution-transport/Cargo.lock" + lock.write_text( + lock.read_text(encoding="utf-8") + + '\n[[package]]\nname = "reqwest"\nversion = "0.13.5"\n' + + 'source = "registry+https://github.com/rust-lang/crates.io-index"\n' + + 'checksum = "fixture"\n', + encoding="utf-8", + ) + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any("gzip" in violation for violation in violations) + assert any("distribution_http" in violation for violation in violations) From 5356dcfa9a1a75c7cecdf6d185e7359cc056929a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 06:04:18 +0900 Subject: [PATCH 254/308] fix(distribution): admit renamed reqwest by package identity --- .../verify_distribution_http_dependencies.py | 44 ++++++++++++++----- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/scripts/checks/verify_distribution_http_dependencies.py b/scripts/checks/verify_distribution_http_dependencies.py index deb7f5f06..a971208fd 100755 --- a/scripts/checks/verify_distribution_http_dependencies.py +++ b/scripts/checks/verify_distribution_http_dependencies.py @@ -32,26 +32,46 @@ def _is_affected_rustls(raw: str) -> bool: return version is not None and RUSTLS_AFFECTED_MIN <= version < RUSTLS_PATCHED_MIN +def _dependency_package_name(dependency_name: str, declaration: Any) -> Any: + """Return the Cargo package selected by one dependency key or rename.""" + if isinstance(declaration, dict) and "package" in declaration: + return declaration["package"] + return dependency_name + + +def _reqwest_declarations_in_table( + dependencies: Any, + *, + location_prefix: str, +) -> list[tuple[str, Any]]: + """Return reqwest package declarations from one normal dependency table.""" + if not isinstance(dependencies, dict): + return [] + return [ + (f"{location_prefix}.{dependency_name}", declaration) + for dependency_name, declaration in dependencies.items() + if _dependency_package_name(dependency_name, declaration) == "reqwest" + ] + + def _direct_reqwest_declarations(manifest: dict[str, Any]) -> list[tuple[str, Any]]: - """Return runtime reqwest declarations from unconditional and target-scoped dependencies.""" - declarations: list[tuple[str, Any]] = [] - dependencies = manifest.get("dependencies", {}) - if isinstance(dependencies, dict) and "reqwest" in dependencies: - declarations.append(("dependencies.reqwest", dependencies["reqwest"])) + """Return runtime reqwest packages from unconditional and target-scoped dependencies.""" + declarations = _reqwest_declarations_in_table( + manifest.get("dependencies", {}), + location_prefix="dependencies", + ) targets = manifest.get("target", {}) if isinstance(targets, dict): for selector, target_table in targets.items(): if not isinstance(target_table, dict): continue - target_dependencies = target_table.get("dependencies", {}) - if isinstance(target_dependencies, dict) and "reqwest" in target_dependencies: - declarations.append( - ( - f"target.{selector}.dependencies.reqwest", - target_dependencies["reqwest"], - ) + declarations.extend( + _reqwest_declarations_in_table( + target_table.get("dependencies", {}), + location_prefix=f"target.{selector}.dependencies", ) + ) return declarations From 49b84ebc97379574d3fdc7d1e371b804015ab4f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 06:05:19 +0900 Subject: [PATCH 255/308] docs(distribution): trace Cargo reqwest alias admission --- .../distribution-http-dependency-admission.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/traceability/distribution-http-dependency-admission.md b/docs/traceability/distribution-http-dependency-admission.md index c2e94303f..a4f823114 100644 --- a/docs/traceability/distribution-http-dependency-admission.md +++ b/docs/traceability/distribution-http-dependency-admission.md @@ -14,12 +14,14 @@ The preceding checker used a narrower TLS deny-list. It correctly rejected the a A second review of the executable gate found an ownership-location bypass after the feature allow-list was added. The checker originally read only top-level `[dependencies].reqwest`. Cargo permits normal runtime dependencies under target-scoped tables such as `[target.'cfg(windows)'.dependencies]`; a future Windows-only or macOS-only reqwest declaration there would have activated the production client while the checker returned early as if Distribution had no direct HTTP dependency. Target-scoped normal dependencies therefore belong to the same owner admission surface as unconditional normal dependencies. -This finding does not prove that an existing transitive `rustls` entry elsewhere in BandScope is exploitable. The gate is deliberately activated when `apps/desktop/distribution-transport/Cargo.toml` acquires a direct runtime `reqwest` dependency, unconditional or target-scoped, because that is the repository-owned production HTTP boundary being prepared here. +Fresh review then found a third bypass in dependency identity. Cargo explicitly permits a dependency key to differ from the package name by using `package = "..."`. For example, `distribution_http = { package = "reqwest", ... }` is still a direct runtime dependency on the reqwest package. The previous checker matched only the TOML key `reqwest`, so a renamed reqwest dependency could make the gate return early and skip the feature/TLS/lock admission entirely. The gate must therefore identify owner dependencies by Cargo package identity, not by the local dependency alias. + +This finding does not prove that an existing transitive `rustls` entry elsewhere in BandScope is exploitable. The gate is deliberately activated when `apps/desktop/distribution-transport/Cargo.toml` acquires a direct runtime `reqwest` package dependency, unconditional or target-scoped and regardless of its local Cargo alias, because that is the repository-owned production HTTP boundary being prepared here. ## Constraints - The production updater client must remain in the Distribution bounded context and must not reimplement metadata, project persistence, authentication, or installer ownership. -- Any direct runtime `reqwest` dependency, whether under top-level `[dependencies]` or a Cargo `[target..dependencies]` table, must use table syntax with `default-features = false` and exactly the approved direct feature set: `features = ["rustls"]`. +- Any direct runtime reqwest package dependency, whether under top-level `[dependencies]` or a Cargo `[target..dependencies]` table and whether named `reqwest` locally or renamed with `package = "reqwest"`, must use table syntax with `default-features = false` and exactly the approved direct feature set: `features = ["rustls"]`. - `dev-dependencies` and `build-dependencies` do not activate the production client and are not treated as runtime owner declarations by this gate. - Additional direct reqwest features are rejected until a concrete Distribution requirement, threat analysis, tests and traceability justify widening the allow-list. This currently rejects native/default TLS alternatives, transparent decompression, proxy, alternate DNS and HTTP/2/HTTP/3 feature activation at the owner manifest. - Runtime code must still call `ClientBuilder::tls_backend_rustls()`. Cargo features are additive across the dependency graph, so the manifest allow-list is not a substitute for explicit runtime backend selection. @@ -38,6 +40,8 @@ Maintaining separate forbidden-feature sets for native TLS, decompression, proxi Inspecting only top-level `[dependencies]` was rejected because Cargo target tables can declare the same normal runtime dependency for one platform. Distribution supports Windows and macOS explicitly; platform scoping changes where the dependency is declared, not who owns its network/TLS semantics. The checker therefore enumerates both unconditional and target-scoped normal dependency tables and applies the same declaration policy to each one. +Matching only a dependency key literally named `reqwest` was rejected because Cargo's `package` key is the authoritative package-selection mechanism when a dependency is renamed. A local alias changes the Rust/Cargo-facing dependency name, not the upstream package being introduced into the Distribution runtime graph. The checker therefore resolves each normal dependency declaration to its Cargo package name first and applies reqwest policy whenever that package name is `reqwest`. + Enabling reqwest's optional `stream` feature was rejected for the current adapter design because `Response::chunk()` already provides bounded asynchronous chunk retrieval without that feature. Avoiding `stream` also avoids an unnecessary `futures`/`tokio-util` surface in this small security-sensitive owner. Rejecting every rustls version below 0.23.45 was also rejected. RustSec explicitly lists versions below 0.23.13 as unaffected by this advisory, and future 0.24+ lines should not fail a check written for a 0.23 advisory. The selected check therefore models the published affected interval exactly. @@ -46,11 +50,11 @@ Scanning every Cargo.lock in the repository and treating any affected transitive ## Selected design -`scripts/checks/verify_distribution_http_dependencies.py` is a dependency-free Python 3 gate using `tomllib`. It enumerates direct runtime reqwest declarations from top-level `[dependencies]` and every `[target..dependencies]` table. If none exists it returns success and does not infer exposure from unrelated graphs. Once any direct runtime reqwest declaration exists, every declaration must explicitly own the rustls backend and use exactly the direct feature set `{rustls}`; the gate then requires a committed standalone lock containing reqwest and rustls and rejects every locked rustls version inside the `RUSTSEC-2026-0285` affected interval. +`scripts/checks/verify_distribution_http_dependencies.py` is a dependency-free Python 3 gate using `tomllib`. It enumerates direct runtime dependency declarations from top-level `[dependencies]` and every `[target..dependencies]` table, resolves each declaration's package identity using Cargo's `package` rename semantics, and selects every declaration whose package is `reqwest`. If none exists it returns success and does not infer exposure from unrelated graphs. Once any direct runtime reqwest package declaration exists, every declaration must explicitly own the rustls backend and use exactly the direct feature set `{rustls}`; the gate then requires a committed standalone lock containing reqwest and rustls and rejects every locked rustls version inside the `RUSTSEC-2026-0285` affected interval. `.github/workflows/ci.yml` runs this check in `lock-validation` immediately after checkout. The Distribution Windows/macOS/Linux Rust jobs depend on that job, so an unsafe future HTTP graph is rejected before those crates compile rather than after a platform matrix has already exercised it. `scripts/harness/quickcheck.sh` invokes the same checker so local canonical validation and hosted admission share one rule. -The regression suite uses synthetic Cargo manifest/lock fixtures only for policy-unit coverage. It proves rejection of rustls 0.23.44, acceptance of 0.23.45 and 0.24.0, non-activation when Distribution has no direct reqwest dependency, rejection of implicit reqwest TLS feature selection, rejection of unapproved direct transport features including gzip/Brotli/Zstandard/deflate decoding, system/SOCKS proxy, alternate DNS, HTTP/2 and HTTP/3, and rejection of the same invalid reqwest declaration when it is moved under a Windows target dependency table. Those fixtures are not production networking evidence. +The regression suite uses synthetic Cargo manifest/lock fixtures only for policy-unit coverage. It proves rejection of rustls 0.23.44, acceptance of 0.23.45 and 0.24.0, non-activation when Distribution has no direct reqwest dependency, rejection of implicit reqwest TLS feature selection, rejection of unapproved direct transport features including gzip/Brotli/Zstandard/deflate decoding, system/SOCKS proxy, alternate DNS, HTTP/2 and HTTP/3, rejection of the same invalid reqwest declaration when it is moved under a Windows target dependency table, and rejection when the reqwest package is renamed to another local dependency key. Those fixtures are not production networking evidence. ## RED -> repair evidence @@ -64,6 +68,8 @@ The regression suite uses synthetic Cargo manifest/lock fixtures only for policy - `eb10409b07d26247d6060466b93ad37ec02f9870` expanded edge coverage across transparent decompression, proxy, DNS and HTTP protocol feature classes so a future widening of the allow-list is an explicit contract change. - `d36c4d647ace77d8a0d68a2e3690460cadd980ed` added a RED contract moving the invalid `rustls + gzip` declaration under `[target.'cfg(windows)'.dependencies]`; the preceding checker treated the manifest as having no direct reqwest dependency. - `da209f160bcf4e81cc70cf4760ad658c9d78c679` makes target-scoped normal runtime reqwest declarations enter the same fail-closed owner admission as top-level declarations. +- `7330af6e40a6ba5b4c49c9de32abf291dd7c515b` adds the Cargo-rename RED: `distribution_http = { package = "reqwest", ..., features = ["rustls", "gzip"] }` must be treated as the same direct reqwest owner dependency rather than bypassing admission because the local key is not `reqwest`. +- `5356dcfa9a1a75c7cecdf6d185e7359cc056929a` resolves normal dependency declarations to Cargo package identity before selecting reqwest, so unconditional and target-scoped renamed dependencies enter the same policy path. Hosted exact-head checks remain authoritative for repository integration. This source-level gate does not claim that the production HTTP adapter exists, that remote metadata is authenticated, that updater artifact signatures have been verified, or that current packaged Windows/macOS network behavior is release-ready. @@ -73,6 +79,8 @@ The next Distribution implementation may add reqwest only together with a lock g ## References +Rust Project. (2026). *Specifying dependencies: Renaming dependencies in Cargo.toml*. The Cargo Book. https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#renaming-dependencies-in-cargotoml + RustSec. (2026, September 14). *RUSTSEC-2026-0285: rustls: TLS 1.3 handshake messages incorrectly accepted across encryption level boundaries*. RustSec Advisory Database. https://rustsec.org/advisories/RUSTSEC-2026-0285.html Reqwest project. (2026). *Cargo feature table (reqwest 0.13.5)*. Docs.rs. https://docs.rs/crate/reqwest/0.13.5/features From cf6655d134ebcae57037be2a21a1c8f777437ace Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 06:07:07 +0900 Subject: [PATCH 256/308] test(distribution): reject workspace-inherited reqwest bypass --- ..._distribution_http_dependency_admission.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/services/analysis-engine/tests/test_distribution_http_dependency_admission.py b/services/analysis-engine/tests/test_distribution_http_dependency_admission.py index 337ff2437..a901b3381 100644 --- a/services/analysis-engine/tests/test_distribution_http_dependency_admission.py +++ b/services/analysis-engine/tests/test_distribution_http_dependency_admission.py @@ -215,3 +215,34 @@ def test_renamed_reqwest_cannot_bypass_direct_dependency_admission( assert any("gzip" in violation for violation in violations) assert any("distribution_http" in violation for violation in violations) + + +def test_workspace_inherited_reqwest_cannot_bypass_direct_dependency_admission( + tmp_path: Path, +) -> None: + """Reject reqwest hidden behind workspace dependency inheritance.""" + _write_fixture(tmp_path, reqwest=None, rustls_version="0.23.45") + manifest = tmp_path / "apps/desktop/distribution-transport/Cargo.toml" + manifest.write_text( + '[package]\nname = "fixture"\nversion = "0.0.0"\n\n' + '[dependencies]\n' + 'distribution_http = { workspace = true, features = ["gzip"] }\n\n' + '[workspace]\n\n' + '[workspace.dependencies]\n' + 'distribution_http = { package = "reqwest", version = "0.13.5", ' + 'default-features = false, features = ["rustls"] }\n', + encoding="utf-8", + ) + lock = tmp_path / "apps/desktop/distribution-transport/Cargo.lock" + lock.write_text( + lock.read_text(encoding="utf-8") + + '\n[[package]]\nname = "reqwest"\nversion = "0.13.5"\n' + + 'source = "registry+https://github.com/rust-lang/crates.io-index"\n' + + 'checksum = "fixture"\n', + encoding="utf-8", + ) + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any("workspace" in violation for violation in violations) + assert any("distribution_http" in violation for violation in violations) From 8284ae6ccc5034ad4ab1e94f201e125d25f975da Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 06:07:32 +0900 Subject: [PATCH 257/308] fix(distribution): reject workspace-inherited reqwest ownership --- .../verify_distribution_http_dependencies.py | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/scripts/checks/verify_distribution_http_dependencies.py b/scripts/checks/verify_distribution_http_dependencies.py index a971208fd..acdecdf81 100755 --- a/scripts/checks/verify_distribution_http_dependencies.py +++ b/scripts/checks/verify_distribution_http_dependencies.py @@ -39,10 +39,32 @@ def _dependency_package_name(dependency_name: str, declaration: Any) -> Any: return dependency_name +def _workspace_dependencies(manifest: dict[str, Any]) -> dict[str, Any]: + """Return workspace dependency declarations visible to the root package.""" + workspace = manifest.get("workspace", {}) + if not isinstance(workspace, dict): + return {} + dependencies = workspace.get("dependencies", {}) + return dependencies if isinstance(dependencies, dict) else {} + + +def _resolved_package_name( + dependency_name: str, + declaration: Any, + workspace_dependencies: dict[str, Any], +) -> Any: + """Resolve package identity through Cargo workspace inheritance when present.""" + if isinstance(declaration, dict) and declaration.get("workspace") is True: + inherited = workspace_dependencies.get(dependency_name) + return _dependency_package_name(dependency_name, inherited) + return _dependency_package_name(dependency_name, declaration) + + def _reqwest_declarations_in_table( dependencies: Any, *, location_prefix: str, + workspace_dependencies: dict[str, Any], ) -> list[tuple[str, Any]]: """Return reqwest package declarations from one normal dependency table.""" if not isinstance(dependencies, dict): @@ -50,15 +72,22 @@ def _reqwest_declarations_in_table( return [ (f"{location_prefix}.{dependency_name}", declaration) for dependency_name, declaration in dependencies.items() - if _dependency_package_name(dependency_name, declaration) == "reqwest" + if _resolved_package_name( + dependency_name, + declaration, + workspace_dependencies, + ) + == "reqwest" ] def _direct_reqwest_declarations(manifest: dict[str, Any]) -> list[tuple[str, Any]]: """Return runtime reqwest packages from unconditional and target-scoped dependencies.""" + workspace_dependencies = _workspace_dependencies(manifest) declarations = _reqwest_declarations_in_table( manifest.get("dependencies", {}), location_prefix="dependencies", + workspace_dependencies=workspace_dependencies, ) targets = manifest.get("target", {}) @@ -70,6 +99,7 @@ def _direct_reqwest_declarations(manifest: dict[str, Any]) -> list[tuple[str, An _reqwest_declarations_in_table( target_table.get("dependencies", {}), location_prefix=f"target.{selector}.dependencies", + workspace_dependencies=workspace_dependencies, ) ) return declarations @@ -84,6 +114,12 @@ def _validate_reqwest_declaration(location: str, reqwest: Any) -> list[str]: f"{prefix}: direct reqwest must use a table with " 'default-features = false and features = ["rustls"]' ] + if reqwest.get("workspace") is True: + return [ + f"{prefix}: reqwest workspace inheritance is not admitted for the " + "Distribution transport; declare the package, default-features = false, " + 'and features = ["rustls"] directly in this runtime dependency table' + ] if reqwest.get("default-features") is not False: violations.append( From 6958feb69ddcf694e76ca532e8d9a4db7ff56f0f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 06:08:20 +0900 Subject: [PATCH 258/308] docs(distribution): trace workspace reqwest inheritance --- .../distribution-http-dependency-admission.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/traceability/distribution-http-dependency-admission.md b/docs/traceability/distribution-http-dependency-admission.md index a4f823114..45043ee8e 100644 --- a/docs/traceability/distribution-http-dependency-admission.md +++ b/docs/traceability/distribution-http-dependency-admission.md @@ -16,12 +16,15 @@ A second review of the executable gate found an ownership-location bypass after Fresh review then found a third bypass in dependency identity. Cargo explicitly permits a dependency key to differ from the package name by using `package = "..."`. For example, `distribution_http = { package = "reqwest", ... }` is still a direct runtime dependency on the reqwest package. The previous checker matched only the TOML key `reqwest`, so a renamed reqwest dependency could make the gate return early and skip the feature/TLS/lock admission entirely. The gate must therefore identify owner dependencies by Cargo package identity, not by the local dependency alias. +A fourth review found the same identity gap through Cargo workspace inheritance. This crate is itself a workspace root, so `[workspace.dependencies]` can define `distribution_http = { package = "reqwest", ... }` while `[dependencies]` activates it with `distribution_http = { workspace = true, ... }`. Cargo makes inherited features additive with the workspace declaration. A checker that examines only the local declaration cannot know that the effective package is reqwest or that workspace and local feature sets combine. Until BandScope has a concrete reason to share this security-sensitive client through workspace inheritance, inherited reqwest is rejected rather than partially reconstructing Cargo feature resolution in a Python preflight. + This finding does not prove that an existing transitive `rustls` entry elsewhere in BandScope is exploitable. The gate is deliberately activated when `apps/desktop/distribution-transport/Cargo.toml` acquires a direct runtime `reqwest` package dependency, unconditional or target-scoped and regardless of its local Cargo alias, because that is the repository-owned production HTTP boundary being prepared here. ## Constraints - The production updater client must remain in the Distribution bounded context and must not reimplement metadata, project persistence, authentication, or installer ownership. - Any direct runtime reqwest package dependency, whether under top-level `[dependencies]` or a Cargo `[target..dependencies]` table and whether named `reqwest` locally or renamed with `package = "reqwest"`, must use table syntax with `default-features = false` and exactly the approved direct feature set: `features = ["rustls"]`. +- A reqwest package inherited with `workspace = true` is not currently admitted. The Distribution transport must declare the package, default-feature policy and direct feature set in its own runtime dependency table so the owner contract is reviewable without reconstructing additive workspace feature inheritance. - `dev-dependencies` and `build-dependencies` do not activate the production client and are not treated as runtime owner declarations by this gate. - Additional direct reqwest features are rejected until a concrete Distribution requirement, threat analysis, tests and traceability justify widening the allow-list. This currently rejects native/default TLS alternatives, transparent decompression, proxy, alternate DNS and HTTP/2/HTTP/3 feature activation at the owner manifest. - Runtime code must still call `ClientBuilder::tls_backend_rustls()`. Cargo features are additive across the dependency graph, so the manifest allow-list is not a substitute for explicit runtime backend selection. @@ -42,6 +45,8 @@ Inspecting only top-level `[dependencies]` was rejected because Cargo target tab Matching only a dependency key literally named `reqwest` was rejected because Cargo's `package` key is the authoritative package-selection mechanism when a dependency is renamed. A local alias changes the Rust/Cargo-facing dependency name, not the upstream package being introduced into the Distribution runtime graph. The checker therefore resolves each normal dependency declaration to its Cargo package name first and applies reqwest policy whenever that package name is `reqwest`. +Trying to admit workspace-inherited reqwest by merging `[workspace.dependencies]` and local `features` inside the policy script was rejected for now. Cargo explicitly makes inherited feature lists additive, and inherited default-feature behavior has edition/toolchain semantics. Duplicating that resolver logic in a small Python gate would create a second dependency-resolution authority. The safer current contract is to identify inherited reqwest through the workspace package declaration and reject it until a deliberate owner decision adds equivalent executable resolution evidence. + Enabling reqwest's optional `stream` feature was rejected for the current adapter design because `Response::chunk()` already provides bounded asynchronous chunk retrieval without that feature. Avoiding `stream` also avoids an unnecessary `futures`/`tokio-util` surface in this small security-sensitive owner. Rejecting every rustls version below 0.23.45 was also rejected. RustSec explicitly lists versions below 0.23.13 as unaffected by this advisory, and future 0.24+ lines should not fail a check written for a 0.23 advisory. The selected check therefore models the published affected interval exactly. @@ -50,11 +55,11 @@ Scanning every Cargo.lock in the repository and treating any affected transitive ## Selected design -`scripts/checks/verify_distribution_http_dependencies.py` is a dependency-free Python 3 gate using `tomllib`. It enumerates direct runtime dependency declarations from top-level `[dependencies]` and every `[target..dependencies]` table, resolves each declaration's package identity using Cargo's `package` rename semantics, and selects every declaration whose package is `reqwest`. If none exists it returns success and does not infer exposure from unrelated graphs. Once any direct runtime reqwest package declaration exists, every declaration must explicitly own the rustls backend and use exactly the direct feature set `{rustls}`; the gate then requires a committed standalone lock containing reqwest and rustls and rejects every locked rustls version inside the `RUSTSEC-2026-0285` affected interval. +`scripts/checks/verify_distribution_http_dependencies.py` is a dependency-free Python 3 gate using `tomllib`. It enumerates direct runtime dependency declarations from top-level `[dependencies]` and every `[target..dependencies]` table, resolves each declaration's package identity using Cargo's `package` rename semantics, and follows `workspace = true` only far enough to determine the inherited workspace package identity. Every declaration whose effective package is `reqwest` enters the owner gate. Workspace-inherited reqwest is rejected. A directly declared reqwest package must explicitly own the rustls backend and use exactly the direct feature set `{rustls}`; the gate then requires a committed standalone lock containing reqwest and rustls and rejects every locked rustls version inside the `RUSTSEC-2026-0285` affected interval. `.github/workflows/ci.yml` runs this check in `lock-validation` immediately after checkout. The Distribution Windows/macOS/Linux Rust jobs depend on that job, so an unsafe future HTTP graph is rejected before those crates compile rather than after a platform matrix has already exercised it. `scripts/harness/quickcheck.sh` invokes the same checker so local canonical validation and hosted admission share one rule. -The regression suite uses synthetic Cargo manifest/lock fixtures only for policy-unit coverage. It proves rejection of rustls 0.23.44, acceptance of 0.23.45 and 0.24.0, non-activation when Distribution has no direct reqwest dependency, rejection of implicit reqwest TLS feature selection, rejection of unapproved direct transport features including gzip/Brotli/Zstandard/deflate decoding, system/SOCKS proxy, alternate DNS, HTTP/2 and HTTP/3, rejection of the same invalid reqwest declaration when it is moved under a Windows target dependency table, and rejection when the reqwest package is renamed to another local dependency key. Those fixtures are not production networking evidence. +The regression suite uses synthetic Cargo manifest/lock fixtures only for policy-unit coverage. It proves rejection of rustls 0.23.44, acceptance of 0.23.45 and 0.24.0, non-activation when Distribution has no direct reqwest dependency, rejection of implicit reqwest TLS feature selection, rejection of unapproved direct transport features including gzip/Brotli/Zstandard/deflate decoding, system/SOCKS proxy, alternate DNS, HTTP/2 and HTTP/3, rejection of the same invalid reqwest declaration when it is moved under a Windows target dependency table, rejection when the reqwest package is renamed to another local dependency key, and rejection when renamed reqwest is hidden behind workspace dependency inheritance. Those fixtures are not production networking evidence. ## RED -> repair evidence @@ -70,6 +75,8 @@ The regression suite uses synthetic Cargo manifest/lock fixtures only for policy - `da209f160bcf4e81cc70cf4760ad658c9d78c679` makes target-scoped normal runtime reqwest declarations enter the same fail-closed owner admission as top-level declarations. - `7330af6e40a6ba5b4c49c9de32abf291dd7c515b` adds the Cargo-rename RED: `distribution_http = { package = "reqwest", ..., features = ["rustls", "gzip"] }` must be treated as the same direct reqwest owner dependency rather than bypassing admission because the local key is not `reqwest`. - `5356dcfa9a1a75c7cecdf6d185e7359cc056929a` resolves normal dependency declarations to Cargo package identity before selecting reqwest, so unconditional and target-scoped renamed dependencies enter the same policy path. +- `cf6655d134ebcae57037be2a21a1c8f777437ace` adds the workspace-inheritance RED: a local `workspace = true` dependency whose workspace package is renamed reqwest must not make the gate return early. +- `8284ae6ccc5034ad4ab1e94f201e125d25f975da` resolves the workspace package identity for admission and fails closed on inherited reqwest instead of implementing a partial Cargo feature resolver. Hosted exact-head checks remain authoritative for repository integration. This source-level gate does not claim that the production HTTP adapter exists, that remote metadata is authenticated, that updater artifact signatures have been verified, or that current packaged Windows/macOS network behavior is release-ready. @@ -81,6 +88,8 @@ The next Distribution implementation may add reqwest only together with a lock g Rust Project. (2026). *Specifying dependencies: Renaming dependencies in Cargo.toml*. The Cargo Book. https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#renaming-dependencies-in-cargotoml +Rust Project. (2026). *Workspaces: The dependencies table*. The Cargo Book. https://doc.rust-lang.org/cargo/reference/workspaces.html#the-dependencies-table + RustSec. (2026, September 14). *RUSTSEC-2026-0285: rustls: TLS 1.3 handshake messages incorrectly accepted across encryption level boundaries*. RustSec Advisory Database. https://rustsec.org/advisories/RUSTSEC-2026-0285.html Reqwest project. (2026). *Cargo feature table (reqwest 0.13.5)*. Docs.rs. https://docs.rs/crate/reqwest/0.13.5/features From a8d91b349054a6bebdc069071761c2010829c51c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 06:09:10 +0900 Subject: [PATCH 259/308] test(distribution): reject noncanonical reqwest sources --- ...bution_http_dependency_source_admission.py | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 services/analysis-engine/tests/test_distribution_http_dependency_source_admission.py diff --git a/services/analysis-engine/tests/test_distribution_http_dependency_source_admission.py b/services/analysis-engine/tests/test_distribution_http_dependency_source_admission.py new file mode 100644 index 000000000..b2893785f --- /dev/null +++ b/services/analysis-engine/tests/test_distribution_http_dependency_source_admission.py @@ -0,0 +1,67 @@ +"""Regression tests for Distribution HTTP dependency source admission.""" + +from __future__ import annotations + +from pathlib import Path + +from conftest import load_module + +POLICY = load_module( + "scripts/checks/verify_distribution_http_dependencies.py", + "verify_distribution_http_dependencies_source", +) + + +def _write_source_fixture( + root: Path, + *, + dependency_fields: str, + reqwest_source: str | None, +) -> None: + """Write one standalone reqwest graph with an explicit dependency source.""" + crate = root / "apps/desktop/distribution-transport" + crate.mkdir(parents=True) + (crate / "Cargo.toml").write_text( + '[package]\nname = "fixture"\nversion = "0.0.0"\n\n' + '[dependencies]\n' + 'reqwest = { default-features = false, features = ["rustls"], ' + f"{dependency_fields} }}\n", + encoding="utf-8", + ) + source_line = f'source = "{reqwest_source}"\n' if reqwest_source else "" + (crate / "Cargo.lock").write_text( + "version = 4\n\n" + '[[package]]\nname = "reqwest"\nversion = "0.13.5"\n' + f"{source_line}" + '\n[[package]]\nname = "rustls"\nversion = "0.23.45"\n' + 'source = "registry+https://github.com/rust-lang/crates.io-index"\n', + encoding="utf-8", + ) + + +def test_reqwest_noncanonical_sources_are_rejected(tmp_path: Path) -> None: + """Reject git, path, and alternate-registry sources for the production client.""" + cases = ( + ( + "git", + 'git = "https://example.invalid/reqwest", rev = "deadbeef"', + "git+https://example.invalid/reqwest?rev=deadbeef#deadbeef", + ), + ("path", 'path = "../reqwest-fork"', None), + ( + "registry", + 'version = "0.13.5", registry = "private"', + "registry+https://example.invalid/index", + ), + ) + for name, dependency_fields, reqwest_source in cases: + fixture = tmp_path / name + _write_source_fixture( + fixture, + dependency_fields=dependency_fields, + reqwest_source=reqwest_source, + ) + + violations = POLICY.verify_distribution_http_dependency_admission(fixture) + + assert any("source" in violation for violation in violations), name From 914609c8f33b0f06809d5ef3b47fdfd67e552568 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 06:09:39 +0900 Subject: [PATCH 260/308] fix(distribution): pin reqwest to canonical registry source --- .../verify_distribution_http_dependencies.py | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/scripts/checks/verify_distribution_http_dependencies.py b/scripts/checks/verify_distribution_http_dependencies.py index acdecdf81..dc4705d22 100755 --- a/scripts/checks/verify_distribution_http_dependencies.py +++ b/scripts/checks/verify_distribution_http_dependencies.py @@ -14,6 +14,10 @@ RUSTLS_AFFECTED_MIN = (0, 23, 13) RUSTLS_PATCHED_MIN = (0, 23, 45) REQWEST_APPROVED_FEATURES = frozenset({"rustls"}) +REQWEST_APPROVED_DECLARATION_KEYS = frozenset( + {"version", "package", "default-features", "features"} +) +CRATES_IO_LOCK_SOURCE = "registry+https://github.com/rust-lang/crates.io-index" def _version_triplet(raw: str) -> tuple[int, int, int] | None: @@ -121,6 +125,21 @@ def _validate_reqwest_declaration(location: str, reqwest: Any) -> list[str]: 'and features = ["rustls"] directly in this runtime dependency table' ] + unapproved_keys = sorted( + set(reqwest).difference(REQWEST_APPROVED_DECLARATION_KEYS) + ) + if unapproved_keys: + violations.append( + f"{prefix}: reqwest source must be the versioned crates.io package; " + "git/path/alternate-registry and other declaration controls are not admitted; " + f"unapproved keys: {', '.join(unapproved_keys)}" + ) + version = reqwest.get("version") + if not isinstance(version, str) or not version.strip(): + violations.append( + f"{prefix}: reqwest source must include an explicit crates.io version requirement" + ) + if reqwest.get("default-features") is not False: violations.append( f"{prefix}: reqwest default features must be disabled so TLS/backend " @@ -164,16 +183,21 @@ def verify_distribution_http_dependency_admission(repo_root: Path) -> list[str]: lock = tomllib.loads(lock_path.read_text(encoding="utf-8")) packages = lock.get("package", []) - reqwest_versions = [ - str(package.get("version", "")) - for package in packages - if package.get("name") == "reqwest" + reqwest_packages = [ + package for package in packages if package.get("name") == "reqwest" ] - if not reqwest_versions: + if not reqwest_packages: violations.append( f"{DISTRIBUTION_TRANSPORT_LOCK}: direct reqwest is missing from the " "committed lock graph" ) + for package in reqwest_packages: + source = package.get("source") + if source != CRATES_IO_LOCK_SOURCE: + violations.append( + f"{DISTRIBUTION_TRANSPORT_LOCK}: reqwest source must be canonical " + f"crates.io registry; found {source!r}" + ) rustls_versions = [ str(package.get("version", "")) From e6a0663255c3164fade5be5105d2dc05f6fb7183 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 06:10:13 +0900 Subject: [PATCH 261/308] test(distribution): reject noncanonical rustls source --- ...bution_http_dependency_source_admission.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/services/analysis-engine/tests/test_distribution_http_dependency_source_admission.py b/services/analysis-engine/tests/test_distribution_http_dependency_source_admission.py index b2893785f..c9f8cfa5c 100644 --- a/services/analysis-engine/tests/test_distribution_http_dependency_source_admission.py +++ b/services/analysis-engine/tests/test_distribution_http_dependency_source_admission.py @@ -65,3 +65,26 @@ def test_reqwest_noncanonical_sources_are_rejected(tmp_path: Path) -> None: violations = POLICY.verify_distribution_http_dependency_admission(fixture) assert any("source" in violation for violation in violations), name + + +def test_rustls_noncanonical_lock_source_is_rejected(tmp_path: Path) -> None: + """Reject a patched or forked rustls source hidden behind a safe-looking version.""" + _write_source_fixture( + tmp_path, + dependency_fields='version = "0.13.5"', + reqwest_source="registry+https://github.com/rust-lang/crates.io-index", + ) + lock = tmp_path / "apps/desktop/distribution-transport/Cargo.lock" + lock.write_text( + lock.read_text(encoding="utf-8").replace( + 'name = "rustls"\nversion = "0.23.45"\n' + 'source = "registry+https://github.com/rust-lang/crates.io-index"', + 'name = "rustls"\nversion = "0.23.45"\n' + 'source = "git+https://example.invalid/rustls#deadbeef"', + ), + encoding="utf-8", + ) + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any("rustls source" in violation for violation in violations) From 4fdc294a2077b4c1d1b7d5e6a7a831a60c98aced Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 06:10:38 +0900 Subject: [PATCH 262/308] fix(distribution): require canonical rustls lock source --- .../verify_distribution_http_dependencies.py | 44 ++++++++++++++----- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/scripts/checks/verify_distribution_http_dependencies.py b/scripts/checks/verify_distribution_http_dependencies.py index dc4705d22..888431d3b 100755 --- a/scripts/checks/verify_distribution_http_dependencies.py +++ b/scripts/checks/verify_distribution_http_dependencies.py @@ -161,6 +161,21 @@ def _validate_reqwest_declaration(location: str, reqwest: Any) -> list[str]: return violations +def _validate_crates_io_lock_source( + *, + package_name: str, + package: dict[str, Any], +) -> str | None: + """Return a violation when a security-owned package is not crates.io-backed.""" + source = package.get("source") + if source == CRATES_IO_LOCK_SOURCE: + return None + return ( + f"{DISTRIBUTION_TRANSPORT_LOCK}: {package_name} source must be canonical " + f"crates.io registry; found {source!r}" + ) + + def verify_distribution_http_dependency_admission(repo_root: Path) -> list[str]: """Verify direct reqwest admission before the production Distribution client compiles.""" manifest_path = repo_root / DISTRIBUTION_TRANSPORT_MANIFEST @@ -192,26 +207,31 @@ def verify_distribution_http_dependency_admission(repo_root: Path) -> list[str]: "committed lock graph" ) for package in reqwest_packages: - source = package.get("source") - if source != CRATES_IO_LOCK_SOURCE: - violations.append( - f"{DISTRIBUTION_TRANSPORT_LOCK}: reqwest source must be canonical " - f"crates.io registry; found {source!r}" - ) + source_violation = _validate_crates_io_lock_source( + package_name="reqwest", + package=package, + ) + if source_violation: + violations.append(source_violation) - rustls_versions = [ - str(package.get("version", "")) - for package in packages - if package.get("name") == "rustls" + rustls_packages = [ + package for package in packages if package.get("name") == "rustls" ] - if not rustls_versions: + if not rustls_packages: violations.append( f"{DISTRIBUTION_TRANSPORT_LOCK}: reqwest rustls backend is selected but " "rustls is absent" ) return violations - for version in rustls_versions: + for package in rustls_packages: + source_violation = _validate_crates_io_lock_source( + package_name="rustls", + package=package, + ) + if source_violation: + violations.append(source_violation) + version = str(package.get("version", "")) if _version_triplet(version) is None: violations.append( f"{DISTRIBUTION_TRANSPORT_LOCK}: cannot parse rustls version {version!r}" From 2ff17b786f2955cac749b033aa29dd900df641c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 06:11:21 +0900 Subject: [PATCH 263/308] docs(distribution): trace HTTP dependency source provenance --- .../distribution-http-dependency-admission.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/traceability/distribution-http-dependency-admission.md b/docs/traceability/distribution-http-dependency-admission.md index 45043ee8e..88262a21a 100644 --- a/docs/traceability/distribution-http-dependency-admission.md +++ b/docs/traceability/distribution-http-dependency-admission.md @@ -18,13 +18,17 @@ Fresh review then found a third bypass in dependency identity. Cargo explicitly A fourth review found the same identity gap through Cargo workspace inheritance. This crate is itself a workspace root, so `[workspace.dependencies]` can define `distribution_http = { package = "reqwest", ... }` while `[dependencies]` activates it with `distribution_http = { workspace = true, ... }`. Cargo makes inherited features additive with the workspace declaration. A checker that examines only the local declaration cannot know that the effective package is reqwest or that workspace and local feature sets combine. Until BandScope has a concrete reason to share this security-sensitive client through workspace inheritance, inherited reqwest is rejected rather than partially reconstructing Cargo feature resolution in a Python preflight. +The final source review in this slice found that package identity and TLS features were still insufficient supply-chain admission. Cargo can source a package from git, a local path or an alternate registry while retaining the package name `reqwest`, and a `[patch.crates-io]` override can similarly replace the resolved `rustls` package while preserving a safe-looking version. The repository dependency policy already requires direct git refs and equivalent nonstandard sources to be rejected or explicitly excepted. Distribution therefore admits only a versioned crates.io reqwest declaration and requires both resolved reqwest and rustls lock entries to carry the canonical crates.io registry source. A version string alone is not accepted as provenance. + This finding does not prove that an existing transitive `rustls` entry elsewhere in BandScope is exploitable. The gate is deliberately activated when `apps/desktop/distribution-transport/Cargo.toml` acquires a direct runtime `reqwest` package dependency, unconditional or target-scoped and regardless of its local Cargo alias, because that is the repository-owned production HTTP boundary being prepared here. ## Constraints - The production updater client must remain in the Distribution bounded context and must not reimplement metadata, project persistence, authentication, or installer ownership. - Any direct runtime reqwest package dependency, whether under top-level `[dependencies]` or a Cargo `[target..dependencies]` table and whether named `reqwest` locally or renamed with `package = "reqwest"`, must use table syntax with `default-features = false` and exactly the approved direct feature set: `features = ["rustls"]`. -- A reqwest package inherited with `workspace = true` is not currently admitted. The Distribution transport must declare the package, default-feature policy and direct feature set in its own runtime dependency table so the owner contract is reviewable without reconstructing additive workspace feature inheritance. +- A reqwest package inherited with `workspace = true` is not currently admitted. The Distribution transport must declare the package, version, default-feature policy and direct feature set in its own runtime dependency table so the owner contract is reviewable without reconstructing additive workspace feature inheritance. +- Direct reqwest declarations may use only `version`, optional `package` rename metadata, `default-features`, and `features`. Git, path, alternate-registry, branch/tag/rev and optional/source indirection are not admitted without a new owner decision and dependency-policy exception. +- The committed standalone lock must resolve reqwest and rustls from `registry+https://github.com/rust-lang/crates.io-index`; git/path/alternate-registry entries are rejected even when the package name and version look acceptable. - `dev-dependencies` and `build-dependencies` do not activate the production client and are not treated as runtime owner declarations by this gate. - Additional direct reqwest features are rejected until a concrete Distribution requirement, threat analysis, tests and traceability justify widening the allow-list. This currently rejects native/default TLS alternatives, transparent decompression, proxy, alternate DNS and HTTP/2/HTTP/3 feature activation at the owner manifest. - Runtime code must still call `ClientBuilder::tls_backend_rustls()`. Cargo features are additive across the dependency graph, so the manifest allow-list is not a substitute for explicit runtime backend selection. @@ -47,6 +51,8 @@ Matching only a dependency key literally named `reqwest` was rejected because Ca Trying to admit workspace-inherited reqwest by merging `[workspace.dependencies]` and local `features` inside the policy script was rejected for now. Cargo explicitly makes inherited feature lists additive, and inherited default-feature behavior has edition/toolchain semantics. Duplicating that resolver logic in a small Python gate would create a second dependency-resolution authority. The safer current contract is to identify inherited reqwest through the workspace package declaration and reject it until a deliberate owner decision adds equivalent executable resolution evidence. +Allowing git/path/alternate-registry reqwest or a patched/forked rustls solely because the package name and version match was rejected. That would let a source substitution bypass the direct dependency admission evidence while leaving a superficially acceptable Cargo graph. The chosen policy uses ordinary crates.io source provenance for this small release-security owner; any future source exception must be explicit and reviewed under the repository dependency policy rather than silently inferred by the checker. + Enabling reqwest's optional `stream` feature was rejected for the current adapter design because `Response::chunk()` already provides bounded asynchronous chunk retrieval without that feature. Avoiding `stream` also avoids an unnecessary `futures`/`tokio-util` surface in this small security-sensitive owner. Rejecting every rustls version below 0.23.45 was also rejected. RustSec explicitly lists versions below 0.23.13 as unaffected by this advisory, and future 0.24+ lines should not fail a check written for a 0.23 advisory. The selected check therefore models the published affected interval exactly. @@ -55,11 +61,11 @@ Scanning every Cargo.lock in the repository and treating any affected transitive ## Selected design -`scripts/checks/verify_distribution_http_dependencies.py` is a dependency-free Python 3 gate using `tomllib`. It enumerates direct runtime dependency declarations from top-level `[dependencies]` and every `[target..dependencies]` table, resolves each declaration's package identity using Cargo's `package` rename semantics, and follows `workspace = true` only far enough to determine the inherited workspace package identity. Every declaration whose effective package is `reqwest` enters the owner gate. Workspace-inherited reqwest is rejected. A directly declared reqwest package must explicitly own the rustls backend and use exactly the direct feature set `{rustls}`; the gate then requires a committed standalone lock containing reqwest and rustls and rejects every locked rustls version inside the `RUSTSEC-2026-0285` affected interval. +`scripts/checks/verify_distribution_http_dependencies.py` is a dependency-free Python 3 gate using `tomllib`. It enumerates direct runtime dependency declarations from top-level `[dependencies]` and every `[target..dependencies]` table, resolves each declaration's package identity using Cargo's `package` rename semantics, and follows `workspace = true` only far enough to determine the inherited workspace package identity. Every declaration whose effective package is `reqwest` enters the owner gate. Workspace-inherited reqwest is rejected. A directly declared reqwest package must use only the approved declaration-key set, include an explicit version requirement, own the rustls backend, and use exactly the direct feature set `{rustls}`. The gate then requires a committed standalone lock containing reqwest and rustls from the canonical crates.io registry source and rejects every locked rustls version inside the `RUSTSEC-2026-0285` affected interval. `.github/workflows/ci.yml` runs this check in `lock-validation` immediately after checkout. The Distribution Windows/macOS/Linux Rust jobs depend on that job, so an unsafe future HTTP graph is rejected before those crates compile rather than after a platform matrix has already exercised it. `scripts/harness/quickcheck.sh` invokes the same checker so local canonical validation and hosted admission share one rule. -The regression suite uses synthetic Cargo manifest/lock fixtures only for policy-unit coverage. It proves rejection of rustls 0.23.44, acceptance of 0.23.45 and 0.24.0, non-activation when Distribution has no direct reqwest dependency, rejection of implicit reqwest TLS feature selection, rejection of unapproved direct transport features including gzip/Brotli/Zstandard/deflate decoding, system/SOCKS proxy, alternate DNS, HTTP/2 and HTTP/3, rejection of the same invalid reqwest declaration when it is moved under a Windows target dependency table, rejection when the reqwest package is renamed to another local dependency key, and rejection when renamed reqwest is hidden behind workspace dependency inheritance. Those fixtures are not production networking evidence. +The regression suite uses synthetic Cargo manifest/lock fixtures only for policy-unit coverage. It proves rejection of rustls 0.23.44, acceptance of 0.23.45 and 0.24.0, non-activation when Distribution has no direct reqwest dependency, rejection of implicit reqwest TLS feature selection, rejection of unapproved direct transport features including gzip/Brotli/Zstandard/deflate decoding, system/SOCKS proxy, alternate DNS, HTTP/2 and HTTP/3, rejection of the same invalid reqwest declaration when it is moved under a Windows target dependency table, rejection when the reqwest package is renamed to another local dependency key, rejection when renamed reqwest is hidden behind workspace dependency inheritance, rejection of git/path/alternate-registry reqwest sources, and rejection of a non-crates.io rustls lock source. Those fixtures are not production networking evidence. ## RED -> repair evidence @@ -77,6 +83,10 @@ The regression suite uses synthetic Cargo manifest/lock fixtures only for policy - `5356dcfa9a1a75c7cecdf6d185e7359cc056929a` resolves normal dependency declarations to Cargo package identity before selecting reqwest, so unconditional and target-scoped renamed dependencies enter the same policy path. - `cf6655d134ebcae57037be2a21a1c8f777437ace` adds the workspace-inheritance RED: a local `workspace = true` dependency whose workspace package is renamed reqwest must not make the gate return early. - `8284ae6ccc5034ad4ab1e94f201e125d25f975da` resolves the workspace package identity for admission and fails closed on inherited reqwest instead of implementing a partial Cargo feature resolver. +- `a8d91b349054a6bebdc069071761c2010829c51c` adds RED coverage for git, local-path and alternate-registry reqwest declarations; the preceding gate accepted their package name/features without validating source provenance. +- `914609c8f33b0f06809d5ef3b47fdfd67e552568` restricts direct reqwest declaration controls to the versioned crates.io form and requires canonical crates.io reqwest lock provenance. +- `e6a0663255c3164fade5be5105d2dc05f6fb7183` adds the corresponding RED for a safe-looking rustls `0.23.45` lock entry sourced from git. +- `4fdc294a2077b4c1d1b7d5e6a7a831a60c98aced` applies the same canonical crates.io lock-source requirement to rustls before advisory-range admission. Hosted exact-head checks remain authoritative for repository integration. This source-level gate does not claim that the production HTTP adapter exists, that remote metadata is authenticated, that updater artifact signatures have been verified, or that current packaged Windows/macOS network behavior is release-ready. From 09852ddb7f262e040cd8a28814bee73f9b4732af Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 07:04:33 +0900 Subject: [PATCH 264/308] test(distribution): reject unbounded reqwest requirements --- ...ion_http_dependency_version_requirement.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 services/analysis-engine/tests/test_distribution_http_dependency_version_requirement.py diff --git a/services/analysis-engine/tests/test_distribution_http_dependency_version_requirement.py b/services/analysis-engine/tests/test_distribution_http_dependency_version_requirement.py new file mode 100644 index 000000000..cbde5a499 --- /dev/null +++ b/services/analysis-engine/tests/test_distribution_http_dependency_version_requirement.py @@ -0,0 +1,57 @@ +"""Version-requirement contracts for Distribution's production HTTP client.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from conftest import load_module + +POLICY = load_module( + "scripts/checks/verify_distribution_http_dependencies.py", + "verify_distribution_http_dependencies_version_requirement", +) + + +def _write_fixture(root: Path, version_requirement: str) -> None: + """Write a safe lock graph paired with one reqwest version requirement.""" + crate = root / "apps/desktop/distribution-transport" + crate.mkdir(parents=True) + (crate / "Cargo.toml").write_text( + '[package]\nname = "fixture"\nversion = "0.0.0"\n\n' + "[dependencies]\n" + f'reqwest = {{ version = "{version_requirement}", default-features = false, ' + 'features = ["rustls"] }}\n', + encoding="utf-8", + ) + (crate / "Cargo.lock").write_text( + "version = 4\n\n" + '[[package]]\nname = "reqwest"\nversion = "0.13.5"\n' + 'source = "registry+https://github.com/rust-lang/crates.io-index"\n' + 'checksum = "fixture"\n\n' + '[[package]]\nname = "rustls"\nversion = "0.23.45"\n' + 'source = "registry+https://github.com/rust-lang/crates.io-index"\n' + 'checksum = "fixture"\n', + encoding="utf-8", + ) + + +@pytest.mark.parametrize("version_requirement", ["*", ">=0.13.5"]) +def test_direct_reqwest_rejects_unbounded_version_requirement( + tmp_path: Path, + version_requirement: str, +) -> None: + """Reject requirements that can drift across an unbounded future release line.""" + _write_fixture(tmp_path, version_requirement) + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any("bounded three-component" in violation for violation in violations) + + +def test_direct_reqwest_accepts_bounded_three_component_requirement(tmp_path: Path) -> None: + """Keep the normal Cargo caret requirement form available with a committed lock.""" + _write_fixture(tmp_path, "0.13.5") + + assert POLICY.verify_distribution_http_dependency_admission(tmp_path) == [] From 106d1f63117cbebaeaefa11e2998260e3579d3be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 07:05:01 +0900 Subject: [PATCH 265/308] fix(distribution): bound reqwest version requirements --- .../verify_distribution_http_dependencies.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/scripts/checks/verify_distribution_http_dependencies.py b/scripts/checks/verify_distribution_http_dependencies.py index 888431d3b..8dbdf8f2c 100755 --- a/scripts/checks/verify_distribution_http_dependencies.py +++ b/scripts/checks/verify_distribution_http_dependencies.py @@ -30,6 +30,15 @@ def _version_triplet(raw: str) -> tuple[int, int, int] | None: return major, minor, patch +def _is_bounded_three_component_requirement(raw: str) -> bool: + """Return whether a Cargo requirement is one canonical three-component caret form.""" + version = _version_triplet(raw) + if version is None: + return False + canonical = ".".join(str(component) for component in version) + return raw == canonical + + def _is_affected_rustls(raw: str) -> bool: """Return whether a rustls version is inside RUSTSEC-2026-0285's affected range.""" version = _version_triplet(raw) @@ -139,6 +148,11 @@ def _validate_reqwest_declaration(location: str, reqwest: Any) -> list[str]: violations.append( f"{prefix}: reqwest source must include an explicit crates.io version requirement" ) + elif not _is_bounded_three_component_requirement(version): + violations.append( + f"{prefix}: reqwest version must use one bounded three-component Cargo " + f"requirement such as 0.13.5; found {version!r}" + ) if reqwest.get("default-features") is not False: violations.append( From bd359a33a5d0cbca1d8414495ffc08decf6d3397 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 07:06:25 +0900 Subject: [PATCH 266/308] docs(distribution): trace bounded reqwest requirements --- .../distribution-http-dependency-admission.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/traceability/distribution-http-dependency-admission.md b/docs/traceability/distribution-http-dependency-admission.md index 88262a21a..7fbbf469a 100644 --- a/docs/traceability/distribution-http-dependency-admission.md +++ b/docs/traceability/distribution-http-dependency-admission.md @@ -18,7 +18,9 @@ Fresh review then found a third bypass in dependency identity. Cargo explicitly A fourth review found the same identity gap through Cargo workspace inheritance. This crate is itself a workspace root, so `[workspace.dependencies]` can define `distribution_http = { package = "reqwest", ... }` while `[dependencies]` activates it with `distribution_http = { workspace = true, ... }`. Cargo makes inherited features additive with the workspace declaration. A checker that examines only the local declaration cannot know that the effective package is reqwest or that workspace and local feature sets combine. Until BandScope has a concrete reason to share this security-sensitive client through workspace inheritance, inherited reqwest is rejected rather than partially reconstructing Cargo feature resolution in a Python preflight. -The final source review in this slice found that package identity and TLS features were still insufficient supply-chain admission. Cargo can source a package from git, a local path or an alternate registry while retaining the package name `reqwest`, and a `[patch.crates-io]` override can similarly replace the resolved `rustls` package while preserving a safe-looking version. The repository dependency policy already requires direct git refs and equivalent nonstandard sources to be rejected or explicitly excepted. Distribution therefore admits only a versioned crates.io reqwest declaration and requires both resolved reqwest and rustls lock entries to carry the canonical crates.io registry source. A version string alone is not accepted as provenance. +The final source review in the preceding slice found that package identity and TLS features were still insufficient supply-chain admission. Cargo can source a package from git, a local path or an alternate registry while retaining the package name `reqwest`, and a `[patch.crates-io]` override can similarly replace the resolved `rustls` package while preserving a safe-looking version. The repository dependency policy already requires direct git refs and equivalent nonstandard sources to be rejected or explicitly excepted. Distribution therefore admits only a versioned crates.io reqwest declaration and requires both resolved reqwest and rustls lock entries to carry the canonical crates.io registry source. A version string alone is not accepted as provenance. + +Fresh review of that versioned-source rule found one remaining fail-open input: the checker accepted any non-empty version string. Cargo accepts wildcard and comparator requirements such as `*` and `>=0.13.5`; those forms can cross future SemVer-incompatible release lines on a lock refresh. That conflicts with the repository policy rejecting unbounded version ranges. Cargo recommends a fully specified three-component default/caret requirement such as `0.13.5`; for a pre-1.0 package this is bounded to the compatible `0.13.x` line while the committed lock remains the exact current resolution. Distribution therefore requires that canonical three-component form and rejects wildcard, comparator, shortened, pre-release and metadata-bearing requirements at this owner boundary. This finding does not prove that an existing transitive `rustls` entry elsewhere in BandScope is exploitable. The gate is deliberately activated when `apps/desktop/distribution-transport/Cargo.toml` acquires a direct runtime `reqwest` package dependency, unconditional or target-scoped and regardless of its local Cargo alias, because that is the repository-owned production HTTP boundary being prepared here. @@ -26,6 +28,7 @@ This finding does not prove that an existing transitive `rustls` entry elsewhere - The production updater client must remain in the Distribution bounded context and must not reimplement metadata, project persistence, authentication, or installer ownership. - Any direct runtime reqwest package dependency, whether under top-level `[dependencies]` or a Cargo `[target..dependencies]` table and whether named `reqwest` locally or renamed with `package = "reqwest"`, must use table syntax with `default-features = false` and exactly the approved direct feature set: `features = ["rustls"]`. +- A direct reqwest `version` must use the canonical fully specified `MAJOR.MINOR.PATCH` Cargo default/caret requirement form. Wildcards, comparison operators, shortened requirements, prerelease/build metadata and otherwise unbounded requirements are not admitted; the committed lock remains the exact resolved-version authority. - A reqwest package inherited with `workspace = true` is not currently admitted. The Distribution transport must declare the package, version, default-feature policy and direct feature set in its own runtime dependency table so the owner contract is reviewable without reconstructing additive workspace feature inheritance. - Direct reqwest declarations may use only `version`, optional `package` rename metadata, `default-features`, and `features`. Git, path, alternate-registry, branch/tag/rev and optional/source indirection are not admitted without a new owner decision and dependency-policy exception. - The committed standalone lock must resolve reqwest and rustls from `registry+https://github.com/rust-lang/crates.io-index`; git/path/alternate-registry entries are rejected even when the package name and version look acceptable. @@ -53,6 +56,8 @@ Trying to admit workspace-inherited reqwest by merging `[workspace.dependencies] Allowing git/path/alternate-registry reqwest or a patched/forked rustls solely because the package name and version match was rejected. That would let a source substitution bypass the direct dependency admission evidence while leaving a superficially acceptable Cargo graph. The chosen policy uses ordinary crates.io source provenance for this small release-security owner; any future source exception must be explicit and reviewed under the repository dependency policy rather than silently inferred by the checker. +Allowing `*`, lower-bound-only comparator requirements or shortened version requirements was rejected because a future lock refresh could select a SemVer-incompatible reqwest line without changing the manifest. Requiring an exact `=MAJOR.MINOR.PATCH` pin was also rejected as unnecessary duplication of the committed Cargo lock for this binary product. Cargo's recommended fully specified default/caret form keeps a declared minimum and SemVer-compatible upper bound, while `Cargo.lock` retains the exact resolved graph that CI builds with `--locked`. + Enabling reqwest's optional `stream` feature was rejected for the current adapter design because `Response::chunk()` already provides bounded asynchronous chunk retrieval without that feature. Avoiding `stream` also avoids an unnecessary `futures`/`tokio-util` surface in this small security-sensitive owner. Rejecting every rustls version below 0.23.45 was also rejected. RustSec explicitly lists versions below 0.23.13 as unaffected by this advisory, and future 0.24+ lines should not fail a check written for a 0.23 advisory. The selected check therefore models the published affected interval exactly. @@ -61,11 +66,11 @@ Scanning every Cargo.lock in the repository and treating any affected transitive ## Selected design -`scripts/checks/verify_distribution_http_dependencies.py` is a dependency-free Python 3 gate using `tomllib`. It enumerates direct runtime dependency declarations from top-level `[dependencies]` and every `[target..dependencies]` table, resolves each declaration's package identity using Cargo's `package` rename semantics, and follows `workspace = true` only far enough to determine the inherited workspace package identity. Every declaration whose effective package is `reqwest` enters the owner gate. Workspace-inherited reqwest is rejected. A directly declared reqwest package must use only the approved declaration-key set, include an explicit version requirement, own the rustls backend, and use exactly the direct feature set `{rustls}`. The gate then requires a committed standalone lock containing reqwest and rustls from the canonical crates.io registry source and rejects every locked rustls version inside the `RUSTSEC-2026-0285` affected interval. +`scripts/checks/verify_distribution_http_dependencies.py` is a dependency-free Python 3 gate using `tomllib`. It enumerates direct runtime dependency declarations from top-level `[dependencies]` and every `[target..dependencies]` table, resolves each declaration's package identity using Cargo's `package` rename semantics, and follows `workspace = true` only far enough to determine the inherited workspace package identity. Every declaration whose effective package is `reqwest` enters the owner gate. Workspace-inherited reqwest is rejected. A directly declared reqwest package must use only the approved declaration-key set, use a canonical fully specified three-component default/caret version requirement, own the rustls backend, and use exactly the direct feature set `{rustls}`. The gate then requires a committed standalone lock containing reqwest and rustls from the canonical crates.io registry source and rejects every locked rustls version inside the `RUSTSEC-2026-0285` affected interval. `.github/workflows/ci.yml` runs this check in `lock-validation` immediately after checkout. The Distribution Windows/macOS/Linux Rust jobs depend on that job, so an unsafe future HTTP graph is rejected before those crates compile rather than after a platform matrix has already exercised it. `scripts/harness/quickcheck.sh` invokes the same checker so local canonical validation and hosted admission share one rule. -The regression suite uses synthetic Cargo manifest/lock fixtures only for policy-unit coverage. It proves rejection of rustls 0.23.44, acceptance of 0.23.45 and 0.24.0, non-activation when Distribution has no direct reqwest dependency, rejection of implicit reqwest TLS feature selection, rejection of unapproved direct transport features including gzip/Brotli/Zstandard/deflate decoding, system/SOCKS proxy, alternate DNS, HTTP/2 and HTTP/3, rejection of the same invalid reqwest declaration when it is moved under a Windows target dependency table, rejection when the reqwest package is renamed to another local dependency key, rejection when renamed reqwest is hidden behind workspace dependency inheritance, rejection of git/path/alternate-registry reqwest sources, and rejection of a non-crates.io rustls lock source. Those fixtures are not production networking evidence. +The regression suite uses synthetic Cargo manifest/lock fixtures only for policy-unit coverage. It proves rejection of rustls 0.23.44, acceptance of 0.23.45 and 0.24.0, non-activation when Distribution has no direct reqwest dependency, rejection of implicit reqwest TLS feature selection, rejection of unapproved direct transport features including gzip/Brotli/Zstandard/deflate decoding, system/SOCKS proxy, alternate DNS, HTTP/2 and HTTP/3, rejection of the same invalid reqwest declaration when it is moved under a Windows target dependency table, rejection when the reqwest package is renamed to another local dependency key, rejection when renamed reqwest is hidden behind workspace dependency inheritance, rejection of git/path/alternate-registry reqwest sources, rejection of a non-crates.io rustls lock source, rejection of wildcard/lower-bound-only reqwest version requirements, and acceptance of a bounded three-component default requirement. Those fixtures are not production networking evidence. ## RED -> repair evidence @@ -87,6 +92,8 @@ The regression suite uses synthetic Cargo manifest/lock fixtures only for policy - `914609c8f33b0f06809d5ef3b47fdfd67e552568` restricts direct reqwest declaration controls to the versioned crates.io form and requires canonical crates.io reqwest lock provenance. - `e6a0663255c3164fade5be5105d2dc05f6fb7183` adds the corresponding RED for a safe-looking rustls `0.23.45` lock entry sourced from git. - `4fdc294a2077b4c1d1b7d5e6a7a831a60c98aced` applies the same canonical crates.io lock-source requirement to rustls before advisory-range admission. +- `09852ddb7f262e040cd8a28814bee73f9b4732af` adds RED coverage for wildcard `*` and lower-bound-only `>=0.13.5` reqwest requirements while retaining a bounded `0.13.5` control case. +- `106d1f63117cbebaeaefa11e2998260e3579d3be` makes reqwest version admission require one canonical fully specified three-component Cargo default/caret requirement before lock/TLS admission continues. Hosted exact-head checks remain authoritative for repository integration. This source-level gate does not claim that the production HTTP adapter exists, that remote metadata is authenticated, that updater artifact signatures have been verified, or that current packaged Windows/macOS network behavior is release-ready. @@ -98,6 +105,8 @@ The next Distribution implementation may add reqwest only together with a lock g Rust Project. (2026). *Specifying dependencies: Renaming dependencies in Cargo.toml*. The Cargo Book. https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#renaming-dependencies-in-cargotoml +Rust Project. (2026). *Dependency resolution: Version requirements*. The Cargo Book. https://doc.rust-lang.org/cargo/reference/resolver.html#version-numbers + Rust Project. (2026). *Workspaces: The dependencies table*. The Cargo Book. https://doc.rust-lang.org/cargo/reference/workspaces.html#the-dependencies-table RustSec. (2026, September 14). *RUSTSEC-2026-0285: rustls: TLS 1.3 handshake messages incorrectly accepted across encryption level boundaries*. RustSec Advisory Database. https://rustsec.org/advisories/RUSTSEC-2026-0285.html @@ -107,5 +116,3 @@ Reqwest project. (2026). *Cargo feature table (reqwest 0.13.5)*. Docs.rs. https: Reqwest project. (2026). *TLS configuration and types (reqwest 0.13.5)*. Docs.rs. https://docs.rs/reqwest/0.13.5/reqwest/tls/ Reqwest project. (2026). *Response (reqwest 0.13.5)*. Docs.rs. https://docs.rs/reqwest/0.13.5/reqwest/struct.Response.html - -Reqwest project. (2026). *ClientBuilder (reqwest 0.13.5)*. Docs.rs. https://docs.rs/reqwest/0.13.5/reqwest/struct.ClientBuilder.html From 308f4a618cbbfd07fc9380b721f9987b3cb373d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 08:04:04 +0900 Subject: [PATCH 267/308] test(distribution): expose Windows staging replacement cleanup --- .../distribution-download/tests/path_replacement_cleanup.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/distribution-download/tests/path_replacement_cleanup.rs b/apps/desktop/distribution-download/tests/path_replacement_cleanup.rs index 4e9031caa..ce21106ec 100644 --- a/apps/desktop/distribution-download/tests/path_replacement_cleanup.rs +++ b/apps/desktop/distribution-download/tests/path_replacement_cleanup.rs @@ -1,4 +1,4 @@ -#![cfg(unix)] +#![cfg(any(unix, windows))] use bandscope_distribution_download::{ArtifactDownloadAdmission, StagedArtifactFile}; use std::fs; @@ -42,7 +42,7 @@ fn cancelled_attempt_does_not_delete_replacement_path() { let original_path = staged.path().to_path_buf(); let moved_original = directory.join("moved-original.bin"); - fs::rename(&original_path, &moved_original).expect("move owned staging inode away"); + fs::rename(&original_path, &moved_original).expect("move owned staging file away"); fs::write(&original_path, b"replacement-must-survive").expect("create unrelated replacement"); drop(staged); @@ -67,7 +67,7 @@ fn sealed_attempt_does_not_delete_replacement_path() { let original_path = sealed.path().to_path_buf(); let moved_original = directory.join("moved-sealed-original.bin"); - fs::rename(&original_path, &moved_original).expect("move sealed staging inode away"); + fs::rename(&original_path, &moved_original).expect("move sealed staging file away"); fs::write(&original_path, b"replacement-must-survive").expect("create unrelated replacement"); drop(sealed); From 1d603316b0ff4ff8b737aea2ef57c76250e326d0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 08:06:07 +0900 Subject: [PATCH 268/308] fix(distribution): defer unprovable Windows staging unlink --- apps/desktop/distribution-download/src/lib.rs | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/apps/desktop/distribution-download/src/lib.rs b/apps/desktop/distribution-download/src/lib.rs index fb5d74b58..6cbbbb878 100644 --- a/apps/desktop/distribution-download/src/lib.rs +++ b/apps/desktop/distribution-download/src/lib.rs @@ -187,10 +187,13 @@ impl ArtifactDownloadAdmission { /// 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`. +/// launches. Unix removes a descriptor-owned pathname on drop after identity +/// confirmation. Windows deliberately defers pathname reclamation to the next +/// leased staging attempt because stable Rust does not expose an equivalent +/// by-handle file identity suitable for proving that a remembered pathname still +/// denotes the owned object. Callers cannot write the descriptor directly; +/// response bytes must pass through `ArtifactDownloadAdmission` via +/// `admit_chunk`. #[derive(Debug)] pub struct StagedArtifactFile { file: Option, @@ -329,12 +332,15 @@ impl Drop for StagedArtifactFile { /// Synchronized but still unverified staging artifact. /// /// The descriptor and staging lease stay open for later digest/signature -/// verification. Dropping this value removes the staging pathname only when it -/// still resolves to the descriptor-owned file on Unix; a replacement pathname -/// is left untouched. Other desktop targets retain the historical best-effort -/// cleanup until an equally strong stable file-identity primitive is available. -/// The lease is released only after path cleanup. A later trust-promotion type, -/// not this byte-count boundary, must explicitly retain verified bytes. +/// verification. On Unix, dropping this value removes the staging pathname only +/// when it still resolves to the descriptor-owned file; a replacement pathname +/// is left untouched. On Windows, drop closes the descriptor but intentionally +/// leaves the pathname in the app-owned scratch directory because stable Rust +/// does not expose the by-handle identity needed to prove that path ownership. +/// A later staging attempt reclaims a stale regular child only while holding the +/// same process-shared lease. The lease is released after descriptor cleanup. A +/// later trust-promotion type, not this byte-count boundary, must explicitly +/// retain verified bytes. #[derive(Debug)] pub struct SealedArtifactFile { file: Option, @@ -444,9 +450,11 @@ fn cleanup_owned_staging_path(file: File, path: &Path) { } #[cfg(not(unix))] -fn cleanup_owned_staging_path(file: File, path: &Path) { +fn cleanup_owned_staging_path(file: File, _path: &Path) { + // Stable Rust does not expose a portable by-handle file identity here. + // Closing without pathname deletion avoids deleting a replacement object; + // the next leased staging attempt reclaims stale regular scratch files. drop(file); - let _ = fs::remove_file(path); } fn acquire_staging_lease(staging_directory: &Path) -> Result { From 1a6ee097106cf2204cf9bdbcc0040999988f4e14 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 08:06:25 +0900 Subject: [PATCH 269/308] test(distribution): prove deferred Windows staging reclaim --- .../tests/path_replacement_cleanup.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/desktop/distribution-download/tests/path_replacement_cleanup.rs b/apps/desktop/distribution-download/tests/path_replacement_cleanup.rs index ce21106ec..9f4fba81a 100644 --- a/apps/desktop/distribution-download/tests/path_replacement_cleanup.rs +++ b/apps/desktop/distribution-download/tests/path_replacement_cleanup.rs @@ -78,3 +78,24 @@ fn sealed_attempt_does_not_delete_replacement_path() { ); cleanup(&directory, &[&original_path, &moved_original]); } + +#[cfg(windows)] +#[test] +fn deferred_windows_stale_file_is_reclaimed_by_next_leased_attempt() { + let directory = scratch_dir("windows-deferred-reclaim"); + let staged = StagedArtifactFile::create(&directory, "update.bin").expect("first stage file"); + let path = staged.path().to_path_buf(); + + drop(staged); + assert!( + path.is_file(), + "Windows drop leaves scratch bytes when pathname ownership cannot be proven" + ); + + let next = StagedArtifactFile::create(&directory, "update.bin") + .expect("next leased attempt reclaims stale regular scratch"); + assert_eq!(next.path(), path); + drop(next); + + cleanup(&directory, &[&path]); +} From 0bae56fe905ed165c6973867f6c7e896c431195f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 08:06:52 +0900 Subject: [PATCH 270/308] docs(distribution): trace Windows staging cleanup authority --- .../updater-staging-path-identity.md | 40 +++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/docs/traceability/updater-staging-path-identity.md b/docs/traceability/updater-staging-path-identity.md index 50fc8dfcc..aa2b5ce21 100644 --- a/docs/traceability/updater-staging-path-identity.md +++ b/docs/traceability/updater-staging-path-identity.md @@ -6,40 +6,56 @@ The staging lease serializes cooperating BandScope attempts. It is not a filesystem namespace capability and does not stop another same-user process from renaming or replacing a pathname. Cleanup therefore must not infer object ownership from a stale path string. +The first repair closed this on Unix by comparing the still-open descriptor's `(dev, ino)` with the current pathname. Fresh review found that Windows still used the old close-then-`remove_file(path)` fallback. Rust 1.98.1 documents that Windows `OpenOptions` uses `FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE` by default, so another process can rename or delete the file while BandScope still has the handle open. The Windows fallback therefore preserved the exact replacement-path deletion class that the Unix repair was intended to remove. + ## RED evidence -Commit `4aca07e177198b75cfe166208b48808704b170e4` adds `apps/desktop/distribution-download/tests/path_replacement_cleanup.rs` for Unix desktop semantics. +Commit `4aca07e177198b75cfe166208b48808704b170e4` originally added `apps/desktop/distribution-download/tests/path_replacement_cleanup.rs` for Unix desktop semantics. + +Commit `308f4a618cbbfd07fc9380b721f9987b3cb373d1` extends those same cancelled and sealed replacement-path contracts to Windows. On the preceding implementation both tests reach the non-Unix cleanup helper, which closes the descriptor and unconditionally removes the remembered pathname; the unrelated replacement therefore does not survive. The regression covers both lifecycle paths: -- a cancelled `StagedArtifactFile` whose owned inode is renamed away before `Drop`; -- a `SealedArtifactFile` whose owned inode is renamed away before verifier-owner cleanup. +- a cancelled `StagedArtifactFile` whose owned file is renamed away before `Drop`; +- a `SealedArtifactFile` whose owned file is renamed away before verifier-owner cleanup. -Each case creates an unrelated replacement at the original staging basename before dropping the BandScope owner and requires those replacement bytes to survive. The previous close-then-`remove_file(path)` implementation deletes the replacement and violates the contract. +Each case creates an unrelated replacement at the original staging basename before dropping the BandScope owner and requires those replacement bytes to survive. ## Causal repair -Commit `bc72745df92fc048b25a84cca349a6ecf0d9daf1` routes both staged and sealed cleanup through one owner helper. +Commit `bc72745df92fc048b25a84cca349a6ecf0d9daf1` remains the Unix owner repair. Unix cleanup reads the still-open descriptor identity (`dev`, `ino`) and compares it with `symlink_metadata` for the current pathname. The pathname is removed only when it is a direct regular non-symlink object with the same device/inode identity as the owned descriptor. A missing, symlinked, non-regular, or replaced pathname is left untouched. -On Unix, cleanup reads the still-open descriptor identity (`dev`, `ino`) and compares it with `symlink_metadata` for the current pathname. The pathname is removed only when it is a direct regular non-symlink object with the same device/inode identity as the owned descriptor. A missing, symlinked, non-regular, or replaced pathname is left untouched. The descriptor is then closed and the staging lease is released. +Commit `1d603316b0ff4ff8b737aea2ef57c76250e326d0` removes the unsafe Windows/non-Unix pathname unlink. Where stable Rust cannot prove that a remembered path still denotes the owned descriptor, `Drop` now closes the descriptor and leaves the app-owned scratch pathname untouched. This chooses a bounded stale-file cost over deleting an object whose ownership cannot be established. -The helper deliberately keeps the descriptor open until the identity comparison and optional unlink have completed; closing first would discard the strongest object reference available to the owner. +The existing `StagedArtifactFile::create` restart path already acquires `.bandscope-staging.lock` before classifying a pre-existing child as stale and only reclaims a direct regular file. Commit `1a6ee097106cf2204cf9bdbcc0040999988f4e14` adds a Windows-specific contract proving that a deferred stale `update.bin` survives `Drop` and is reclaimed by the next leased staging attempt. The fix therefore does not turn deferred cleanup into permanent accumulation during subsequent updater attempts. ## Alternatives considered - **Close then unconditionally remove the remembered path** — rejected because the pathname may now identify another filesystem object. - **Check only that the pathname exists and is a regular file** — rejected because type equality is not object identity. -- **Never clean up on `Drop`** — rejected because cancelled and unverified updater artifacts would accumulate and make crash-safe restart semantics unreliable. -- **Claim equivalent Windows identity from unstable standard-library metadata extensions** — rejected. The stable implementation must not depend on nightly-only Windows by-handle metadata APIs merely to preserve a source-level parity claim. +- **Use unstable Windows by-handle identity APIs** — rejected because production Distribution builds use stable Rust and must not depend on nightly-only metadata extensions merely to claim parity. +- **Treat default Windows sharing as an ownership lock** — rejected. Rust documents that the default share mode includes `FILE_SHARE_DELETE`, which permits delete/rename operations by compatible subsequent handles. +- **Disable delete sharing and unlink after closing the descriptor** — rejected because closing first recreates a pathname replacement race between handle close and `remove_file(path)`. +- **Never reclaim stale scratch** — rejected. Windows now defers destructive pathname cleanup on `Drop`, but the next app-owned staging attempt still reclaims a stale regular child only while holding the shared staging lease. ## Claim boundary and residual risk -This repair closes the deterministic Unix/macOS/Linux case where a replacement pathname is already present when cleanup performs its identity check. It does not claim to defeat a malicious process that can win the remaining metadata-check-to-unlink race after the comparison. Descriptor-relative unlink or an equivalent OS capability would be required for that stronger hostile same-user guarantee. +On Unix/macOS/Linux, this repair closes the deterministic case where a replacement pathname is already present when cleanup performs its identity comparison. It does not claim to defeat a malicious process that can win the remaining metadata-check-to-unlink race after the comparison. Descriptor-relative unlink or an equivalent OS capability would be required for that stronger hostile same-user guarantee. -Windows retains the prior best-effort close-then-remove behavior in this commit because stable Rust does not expose an equivalent file-index identity through the same portable metadata API used here. Windows path replacement, hard-link identity, and delete-sharing behavior therefore remain explicit Distribution acceptance gaps rather than being reported as parity-complete. +On Windows, this repair makes the stronger conservative claim that `Drop` will not delete any remembered staging pathname when stable source code cannot prove descriptor/path identity. It consequently leaves cancelled or sealed-but-unpromoted scratch bytes until the next staging attempt or another explicitly owner-safe cleanup path. This is a storage-retention tradeoff, not trust promotion: deferred bytes remain unverified scratch and are never accepted as a release artifact or freshness authority. + +The regression depends on ordinary Windows rename behavior while a Rust file handle is open. Rust's Windows `OpenOptionsExt` documentation states that the default share mode includes `FILE_SHARE_DELETE`, allowing delete/rename by another process while the handle is open. Microsoft documents that `FILE_SHARE_DELETE` permits subsequent delete/rename access and that path-based deletion acts on the current pathname target, which is why close-then-delete cannot establish ownership. ## Product effect -Cancellation and unverified-artifact cleanup no longer intentionally deletes a pathname merely because it has the same basename as the staging object BandScope originally created on Unix desktop targets. This protects unrelated local data from a stale cleanup action without promoting staging bytes to trusted release artifacts or changing the updater trust order. +Cancellation and unverified-artifact cleanup no longer intentionally deletes a pathname merely because it has the same basename as the staging object BandScope originally created on either Unix desktop targets or Windows. Unix removes only identity-matching paths; Windows defers path deletion to the next leased stale-file admission rather than guessing ownership. The release trust order remains: provisional metadata and transport admission → authenticated release identity → cryptographic updater signature verification → sealed-descriptor digest/authenticated-size binding → explicit verified-artifact promotion → anti-replay decision and durable highest-seen mutation. + +## References + +Microsoft. (n.d.). *CreateFileA function (fileapi.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilea + +Microsoft. (n.d.). *DeleteFile function (winbase.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-deletefile + +The Rust Project Developers. (2026). *OpenOptionsExt in std::os::windows::fs* (Rust standard library 1.98.1). https://doc.rust-lang.org/std/os/windows/fs/trait.OpenOptionsExt.html From c69e91de8f966e4c8ced21457e4a6c6b5dbd0453 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 08:08:48 +0900 Subject: [PATCH 271/308] docs(architecture): align Windows staging cleanup semantics --- ARCHITECTURE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 73a89592b..0c1764d4d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -61,7 +61,7 @@ Last updated: 2026-09-16 - `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-download` - network-library-independent Rust streaming/staging boundary for updater artifacts; owns expected-size/content-length/chunk/cumulative limits, exclusive temporary artifact lifecycle, descriptor-bound Unix cleanup, conservative Windows deferred stale-file reclamation, and read-only descriptor-bound verifier access, but not HTTP, signatures, digests or installation - `apps/desktop/distribution-state` - Distribution-owned bounded highest-authenticated-release state; Unix uses a single-link append/sync log, while Windows publishes synchronized replacement snapshots to avoid mutating pre-existing hard-link aliases on stable Rust; it consumes `distribution-core` identity and never project bytes - `packages/shared-types` - stable cross-layer types shared by the UI and orchestration layer - `services/analysis-engine` - Python analysis service for source separation and music analysis @@ -75,7 +75,7 @@ Last updated: 2026-09-16 - `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. Unix cleanup unlinks the staging pathname only when the current direct regular-file `(dev, ino)` still matches the open descriptor, so an already-replaced basename is not deleted as if it were the owned artifact. 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-download` owns the pure streaming/staging primitive used before artifact trust is established. It enforces a 2 GiB artifact ceiling, exact optional `Content-Length`, 1 MiB maximum caller chunk, cumulative overrun rejection before sink write, sink-error poisoning, exact-length completion, exclusive app-owned staging and descriptor-safe cleanup. A sealed artifact remains provisional; downstream verification reads the exact still-open descriptor through a positional `Read` wrapper and cannot obtain the underlying write-capable staging `File` through the public API. Unix cleanup unlinks the staging pathname only when the current direct regular-file `(dev, ino)` still matches the open descriptor, so an already-replaced basename is not deleted as if it were the owned artifact. Windows does not unlink a remembered pathname on `Drop` when stable Rust cannot prove descriptor/path identity; it closes the handle and leaves the unverified scratch file for the next staging attempt, which may reclaim a stale direct regular child only after acquiring the shared staging lease. This trades bounded scratch retention for avoiding deletion of an unrelated replacement object and is not trust promotion. It does not perform network I/O, authenticate metadata, verify signatures/digests, run installers or mutate freshness state. Commercial completion requires the production HTTP adapter to disable implicit redirects, report response state through `distribution-transport`, and route actual response chunks through this boundary rather than relying on Tauri's full-response buffering. - `apps/desktop/distribution-state` persists only the highest authenticated release identity as a bounded ordered log under a sibling OS lease. It revalidates committed identities, rejects local version regression/equivocation, and recovers only a syntactically valid torn final-record prefix. Unix admits only a single-link state object and uses synchronized append/truncate repair. Windows does not depend on nightly-only link-count metadata: when mutation is required it writes the complete committed bounded log to a `create_new` sibling snapshot, synchronizes it, and replaces only the state pathname, preserving any pre-existing hard-link alias file record. This source-level design is not packaged Windows power-loss proof and does not claim protection from a same-user actor that ignores the advisory lease and races filesystem namespace changes. - Tauri updater signatures authenticate downloaded updater artifact bytes. They do not, by themselves, authenticate the whole `Update.raw_json` response or BandScope's `sourceCommit`/digest extensions. Remote metadata therefore stays provisional until a canonical metadata-authentication path binds its release identity to trusted authority. - Only after metadata authentication and updater artifact signature/digest/size binding may exact `version`, `sourceCommit`, updater SHA-256, target, and compatibility floor enter `distribution-core` and `distribution-state` as freshness authority. From 7996cc2a1ae722093ab02c5a590ccc562a2b058b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 08:12:08 +0900 Subject: [PATCH 272/308] test(distribution): align sealed cleanup with Windows contract --- .../tests/sealed_reader.rs | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/desktop/distribution-download/tests/sealed_reader.rs b/apps/desktop/distribution-download/tests/sealed_reader.rs index a85983dba..043bbb326 100644 --- a/apps/desktop/distribution-download/tests/sealed_reader.rs +++ b/apps/desktop/distribution-download/tests/sealed_reader.rs @@ -1,6 +1,7 @@ use bandscope_distribution_download::{ArtifactDownloadAdmission, StagedArtifactFile}; use std::fs::{self, OpenOptions}; use std::io::{ErrorKind, Read, Write}; +use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; fn scratch_dir(label: &str) -> std::path::PathBuf { @@ -16,6 +17,20 @@ fn scratch_dir(label: &str) -> std::path::PathBuf { path } +fn assert_platform_drop_cleanup(path: &Path) { + #[cfg(unix)] + assert!(!path.exists(), "Unix removes the descriptor-owned staging path"); + + #[cfg(not(unix))] + { + assert!( + path.is_file(), + "non-Unix drop defers pathname deletion when descriptor identity cannot be proven" + ); + fs::remove_file(path).expect("remove deferred unverified scratch fixture"); + } +} + #[test] fn sealed_artifact_exposes_descriptor_bound_read_only_stream() { let directory = scratch_dir("sealed-reader"); @@ -38,7 +53,7 @@ fn sealed_artifact_exposes_descriptor_bound_read_only_stream() { drop(reader); drop(sealed); - assert!(!staged_path.exists()); + assert_platform_drop_cleanup(&staged_path); fs::remove_dir(directory).expect("remove staging directory"); } @@ -74,7 +89,7 @@ fn sealed_reader_never_crosses_the_admitted_byte_boundary_after_external_growth( drop(reader); drop(sealed); - assert!(!staged_path.exists()); + assert_platform_drop_cleanup(&staged_path); fs::remove_dir(directory).expect("remove staging directory"); } @@ -108,6 +123,6 @@ fn sealed_reader_fails_closed_when_the_admitted_descriptor_is_truncated() { drop(reader); drop(sealed); - assert!(!staged_path.exists()); + assert_platform_drop_cleanup(&staged_path); fs::remove_dir(directory).expect("remove staging directory"); } From 2acb2f3708b6ef6fb664e7dcc2741fa219c199ed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 08:12:32 +0900 Subject: [PATCH 273/308] test(distribution): align transport staging cleanup by platform --- .../tests/transport_policy.rs | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/desktop/distribution-transport/tests/transport_policy.rs b/apps/desktop/distribution-transport/tests/transport_policy.rs index f109805ab..1a926bed3 100644 --- a/apps/desktop/distribution-transport/tests/transport_policy.rs +++ b/apps/desktop/distribution-transport/tests/transport_policy.rs @@ -4,6 +4,7 @@ use bandscope_distribution_transport::{ ReleaseTransportPolicy, ResponseDecision, TransportDownloadError, TransportPolicyError, }; use std::fs; +use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; const SOURCE_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; @@ -48,6 +49,20 @@ fn scratch_dir(label: &str) -> std::path::PathBuf { path } +fn assert_platform_drop_cleanup(path: &Path) { + #[cfg(unix)] + assert!(!path.exists(), "Unix removes the descriptor-owned staging path"); + + #[cfg(not(unix))] + { + assert!( + path.is_file(), + "non-Unix drop defers pathname deletion when descriptor identity cannot be proven" + ); + fs::remove_file(path).expect("remove deferred unverified scratch fixture"); + } +} + #[test] fn malformed_tauri_signature_envelope_is_rejected_by_metadata_owner() { assert_eq!( @@ -88,7 +103,7 @@ fn github_release_redirect_is_one_hop_and_streams_through_bounded_staging() { 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"); + assert_platform_drop_cleanup(&path); fs::remove_dir(directory).expect("remove staging directory"); } @@ -191,7 +206,7 @@ fn explicit_identity_content_encoding_remains_admitted() { 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"); + assert_platform_drop_cleanup(&path); fs::remove_dir(directory).expect("remove staging directory"); } @@ -211,7 +226,9 @@ fn cancelled_transport_drops_partial_staging_bytes() { .expect("start bounded staging"); download.admit_chunk(b"da").expect("partial chunk"); assert_eq!(fs::read_dir(&directory).expect("read staging directory").count(), 1); + let path = download.path().to_path_buf(); drop(download); + assert_platform_drop_cleanup(&path); assert_eq!(fs::read_dir(&directory).expect("read staging directory").count(), 0); fs::remove_dir(directory).expect("remove staging directory"); } From 4c94b4698c79fd32f659fb58106f2da0aa510d1a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 08:13:05 +0900 Subject: [PATCH 274/308] test(distribution): align staging lifecycle with Windows cleanup --- .../tests/staged_artifact.rs | 48 ++++++++++++------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/apps/desktop/distribution-download/tests/staged_artifact.rs b/apps/desktop/distribution-download/tests/staged_artifact.rs index d1d42e3be..986fd41df 100644 --- a/apps/desktop/distribution-download/tests/staged_artifact.rs +++ b/apps/desktop/distribution-download/tests/staged_artifact.rs @@ -25,16 +25,31 @@ fn scratch_dir(label: &str) -> std::path::PathBuf { path } -fn remove_scratch_dir(directory: &Path) { - let lease_path = directory.join(".bandscope-staging.lock"); - match fs::remove_file(&lease_path) { +fn remove_file_if_present(path: &Path) { + match fs::remove_file(path) { Ok(()) => {} Err(error) if error.kind() == ErrorKind::NotFound => {} - Err(error) => panic!("remove staging lease fixture: {error}"), + Err(error) => panic!("remove fixture {}: {error}", path.display()), } +} + +fn remove_scratch_dir(directory: &Path) { + remove_file_if_present(&directory.join("update.bin")); + remove_file_if_present(&directory.join(".bandscope-staging.lock")); fs::remove_dir(directory).expect("remove staging directory"); } +fn assert_platform_drop_cleanup(path: &Path) { + #[cfg(unix)] + assert!(!path.exists(), "Unix removes the descriptor-owned staging path"); + + #[cfg(not(unix))] + assert!( + path.is_file(), + "non-Unix drop defers pathname deletion when descriptor identity cannot be proven" + ); +} + fn wait_for_path(path: &Path, label: &str) { for _ in 0..1_000 { if path.exists() { @@ -46,7 +61,7 @@ fn wait_for_path(path: &Path, label: &str) { } #[test] -fn cancelled_staging_file_is_removed_on_drop() { +fn cancelled_staging_file_follows_platform_cleanup_contract() { let directory = scratch_dir("cancel"); let staged = StagedArtifactFile::create(&directory, "update.bin").expect("stage file"); let path = staged.path().to_path_buf(); @@ -54,12 +69,12 @@ fn cancelled_staging_file_is_removed_on_drop() { drop(staged); - assert!(!path.exists()); + assert_platform_drop_cleanup(&path); remove_scratch_dir(&directory); } #[test] -fn sealed_but_unverified_artifact_is_removed_on_drop() { +fn sealed_but_unverified_artifact_follows_platform_cleanup_contract() { let directory = scratch_dir("seal"); let mut staged = StagedArtifactFile::create(&directory, "update.bin").expect("stage file"); let mut admission = ArtifactDownloadAdmission::new(4, Some(4)).expect("admission"); @@ -74,7 +89,7 @@ fn sealed_but_unverified_artifact_is_removed_on_drop() { let path = sealed.path().to_path_buf(); drop(sealed); - assert!(!path.exists()); + assert_platform_drop_cleanup(&path); remove_scratch_dir(&directory); } @@ -97,13 +112,13 @@ fn sealed_unverified_artifact_keeps_staging_lease() { drop(sealed); let replacement = StagedArtifactFile::create(&directory, "update.bin") - .expect("lease must release after sealed cleanup"); + .expect("lease must release and stale scratch must be reclaimable"); drop(replacement); remove_scratch_dir(&directory); } #[test] -fn failed_admission_removes_partial_staging_file() { +fn failed_admission_follows_platform_cleanup_contract() { let directory = scratch_dir("overrun"); let mut staged = StagedArtifactFile::create(&directory, "update.bin").expect("stage file"); let path = staged.path().to_path_buf(); @@ -115,12 +130,12 @@ fn failed_admission_removes_partial_staging_file() { drop(staged); - assert!(!path.exists()); + assert_platform_drop_cleanup(&path); remove_scratch_dir(&directory); } #[test] -fn receipt_size_mismatch_removes_unsealed_staging_file() { +fn receipt_size_mismatch_follows_platform_cleanup_contract() { let directory = scratch_dir("receipt-mismatch"); let staged = StagedArtifactFile::create(&directory, "update.bin").expect("stage file"); let path = staged.path().to_path_buf(); @@ -132,7 +147,7 @@ fn receipt_size_mismatch_removes_unsealed_staging_file() { let receipt = unrelated_admission.finish().expect("receipt"); assert_eq!(staged.seal(receipt).unwrap_err(), StagingArtifactError::SizeMismatch); - assert!(!path.exists()); + assert_platform_drop_cleanup(&path); remove_scratch_dir(&directory); } @@ -147,7 +162,7 @@ fn stale_regular_destination_is_reclaimed_before_new_attempt() { assert_eq!(fs::metadata(&path).expect("replacement metadata").len(), 0); drop(staged); - assert!(!path.exists()); + assert_platform_drop_cleanup(&path); remove_scratch_dir(&directory); } @@ -168,7 +183,7 @@ fn active_staging_attempt_is_not_reclaimed_as_stale() { assert!(path.exists()); drop(first); - assert!(!path.exists()); + assert_platform_drop_cleanup(&path); let replacement = StagedArtifactFile::create(&directory, "update.bin") .expect("released active attempt must allow a fresh retry"); @@ -216,7 +231,8 @@ fn separate_process_cannot_reclaim_live_staging_attempt() { 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 staging_path = directory.join("update.bin"); + assert_platform_drop_cleanup(&staging_path); let replacement = StagedArtifactFile::create(&directory, "update.bin") .expect("fresh attempt after child process release"); From 2e4c355df5c45117c01ff1585e6478a8951ad168 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 08:13:48 +0900 Subject: [PATCH 275/308] test(distribution): clean persistent lease fixtures explicitly --- apps/desktop/distribution-download/tests/sealed_reader.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/desktop/distribution-download/tests/sealed_reader.rs b/apps/desktop/distribution-download/tests/sealed_reader.rs index 043bbb326..ef293cbcf 100644 --- a/apps/desktop/distribution-download/tests/sealed_reader.rs +++ b/apps/desktop/distribution-download/tests/sealed_reader.rs @@ -31,6 +31,11 @@ fn assert_platform_drop_cleanup(path: &Path) { } } +fn remove_staging_lease(directory: &Path) { + fs::remove_file(directory.join(".bandscope-staging.lock")) + .expect("remove persistent staging lease fixture"); +} + #[test] fn sealed_artifact_exposes_descriptor_bound_read_only_stream() { let directory = scratch_dir("sealed-reader"); @@ -54,6 +59,7 @@ fn sealed_artifact_exposes_descriptor_bound_read_only_stream() { drop(reader); drop(sealed); assert_platform_drop_cleanup(&staged_path); + remove_staging_lease(&directory); fs::remove_dir(directory).expect("remove staging directory"); } @@ -90,6 +96,7 @@ fn sealed_reader_never_crosses_the_admitted_byte_boundary_after_external_growth( drop(reader); drop(sealed); assert_platform_drop_cleanup(&staged_path); + remove_staging_lease(&directory); fs::remove_dir(directory).expect("remove staging directory"); } @@ -124,5 +131,6 @@ fn sealed_reader_fails_closed_when_the_admitted_descriptor_is_truncated() { drop(reader); drop(sealed); assert_platform_drop_cleanup(&staged_path); + remove_staging_lease(&directory); fs::remove_dir(directory).expect("remove staging directory"); } From 6a85d358d46194d60a852f59463fa23252785992 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 08:14:12 +0900 Subject: [PATCH 276/308] test(distribution): account for persistent staging lease --- .../tests/transport_policy.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/desktop/distribution-transport/tests/transport_policy.rs b/apps/desktop/distribution-transport/tests/transport_policy.rs index 1a926bed3..f7ff39377 100644 --- a/apps/desktop/distribution-transport/tests/transport_policy.rs +++ b/apps/desktop/distribution-transport/tests/transport_policy.rs @@ -49,6 +49,14 @@ fn scratch_dir(label: &str) -> std::path::PathBuf { path } +fn staging_lease_path(directory: &Path) -> std::path::PathBuf { + directory.join(".bandscope-staging.lock") +} + +fn remove_staging_lease(directory: &Path) { + fs::remove_file(staging_lease_path(directory)).expect("remove persistent staging lease fixture"); +} + fn assert_platform_drop_cleanup(path: &Path) { #[cfg(unix)] assert!(!path.exists(), "Unix removes the descriptor-owned staging path"); @@ -104,6 +112,7 @@ fn github_release_redirect_is_one_hop_and_streams_through_bounded_staging() { let path = sealed.path().to_path_buf(); drop(sealed); assert_platform_drop_cleanup(&path); + remove_staging_lease(&directory); fs::remove_dir(directory).expect("remove staging directory"); } @@ -207,11 +216,12 @@ fn explicit_identity_content_encoding_remains_admitted() { let path = sealed.path().to_path_buf(); drop(sealed); assert_platform_drop_cleanup(&path); + remove_staging_lease(&directory); fs::remove_dir(directory).expect("remove staging directory"); } #[test] -fn cancelled_transport_drops_partial_staging_bytes() { +fn cancelled_transport_releases_lease_and_preserves_platform_cleanup_contract() { let policy = policy(); let head = match policy .admit_initial_response(200, INITIAL_URL, None) @@ -225,10 +235,13 @@ fn cancelled_transport_drops_partial_staging_bytes() { .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); let path = download.path().to_path_buf(); + assert!(path.is_file()); + assert!(staging_lease_path(&directory).is_file()); + drop(download); assert_platform_drop_cleanup(&path); - assert_eq!(fs::read_dir(&directory).expect("read staging directory").count(), 0); + assert!(staging_lease_path(&directory).is_file()); + remove_staging_lease(&directory); fs::remove_dir(directory).expect("remove staging directory"); } From 57385c1d756d169ce223ac7e1400758fc3f8e320 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 08:15:33 +0900 Subject: [PATCH 277/308] docs(distribution): align bounded staging cleanup claims --- docs/traceability/updater-bounded-download.md | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/docs/traceability/updater-bounded-download.md b/docs/traceability/updater-bounded-download.md index e18506830..e84e16fa7 100644 --- a/docs/traceability/updater-bounded-download.md +++ b/docs/traceability/updater-bounded-download.md @@ -13,10 +13,10 @@ BandScope의 Distribution/update 경계는 updater artifact를 신뢰하기 전 - 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 제거합니다. +- Staging fix `ed079fdc4b6150515a1352e892307d6b24bedf6e`: `StagedArtifactFile`과 `SealedArtifactFile`을 추가해 app-owned staging directory 안의 direct portable basename만 `create_new`로 생성하고, response bytes는 public raw-write API가 아니라 `admit_chunk`를 통해서만 descriptor로 보냅니다. Seal은 flush → `sync_all()` → descriptor metadata regular-file/size 확인 후에만 성공하며 still-open descriptor를 반환합니다. 당시 구현은 seal 전 drop/cancel/error 뒤 staging path를 best-effort 제거했습니다. - Coverage `762024843218a86567c855ee1474a10549a3032a`: receipt-size mismatch cleanup, missing/non-directory staging root와 Unix symlink staging-root rejection까지 추가했습니다. -- Trust-promotion RED `a956bcfab7670aa7a461c8929c75cda8b79ba118`: exact-size seal만 성공하면 `SealedArtifactFile` drop 뒤에도 bytes가 남는 기존 동작을 뒤집어, digest/signature trust promotion 전 sealed artifact는 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 경로도 없습니다. +- Trust-promotion RED `a956bcfab7670aa7a461c8929c75cda8b79ba118`: exact-size seal만 성공하면 `SealedArtifactFile` drop 뒤에도 bytes가 남는 기존 동작을 뒤집어, digest/signature trust promotion 전 sealed artifact가 정상 retention 경로로 남아서는 안 된다는 integration contract를 먼저 만들었습니다. +- Causal fix `e76abddb0c40293901cd8672919172d47a93b5b9`: seal은 더 이상 artifact retention을 의미하지 않습니다. `SealedArtifactFile`이 descriptor cleanup 책임을 넘겨받고 당시 구현은 drop 시 descriptor를 먼저 닫은 뒤 staging path를 제거했습니다. 아직 별도의 verified-artifact promotion type은 만들지 않았으므로 unverified sealed bytes를 신뢰된 장기 보존으로 승격하는 public 경로도 없습니다. - Descriptor-capability RED `56aa7467a43299500e79d2e26469b252ae9519c0`: sealed artifact 검증자가 path reopen 없이 byte zero부터 exact descriptor bytes를 읽을 수 있는 read-only stream contract를 먼저 추가했습니다. 당시 `SealedArtifactFile`에는 `reader()`가 없고 대신 write-enabled staging `File`을 `&File`로 직접 노출하고 있어 RED입니다. - Compatibility cleanup `13ca9b7862f59c06f5dcc0337c846c050a3c7199`: 기존 staging lifecycle test가 raw `File` accessor에 의존하지 않도록 정리해 capability 제거를 준비했습니다. - Causal fix `6144302ed807367742f87247b353742f213dbedb`: public `&File` accessor를 제거하고 `SealedArtifactReader`를 추가했습니다. Reader는 Unix/macOS에서 `FileExt::read_at`, Windows에서 `FileExt::seek_read`를 사용해 still-open descriptor를 path reopen 없이 positional read하며 `Read`만 구현합니다. Staging descriptor는 내부적으로 read/write로 열려 있어도 downstream verifier가 그 write capability를 회수할 public API가 없습니다. @@ -26,10 +26,15 @@ BandScope의 Distribution/update 경계는 updater artifact를 신뢰하기 전 - 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 뒤에만 해제됩니다. +- Causal fix `40cd7543fab6ac6cb203e310b16058645edcedae`: artifact pathname을 검사하거나 stale regular child를 제거하기 전에 app-owned staging directory의 persistent `.bandscope-staging.lock`을 열고 `File::try_lock()` exclusive lease를 획득합니다. 다른 cooperating BandScope handle/process가 lock을 보유하면 `ConcurrentAttempt`로 fail closed합니다. Lease는 `StagedArtifactFile`에서 `SealedArtifactFile`로 함께 이동하고 descriptor cleanup 뒤에만 해제됩니다. - Fixture adaptation `ebf94287ea54d329a3276f02a5251054c9b2d20c`: persistent lease sentinel과 ephemeral artifact cleanup을 test teardown에서 구분했습니다. -- Edge coverage `d2d187288ef27e7fabdacde062d83423bfa2e243`: sealed-but-unverified artifact가 lease를 계속 보유하는지, sealed cleanup 뒤 fresh attempt가 가능한지, Unix에서 lease sentinel symlink를 따라가지 않는지를 검증합니다. +- Edge coverage `d2d187288ef27e7fabdacde062d83423bfa2e243`: sealed-but-unverified artifact가 lease를 계속 보유하는지, cleanup 뒤 fresh attempt가 가능한지, Unix에서 lease sentinel symlink를 따라가지 않는지를 검증합니다. - Restart/concurrency traceability `ebefa230880f3e460be012af9cbc42651814c73f`: stale recovery, active-process ownership, persistent sentinel, OS lock의 claim boundary와 기각 대안을 별도 traceability 문서에 연결했습니다. +- Windows pathname RED `308f4a618cbbfd07fc9380b721f9987b3cb373d1`: cancelled/sealed artifact가 열려 있는 동안 owned file을 다른 이름으로 이동하고 original basename에 unrelated replacement를 만든 뒤 Drop했을 때 replacement bytes가 살아 있어야 한다는 기존 Unix contract를 Windows까지 확장했습니다. 당시 non-Unix fallback은 descriptor close 후 remembered pathname을 무조건 삭제하므로 RED입니다. +- Windows causal fix `1d603316b0ff4ff8b737aea2ef57c76250e326d0`: Unix는 still-open descriptor `(dev, ino)`와 현재 direct regular pathname identity가 일치할 때만 unlink합니다. Stable Rust에서 동등한 Windows by-handle identity를 증명할 수 없는 경로는 descriptor를 닫되 remembered pathname을 삭제하지 않습니다. 다른 객체를 지울 가능성보다 unverified scratch를 임시로 남기는 쪽을 선택했습니다. +- Windows deferred-reclaim coverage `1a6ee097106cf2204cf9bdbcc0040999988f4e14`: deferred Windows scratch가 다음 staging attempt의 shared lease 아래에서 stale direct regular child로 회수되고 byte zero에서 새 attempt가 시작됨을 고정합니다. +- Path-identity traceability `0bae56fe905ed165c6973867f6c7e896c431195f`: Windows 기본 sharing, 기각 대안, storage-retention tradeoff와 residual pathname race의 claim boundary를 `updater-staging-path-identity.md`에 기록했습니다. +- Test-contract adaptation `7996cc2a1ae722093ab02c5a590ccc562a2b058b`, `2acb2f3708b6ef6fb664e7dcc2741fa219c199ed`, `4c94b4698c79fd32f659fb58106f2da0aa510d1a`, `2e4c355df5c45117c01ff1585e6478a8951ad168`, `6a85d358d46194d60a852f59463fa23252785992`: sealed-reader, staging-lifecycle, and transport integration fixtures를 OS별 cleanup contract 및 persistent lease sentinel semantics에 맞췄습니다. Windows에서 deferred scratch를 trust success로 간주하지 않고 fixture teardown 또는 다음 leased attempt가 명시적으로 회수합니다. ## 실행 계약 @@ -53,15 +58,15 @@ BandScope의 Distribution/update 경계는 updater artifact를 신뢰하기 전 - 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를 유지하지 않습니다. +- cancel, overrun, sink failure 또는 seal failure 상태로 drop되면 descriptor ownership은 종료되지만 pathname cleanup은 OS별입니다. Unix는 현재 pathname이 still-open descriptor와 같은 direct regular object일 때만 unlink합니다. Windows는 stable code에서 그 identity를 증명할 수 없으므로 pathname 삭제를 추측하지 않고 unverified scratch를 다음 leased attempt까지 남길 수 있습니다. - seal은 userspace flush와 descriptor `sync_all()` 이후 descriptor가 regular file인지, exact receipt size와 같은지 다시 확인합니다. - 성공한 `SealedArtifactFile`은 descriptor와 staging lease를 함께 계속 열어 두므로 후속 digest/signature verification이 path reopen보다 exact staged bytes에 결합되고, 검증 중 다른 cooperating attempt가 pathname을 stale로 reclaim하지 못합니다. - sealed verifier access는 `SealedArtifactReader`의 positional `Read` stream으로 제한합니다. 내부 staging `File`은 write-enabled이지만 raw `&File`을 public하게 반환하지 않으므로 verifier가 `Write for &File` 또는 platform `FileExt` write API로 sealed bytes를 바꾸는 capability를 얻지 않습니다. - `SealedArtifactReader`는 seal 당시 admitted byte count까지만 읽습니다. Seal 뒤 같은 inode가 더 길어져도 appended bytes는 verifier input이 되지 않으며, admitted range가 짧아지면 정상 EOF가 아니라 `UnexpectedEof`로 거부합니다. 따라서 verifier input의 resource bound가 path-side file growth 때문에 다시 열리지 않습니다. -- exact-size seal은 신뢰 승격이 아닙니다. `SealedArtifactFile` 자체는 cleanup-on-drop이며 descriptor를 먼저 닫아 artifact pathname을 제거한 뒤 staging lease를 해제합니다. 후속 digest/signature/authenticated-metadata 결합이 성공하기 전에는 unverified bytes가 정상 종료 경로에서 남지 않습니다. +- exact-size seal은 신뢰 승격이 아닙니다. Unix에서는 identity가 일치하는 staging pathname을 Drop에서 제거합니다. Windows에서는 pathname ownership을 증명할 수 없을 때 descriptor만 닫고 unverified scratch를 남길 수 있으며, 다음 attempt가 shared lease를 획득한 뒤 stale direct regular child로만 회수합니다. 어느 경우에도 deferred bytes는 verified artifact나 freshness authority가 아닙니다. - verified artifact promotion은 이 scratch basename을 장기 보존 위치로 재사용해서는 안 됩니다. 검증된 bytes를 별도 retained/known-good owner로 이동한 뒤에만 launch 간 보존을 허용해야 합니다. -Unit/integration tests는 exact chunked completion, missing `Content-Length`, header mismatch, overrun-before-write, oversized single chunk, truncated response, partial sink failure, zero/over-ceiling expected size, 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로 호출합니다. +Unit/integration tests는 exact chunked completion, missing `Content-Length`, header mismatch, overrun-before-write, oversized single chunk, truncated response, partial sink failure, zero/over-ceiling expected size, OS별 cancellation cleanup, exact seal 후 Unix unlink/Windows deferred reclamation, pathname replacement 보존, descriptor-bound read-only sealed stream, seal 후 external growth에 대한 admitted-range cap, seal 후 truncation fail-closed, failed-admission cleanup, receipt mismatch, stale regular destination restart recovery, active concurrent staging rejection, sealed lease retention/release, persistent lease sentinel teardown, path-like name, invalid staging root, Unix symlink root·lease sentinel·artifact destination 보존을 다룹니다. Python production logic은 추가하지 않았고 repository harness는 locked Rust suite를 validation boundary로 호출합니다. ## 기각한 대안 @@ -81,7 +86,7 @@ Artifact file 자체만 lock하는 방식도 기각합니다. Portable `create_n 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에 잔존할 수 있습니다. +Exact-size seal을 곧바로 artifact retention으로 취급하는 방식도 기각합니다. Byte count와 `sync_all()`은 digest, updater signature, remote metadata authenticity를 증명하지 않습니다. Windows에서 안전한 pathname unlink를 증명할 수 없어 unverified scratch가 다음 leased attempt까지 남을 수는 있지만, 이는 trust promotion이나 known-good retention이 아니라 cleanup을 보수적으로 지연한 것입니다. Sealed artifact에서 raw `&File`을 verifier에 넘기는 방식도 기각합니다. Rust standard library는 `Write for &File`을 구현하고 있고 staging descriptor 자체가 write access로 열린 상태이므로, immutable borrow처럼 보이는 API가 실제로는 sealed bytes를 바꿀 수 있는 write capability를 노출합니다. 별도 path reopen은 descriptor identity를 잃으므로, 동일 open descriptor에 대한 positional read-only wrapper를 사용합니다. @@ -91,13 +96,13 @@ Descriptor EOF까지 무제한 읽는 방식도 기각합니다. Seal 당시에 현재 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도 검증하지 않습니다. +Stale regular child recovery와 active-writer tests는 process-kill 뒤 동일 update가 영구 차단되거나 다른 live BandScope attempt가 pathname을 reclaim하는 source 경로를 닫습니다. Unix pathname cleanup은 metadata-check 이후 unlink까지의 hostile same-user TOCTOU를 완전히 제거한다고 주장하지 않습니다. Windows는 stable Rust에서 descriptor/path identity를 확인하지 못하므로 destructive Drop cleanup을 하지 않고 stale scratch retention을 허용합니다. 이 retention은 다음 leased attempt에서 bounded regular-child recovery가 가능한 범위이며, packaged Windows/macOS power-loss durability, antivirus/file-lock, disk-full, filesystem crash 전체를 증명하지 않습니다. `sync_all()`과 cleanup tests를 packaged durability와 동일시하지 않으며 이 crate는 SHA-256, updater signature, metadata authenticity, installer trust도 검증하지 않습니다. -다음 repository-owned 단계는 production network adapter가 full-response buffering 없이 bounded chunks를 이 primitive에 전달하도록 연결하는 것입니다. 그 adapter는 canonical release origin/redirect 정책을 보존하고 implicit redirect/transparent decompression을 끄며, cancel/network error/disk-full을 staged-file cleanup으로 귀결시켜야 합니다. 그 뒤 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를 넘겨야 합니다. +다음 repository-owned 단계는 production network adapter가 full-response buffering 없이 bounded chunks를 이 primitive에 전달하도록 연결하는 것입니다. 그 adapter는 canonical release origin/redirect 정책을 보존하고 implicit redirect/transparent decompression을 끄며, cancel/network error/disk-full을 staged-file cleanup 또는 명시적인 deferred-scratch recovery 상태로 귀결시켜야 합니다. 그 뒤 organization-approved updater key가 provision되면 still-open sealed descriptor의 signature와 digest/size를 authenticated release identity에 묶고, 그 검증을 통과한 bytes만 별도의 verified-artifact promotion 경계로 보존한 뒤 `distribution-core`와 `distribution-state`로 freshness authority를 넘겨야 합니다. ## Security Notes -Attack surface는 updater HTTP response body, transport length metadata, temporary artifact directory/path, staging lease, staged descriptor와 cancellation/error paths입니다. Remote response는 canonical release namespace를 통과해도 untrusted입니다. Byte/staging admission failure는 installer 실행이나 highest-seen state mutation으로 승격되지 않아야 하며, staging root는 Distribution-owned app storage로 제한해야 합니다. Restart recovery는 stale bytes를 살리는 기능이 아니라 active cooperating owner가 없음을 lease로 확인한 뒤 app-owned scratch의 exact regular child를 제거하고 새 admission을 시작하는 기능입니다. Lease sentinel과 artifact pathname의 symlink/non-regular object는 자동 정리하지 않습니다. 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에 포함하지 않습니다. +Attack surface는 updater HTTP response body, transport length metadata, temporary artifact directory/path, staging lease, staged descriptor와 cancellation/error paths입니다. Remote response는 canonical release namespace를 통과해도 untrusted입니다. Byte/staging admission failure는 installer 실행이나 highest-seen state mutation으로 승격되지 않아야 하며, staging root는 Distribution-owned app storage로 제한해야 합니다. Restart recovery는 stale bytes를 살리는 기능이 아니라 active cooperating owner가 없음을 lease로 확인한 뒤 app-owned scratch의 exact regular child를 제거하고 새 admission을 시작하는 기능입니다. Lease sentinel과 artifact pathname의 symlink/non-regular object는 자동 정리하지 않습니다. Unix Drop은 descriptor identity가 일치하는 direct regular pathname만 제거하고, Windows Drop은 stable code에서 identity를 증명할 수 없으면 pathname 삭제를 지연합니다. Deferred Windows scratch는 여전히 unverified이며 다음 leased attempt가 stale regular child로만 회수합니다. Sealed descriptor의 raw write capability는 verifier에 노출하지 않으며, 후속 검증은 descriptor-bound read-only stream을 사용해야 합니다. 그 stream은 seal 당시 admitted byte count를 상한으로 삼아 post-seal growth를 무시하고 early truncation을 error로 처리해야 합니다. Audio/project bytes나 paths는 updater request/receipt에 포함하지 않습니다. ## References @@ -113,4 +118,4 @@ Rust Project Developers. (2026). *Write in std::io* (Rust 1.98). https://doc.rus 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 +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 From 64fe0bc1a0ab87a534f02122df058623b626d641 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 08:16:12 +0900 Subject: [PATCH 278/308] docs(distribution): make restart recovery OS-cleanup current --- .../updater-staging-restart-recovery.md | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/docs/traceability/updater-staging-restart-recovery.md b/docs/traceability/updater-staging-restart-recovery.md index 8082b23c7..ae55dc258 100644 --- a/docs/traceability/updater-staging-restart-recovery.md +++ b/docs/traceability/updater-staging-restart-recovery.md @@ -1,27 +1,31 @@ # 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을 함께 만족해야 합니다. +BandScope의 updater staging은 신뢰 검증 전 bytes만 두는 scratch namespace입니다. Unix의 정상 cancel/error/drop은 descriptor identity가 일치하는 staging pathname을 제거합니다. Windows는 stable Rust에서 remembered pathname과 owned descriptor의 동일성을 안전하게 증명할 수 없으면 destructive pathname cleanup을 지연하며, 다음 leased attempt가 stale direct regular child를 회수합니다. 프로세스 강제 종료나 전원 상실은 어느 OS에서도 Rust `Drop`을 실행하지 않을 수 있습니다. 반대로 살아 있는 다른 BandScope 인스턴스의 regular staging file을 crash residue로 오인해 지우면 안 됩니다. Restart recovery와 concurrent ownership을 함께 만족해야 합니다. ## 문제와 제약 최초 구현은 `create_new`만 사용했기 때문에 crash 뒤 남은 regular child가 다음 동일 업데이트를 영구적으로 `DestinationExists`에 가둘 수 있었습니다. 이를 고친 `b7a1839d5941c52800bbeaf22921e143060d1ff6`는 app-owned staging의 pre-existing regular child를 stale unverified bytes로 보고 제거했습니다. -그 수리만으로는 충분하지 않았습니다. 다른 BandScope 프로세스가 같은 basename을 실제로 staging 중이어도 pathname만 보면 regular file이므로 두 번째 프로세스가 이를 stale로 오인해 unlink할 수 있었습니다. Unix에서는 첫 번째 writer가 이미 unlink된 inode에 계속 쓸 수 있고 두 번째 writer는 같은 pathname에 새 inode를 만들 수 있어, 두 live attempts가 서로 다른 bytes를 같은 logical staging identity로 취급할 수 있습니다. 첫 writer의 drop cleanup이 뒤늦게 두 번째 writer의 pathname을 제거할 위험도 있습니다. Windows의 open-file 삭제 동작과도 결과가 달라질 수 있어 cross-platform recovery contract로 둘 수 없습니다. +그 수리만으로는 충분하지 않았습니다. 다른 BandScope 프로세스가 같은 basename을 실제로 staging 중이어도 pathname만 보면 regular file이므로 두 번째 프로세스가 이를 stale로 오인해 unlink할 수 있었습니다. Unix에서는 첫 번째 writer가 이미 unlink된 inode에 계속 쓸 수 있고 두 번째 writer는 같은 pathname에 새 inode를 만들 수 있어, 두 live attempts가 서로 다른 bytes를 같은 logical staging identity로 취급할 수 있습니다. 첫 writer의 drop cleanup이 뒤늦게 두 번째 writer의 pathname을 제거할 위험도 있습니다. Windows의 open-file delete/rename sharing과 pathname replacement semantics도 별도로 다뤄야 하므로 cross-platform cleanup을 단일 pathname-only contract로 둘 수 없습니다. -Stale bytes를 resume하거나 신뢰하는 것도 허용하지 않습니다. 이전 프로세스가 남긴 bytes에는 response completion, digest, updater signature, metadata authenticity 증거가 없습니다. 이 namespace에는 verified artifact를 장기 보존하지 않으며, 향후 promotion은 별도 retained/known-good owner가 맡습니다. +Stale bytes를 resume하거나 신뢰하는 것도 허용하지 않습니다. 이전 프로세스가 남긴 bytes에는 response completion, digest, updater signature, metadata authenticity 증거가 없습니다. Windows에서 정상 Drop 뒤 보수적으로 남겨 둔 bytes도 동일하게 unverified scratch입니다. 이 namespace에는 verified artifact를 장기 보존하지 않으며, 향후 promotion은 별도 retained/known-good owner가 맡습니다. ## RED → causal fix - Restart RED `5b0ddb585ee1eb7ddadaa66eeab267c6f55d6467`: 이전 프로세스가 남긴 `update.bin` regular file은 재사용하지 않고 byte zero부터 새 exclusive attempt로 교체해야 하며 destination symlink는 stale regular file로 오인하지 않아야 한다는 계약을 추가했습니다. - Restart causal fix `b7a1839d5941c52800bbeaf22921e143060d1ff6`: exact direct child가 regular file일 때만 stale unverified scratch로 제거한 뒤 `create_new`로 새 descriptor를 만듭니다. Symlink·directory·기타 non-regular object는 fail closed합니다. - Concurrent-writer RED `82843b4833df264aa6d5530d9f46545b1179a0bb`: 첫 `StagedArtifactFile`이 partial bytes를 쓰고 살아 있는 동안 같은 staging namespace에서 두 번째 attempt가 기존 pathname을 reclaim해서는 안 되며 `ConcurrentAttempt`로 실패해야 한다는 계약을 추가했습니다. 기존 stale-recovery 구현은 live regular child도 삭제하므로 이 계약을 만족하지 못합니다. -- Causal fix `40cd7543fab6ac6cb203e310b16058645edcedae`: stale-file 분류보다 먼저 app-owned staging directory의 persistent `.bandscope-staging.lock`을 열고 `File::try_lock()` exclusive lease를 취득합니다. 이미 다른 BandScope handle/process가 lease를 갖고 있으면 `ConcurrentAttempt`로 fail closed합니다. Lease는 staged descriptor와 함께 유지되고 `seal` 시 `SealedArtifactFile`로 이동하여 digest/signature verification 전까지 같은 scratch namespace를 보호합니다. Artifact cleanup이 끝난 뒤 handle을 닫아 lease를 해제합니다. +- Causal fix `40cd7543fab6ac6cb203e310b16058645edcedae`: stale-file 분류보다 먼저 app-owned staging directory의 persistent `.bandscope-staging.lock`을 열고 `File::try_lock()` exclusive lease를 취득합니다. 이미 다른 BandScope handle/process가 lease를 갖고 있으면 `ConcurrentAttempt`로 fail closed합니다. Lease는 staged descriptor와 함께 유지되고 `seal` 시 `SealedArtifactFile`로 이동하여 digest/signature verification 전까지 같은 scratch namespace를 보호합니다. Descriptor cleanup이 끝난 뒤 handle을 닫아 lease를 해제합니다. - Fixture adaptation `ebf94287ea54d329a3276f02a5251054c9b2d20c`: persistent lease sentinel은 crash-safe coordination object이므로 test teardown이 artifact cleanup과 sentinel cleanup을 구분하도록 고쳤습니다. - Edge coverage `d2d187288ef27e7fabdacde062d83423bfa2e243`: sealed-but-unverified 상태에서도 lease가 유지되는지, drop 이후 새 attempt가 가능한지, Unix에서 lease sentinel symlink를 따라가지 않는지를 고정했습니다. - Cross-platform fixture hardening `752b5343c809b8e8f76a9886295de42e19ebc3ff`: Rust가 file lock과 ordinary read/write의 상호작용을 platform-specific으로 명시하므로, lease를 보유한 sealed artifact를 별도 pathname handle로 읽는 테스트 가정을 제거하고 path 존재/ownership과 `ConcurrentAttempt`만 검증하도록 고쳤습니다. Product code나 trust semantics는 바꾸지 않습니다. - Platform-evidence RED `41afd2abb6f3beeded35d2576f3f1e9532b75ce3`: Ubuntu-only native-suite execution만으로 Windows/macOS file-lock semantics를 release evidence로 삼지 못하도록, `ci.yml`이 Linux·Windows·macOS에서 exact `distribution-download` locked all-target test를 실행하고 protected `ci / build-and-test`가 그 matrix를 선행조건으로 가져야 한다는 repository contract를 추가했습니다. - Platform-evidence fix `cfb3ec11503fd8b7abafce05f07ea916cc34153c`: `distribution-download-platform` CI matrix를 `ubuntu-latest`, `windows-2025`, `macos-15`로 추가하고 각 runner에서 `cargo +stable test --manifest-path apps/desktop/distribution-download/Cargo.toml --locked --all-targets`를 실행합니다. Main `ci / build-and-test`는 이 matrix와 npm lock validation을 모두 `needs`로 요구하므로 platform lease test가 실패한 상태에서 required main CI gate가 성공할 수 없습니다. - Real-process coverage `44265a038bf1c162df15539de0bcfaaf6f286bea`: same-process handle contention만으로 process coordination을 추정하지 않도록 integration test가 현재 test binary를 별도 child process로 실행합니다. Child가 실제 staging lease와 artifact를 보유한 뒤 readiness signal을 내고, parent는 같은 staging namespace의 create가 `ConcurrentAttempt`로 실패하며 pathname이 보존되는지 확인합니다. Child process가 lease를 해제한 뒤 parent fresh attempt가 성공해야 test가 끝납니다. 이 test도 위 OS matrix에서 실행됩니다. +- Windows pathname RED `308f4a618cbbfd07fc9380b721f9987b3cb373d1`: owned staging file을 열린 상태에서 이동하고 original basename에 unrelated replacement를 만든 뒤 cancelled/sealed owner를 Drop했을 때 replacement가 살아 있어야 한다는 contract를 Windows까지 확장했습니다. 이전 non-Unix fallback은 descriptor를 닫고 pathname을 무조건 삭제하므로 이 case를 위반했습니다. +- Windows cleanup fix `1d603316b0ff4ff8b737aea2ef57c76250e326d0`: stable Rust에서 descriptor/path identity를 증명할 수 없는 non-Unix 경로는 remembered pathname을 삭제하지 않고 descriptor만 닫습니다. Unix는 `(dev, ino)` equality 확인 뒤에만 unlink합니다. +- Windows recovery coverage `1a6ee097106cf2204cf9bdbcc0040999988f4e14`: Windows Drop이 남긴 unverified regular scratch가 다음 attempt의 shared lease 아래에서 stale child로 회수되고 새 `create_new` file로 교체됨을 고정합니다. +- Cross-platform test adaptation `7996cc2a1ae722093ab02c5a590ccc562a2b058b`, `2acb2f3708b6ef6fb664e7dcc2741fa219c199ed`, `4c94b4698c79fd32f659fb58106f2da0aa510d1a`, `2e4c355df5c45117c01ff1585e6478a8951ad168`, `6a85d358d46194d60a852f59463fa23252785992`: lifecycle/reader/transport fixtures를 Unix unlink와 Windows deferred-reclaim semantics, persistent lease sentinel teardown에 맞췄습니다. ## 실행 계약 @@ -29,12 +33,13 @@ Stale bytes를 resume하거나 신뢰하는 것도 허용하지 않습니다. - `.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부터 시작합니다. +- lease를 획득한 뒤에만 pre-existing regular artifact를 이전 crash 또는 안전하게 deferred된 unverified residue로 간주할 수 있습니다. 해당 bytes는 resume하지 않고 제거한 뒤 `create_new`로 byte zero부터 시작합니다. - `StagedArtifactFile`에서 `SealedArtifactFile`로 전환해도 lease를 유지합니다. Exact descriptor의 digest/signature 검증과 cleanup 사이에 다른 attempt가 pathname을 reclaim하지 못하게 하는 목적입니다. -- staged/sealed artifact cleanup을 마친 뒤 lease handle이 닫히며 다음 attempt가 lease를 얻을 수 있습니다. Process termination 시 OS가 file handle을 닫으면 lock도 함께 해제되므로 persistent sentinel 자체가 영구 blocker가 되지 않습니다. +- Drop은 descriptor ownership을 먼저 끝냅니다. Unix는 current direct regular pathname이 still-open descriptor와 같은 `(dev, ino)`일 때만 unlink합니다. Windows는 stable Rust에서 동일 identity를 증명할 수 없으므로 pathname deletion을 지연할 수 있습니다. 어느 경우든 lease handle은 descriptor cleanup 결정 뒤에 닫히며 다음 attempt가 lease를 얻을 수 있습니다. +- process termination 시 OS가 file handle을 닫으면 lock도 함께 해제되므로 persistent sentinel 자체가 영구 blocker가 되지 않습니다. - symlink, directory 또는 기타 non-regular artifact destination은 자동 삭제하지 않습니다. -- verified artifact를 이 scratch namespace에 장기 보존하는 API는 없습니다. -- 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를 통과한 뒤에만 시작할 수 있습니다. +- verified artifact를 이 scratch namespace에 장기 보존하는 API는 없습니다. Windows deferred regular scratch는 verified retention이 아니며 다음 leased attempt에서 재사용 없이 폐기됩니다. +- platform-specific lock/cleanup behavior를 Linux-only unit evidence로 일반화하지 않습니다. Distribution staging/lease integration suite는 Linux·Windows·macOS hosted runner에서 exact-head 실행되어야 하며 main `ci / build-and-test`는 그 matrix를 통과한 뒤에만 시작할 수 있습니다. - process-ownership claim은 별도 OS process가 lease를 보유하는 integration case를 포함해야 합니다. 같은 test process 안의 두 file handle만으로 cross-process exclusion을 증명했다고 보지 않습니다. ## 선택과 기각한 대안 @@ -47,15 +52,17 @@ Artifact file 자체만 advisory-lock하는 방식은 선택하지 않았습니 Lease sentinel을 정상 drop마다 삭제하는 방식도 사용하지 않습니다. Lock holder가 sentinel pathname을 unlink하면 다른 process가 새 sentinel inode를 만들 수 있고, 기존 inode를 열어 기다리던 process와 lock domain이 갈라질 수 있습니다. Sentinel은 남겨 두고 OS lock의 보유 여부만 active ownership으로 사용합니다. +Windows에서 descriptor를 닫은 뒤 remembered pathname을 무조건 삭제하는 방식도 기각합니다. 기본 file sharing 아래에서는 owned file이 열린 동안 pathname이 rename/replacement될 수 있고, close 이후 path delete는 현재 그 이름을 차지한 다른 object를 삭제할 수 있습니다. Stable Rust by-handle identity가 없는 현재 source boundary에서는 cleanup completeness보다 unrelated-file integrity를 우선합니다. + Linux CI 한 곳에서만 lock suite를 실행하고 Windows/macOS 동작을 문서상 동일하다고 간주하는 방식도 기각합니다. Rust 자체가 file lock 구현과 read/write 상호작용을 platform-specific이라고 명시하므로, 판매 대상 desktop OS family에서 실행 evidence를 직접 확보해야 합니다. Same-process handle contention만으로 process-level lease를 증명하는 방식도 기각합니다. OS lock의 handle/process semantics는 platform-specific할 수 있으므로 별도 process가 실제 lock owner일 때의 exclusion과 release를 각 판매 대상 OS runner에서 실행합니다. ## Claim boundary -이 수리는 **cooperating BandScope processes 사이에서 active staging attempt를 crash residue로 오인해 reclaim하는 source-level race**와 restart 뒤 stale regular file이 동일 update를 영구 차단하는 경로를 함께 닫습니다. `File::try_lock`은 플랫폼에 따라 advisory 또는 mandatory일 수 있으므로, 이 lease가 임의의 로컬 악성 프로세스가 직접 filesystem을 변조하는 것을 막는 mandatory sandbox라고 주장하지 않습니다. Staging root 자체의 ACL/ownership hardening과 pathname TOCTOU 방어도 별도 security boundary입니다. +이 수리는 **cooperating BandScope processes 사이에서 active staging attempt를 crash residue로 오인해 reclaim하는 source-level race**, restart 뒤 stale regular file이 동일 update를 영구 차단하는 경로, 그리고 Windows에서 remembered pathname을 ownership evidence 없이 삭제하던 경로를 함께 닫습니다. `File::try_lock`은 플랫폼에 따라 advisory 또는 mandatory일 수 있으므로, 이 lease가 임의의 로컬 악성 프로세스가 직접 filesystem을 변조하는 것을 막는 mandatory sandbox라고 주장하지 않습니다. Staging root 자체의 ACL/ownership hardening과 Unix metadata-check→unlink TOCTOU도 별도 security boundary입니다. -Cross-platform CI matrix와 real-process test는 Windows/macOS/Linux에서 현재 cooperating-process exclusion 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입니다. +Cross-platform CI matrix와 real-process test는 Windows/macOS/Linux에서 현재 cooperating-process exclusion 및 OS별 cleanup contract가 실행된다는 evidence gate입니다. Packaged application process kill, power loss, disk-full, antivirus/file-lock, filesystem crash가 모두 검증됐다는 뜻은 아닙니다. Production HTTP adapter, cryptographic verification, verified-artifact promotion과 last-known-good retention은 별도 release gate입니다. ## 근거 @@ -65,4 +72,4 @@ Rust 표준 라이브러리는 `File::try_lock`/`TryLockError`를 Rust 1.89.0부 ## 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 +Staging bytes는 canonical release namespace에서 왔더라도 verification 전까지 untrusted입니다. Restart recovery는 stale bytes를 살리는 기능이 아니라 active owner가 없음을 lease로 확인한 뒤 제거하고 새 admission을 시작하는 기능입니다. Lease sentinel과 artifact destination의 symlink/non-regular object는 자동 정리 대상이 아닙니다. Windows에서 safe Drop unlink를 증명할 수 없어 남은 regular scratch도 trust evidence가 아니며 다음 leased attempt가 resume 없이 폐기합니다. Verified artifact는 staging scratch 밖의 별도 owner로 승격되어야 하며 audio/project content는 이 updater staging 경계에 들어오지 않습니다. \ No newline at end of file From 106e03ab954c2bb21a0c127da187a07a17c2b505 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 08:35:05 +0900 Subject: [PATCH 279/308] test(distribution): reject unreviewed reqwest release lines --- ...ion_http_dependency_version_requirement.py | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/services/analysis-engine/tests/test_distribution_http_dependency_version_requirement.py b/services/analysis-engine/tests/test_distribution_http_dependency_version_requirement.py index cbde5a499..bb0b08969 100644 --- a/services/analysis-engine/tests/test_distribution_http_dependency_version_requirement.py +++ b/services/analysis-engine/tests/test_distribution_http_dependency_version_requirement.py @@ -14,7 +14,12 @@ ) -def _write_fixture(root: Path, version_requirement: str) -> None: +def _write_fixture( + root: Path, + version_requirement: str, + *, + locked_reqwest_version: str = "0.13.5", +) -> None: """Write a safe lock graph paired with one reqwest version requirement.""" crate = root / "apps/desktop/distribution-transport" crate.mkdir(parents=True) @@ -27,7 +32,7 @@ def _write_fixture(root: Path, version_requirement: str) -> None: ) (crate / "Cargo.lock").write_text( "version = 4\n\n" - '[[package]]\nname = "reqwest"\nversion = "0.13.5"\n' + f'[[package]]\nname = "reqwest"\nversion = "{locked_reqwest_version}"\n' 'source = "registry+https://github.com/rust-lang/crates.io-index"\n' 'checksum = "fixture"\n\n' '[[package]]\nname = "rustls"\nversion = "0.23.45"\n' @@ -55,3 +60,24 @@ def test_direct_reqwest_accepts_bounded_three_component_requirement(tmp_path: Pa _write_fixture(tmp_path, "0.13.5") assert POLICY.verify_distribution_http_dependency_admission(tmp_path) == [] + + +@pytest.mark.parametrize( + ("version_requirement", "locked_reqwest_version"), + [("0.13.4", "0.13.4"), ("0.14.0", "0.14.0")], +) +def test_direct_reqwest_rejects_unreviewed_release_lines( + tmp_path: Path, + version_requirement: str, + locked_reqwest_version: str, +) -> None: + """Require a new owner decision before downgrading or crossing reqwest's 0.13 line.""" + _write_fixture( + tmp_path, + version_requirement, + locked_reqwest_version=locked_reqwest_version, + ) + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any("reviewed reqwest range" in violation for violation in violations) From 13be2643220bf66ccff6482576ea9c5db01186b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 08:35:32 +0900 Subject: [PATCH 280/308] fix(distribution): bind reqwest admission to reviewed release line --- .../verify_distribution_http_dependencies.py | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/scripts/checks/verify_distribution_http_dependencies.py b/scripts/checks/verify_distribution_http_dependencies.py index 8dbdf8f2c..c32d1b13f 100755 --- a/scripts/checks/verify_distribution_http_dependencies.py +++ b/scripts/checks/verify_distribution_http_dependencies.py @@ -13,6 +13,8 @@ RUSTLS_ENCRYPTION_LEVEL_ADVISORY = "RUSTSEC-2026-0285" RUSTLS_AFFECTED_MIN = (0, 23, 13) RUSTLS_PATCHED_MIN = (0, 23, 45) +REQWEST_REVIEWED_MIN = (0, 13, 5) +REQWEST_REVIEWED_UPPER = (0, 14, 0) REQWEST_APPROVED_FEATURES = frozenset({"rustls"}) REQWEST_APPROVED_DECLARATION_KEYS = frozenset( {"version", "package", "default-features", "features"} @@ -21,7 +23,7 @@ def _version_triplet(raw: str) -> tuple[int, int, int] | None: - """Return the numeric core used by the advisory range.""" + """Return the numeric core used by owner version and advisory ranges.""" core = raw.split("+", 1)[0].split("-", 1)[0] parts = core.split(".") if len(parts) != 3 or any(not part.isdigit() for part in parts): @@ -39,6 +41,15 @@ def _is_bounded_three_component_requirement(raw: str) -> bool: return raw == canonical +def _is_reviewed_reqwest_version(raw: str) -> bool: + """Return whether reqwest stays inside the currently reviewed 0.13 release line.""" + version = _version_triplet(raw) + return ( + version is not None + and REQWEST_REVIEWED_MIN <= version < REQWEST_REVIEWED_UPPER + ) + + def _is_affected_rustls(raw: str) -> bool: """Return whether a rustls version is inside RUSTSEC-2026-0285's affected range.""" version = _version_triplet(raw) @@ -153,6 +164,12 @@ def _validate_reqwest_declaration(location: str, reqwest: Any) -> list[str]: f"{prefix}: reqwest version must use one bounded three-component Cargo " f"requirement such as 0.13.5; found {version!r}" ) + elif not _is_reviewed_reqwest_version(version): + violations.append( + f"{prefix}: reqwest {version} is outside the reviewed reqwest range " + ">=0.13.5,<0.14.0; a downgrade or SemVer-line change requires a new " + "Distribution owner decision and evidence" + ) if reqwest.get("default-features") is not False: violations.append( @@ -227,6 +244,17 @@ def verify_distribution_http_dependency_admission(repo_root: Path) -> list[str]: ) if source_violation: violations.append(source_violation) + version = str(package.get("version", "")) + if _version_triplet(version) is None: + violations.append( + f"{DISTRIBUTION_TRANSPORT_LOCK}: cannot parse reqwest version {version!r}" + ) + elif not _is_reviewed_reqwest_version(version): + violations.append( + f"{DISTRIBUTION_TRANSPORT_LOCK}: resolved reqwest {version} is outside " + "the reviewed reqwest range >=0.13.5,<0.14.0; refresh only within the " + "reviewed line or record a new Distribution owner decision" + ) rustls_packages = [ package for package in packages if package.get("name") == "rustls" From 76625565b0f2ee8f8fddffd0e1b2c39f5d2e32b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 08:36:51 +0900 Subject: [PATCH 281/308] docs(distribution): trace reviewed reqwest release line --- .../distribution-http-reqwest-version-line.md | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 docs/traceability/distribution-http-reqwest-version-line.md diff --git a/docs/traceability/distribution-http-reqwest-version-line.md b/docs/traceability/distribution-http-reqwest-version-line.md new file mode 100644 index 000000000..f7fe3ea92 --- /dev/null +++ b/docs/traceability/distribution-http-reqwest-version-line.md @@ -0,0 +1,55 @@ +# Distribution HTTP reqwest version-line admission traceability + +Status: source-level dependency admission repaired; production HTTP adapter and exact hosted evidence remain pending. + +## Problem + +BandScope's Distribution HTTP admission already required a bounded three-component reqwest requirement, the explicit `rustls` feature, canonical crates.io provenance, and a standalone lock without the `RUSTSEC-2026-0285` rustls affected interval. Fresh review found that the version rule still accepted any syntactically valid three-component reqwest release. `0.13.4` and a future SemVer-incompatible `0.14.0` therefore satisfied the checker even though the transport threat analysis, feature inventory, builder API assumptions, and dependency rationale were reviewed against reqwest 0.13.5. + +That is a policy gap rather than a Cargo parsing bug. A three-component requirement such as `0.14.0` is bounded, but it does not preserve the version line whose behavior BandScope actually reviewed. A downgrade to 0.13.4 likewise moves below the reviewed dependency baseline without a new owner decision. The committed lock is still the exact resolved graph, but the pre-compilation owner gate must reject a manifest or lock graph that silently crosses the reviewed boundary. + +The timing of `RUSTSEC-2026-0285` makes the distinction concrete. Reqwest 0.13.5 was published before rustls 0.23.45. The reqwest 0.13.5 docs.rs source snapshot contains rustls 0.23.44 in its own development lock, while RustSec marks `rustls >=0.23.13,<0.23.45` affected and 0.23.45 patched. That upstream lock is not BandScope's downstream resolution and is not vulnerability evidence for BandScope by itself; it shows why the BandScope-owned standalone lock, rather than an upstream release label, must carry the patched runtime graph. + +## Constraints + +- The currently reviewed reqwest line is `>=0.13.5,<0.14.0`. +- A direct runtime reqwest manifest requirement must remain a canonical three-component Cargo requirement and must fall inside that reviewed line. +- Every reqwest package resolved in `apps/desktop/distribution-transport/Cargo.lock` must also remain inside the reviewed line. A stale safe-looking 0.13 entry must not mask a second unreviewed reqwest release in the same standalone graph. +- A later 0.13.x patch may enter through the normal lock-refresh path because it remains in the reviewed SemVer line and still passes feature, provenance, rustls-advisory, dependency-review, OSV, SBOM and hosted platform gates. +- Any downgrade below 0.13.5 or move to 0.14+ requires a new Distribution owner decision, dependency admission evidence, API/feature review, threat analysis and exact-head tests before the allowed range is changed. +- This rule does not pin reqwest to exactly 0.13.5. The manifest requirement and committed lock retain their separate roles: the manifest constrains the reviewed compatible line; the lock records the exact build graph. +- The production client still must explicitly select `tls_backend_rustls()`, disable redirects, transparent decoding and proxy inheritance, and pass exact response evidence into `distribution-transport`. Version-line admission is not a substitute for runtime configuration. + +## RED -> causal fix + +- RED `106e03ab954c2bb21a0c127da187a07a17c2b505` adds regression cases for a downgrade to reqwest 0.13.4 and a SemVer-line move to 0.14.0. The predecessor checker accepted both because it validated only three-component syntax. +- Causal fix `13be2643220bf66ccff6482576ea9c5db01186b1` adds the reviewed range `>=0.13.5,<0.14.0` to both direct declaration admission and every resolved reqwest lock entry. The existing crates.io-source, exact `{rustls}` feature, workspace/alias/target-scope, and rustls advisory checks remain unchanged. +- A 0.13.6 fixture remains admissible under the helper semantics, so this repair does not convert the binary product lock into an exact patch pin. + +## Alternatives considered + +Exact-pinning reqwest to `=0.13.5` was rejected. BandScope already commits the standalone Cargo lock, and an exact manifest pin would duplicate that authority while making ordinary compatible security/bug-fix refreshes require a source-policy edit. + +Allowing any three-component reqwest version was rejected. The checker would then treat a SemVer-incompatible line as equivalent to the API and feature surface actually reviewed for the updater transport. + +Checking only the manifest and ignoring resolved reqwest versions was rejected. Cargo can carry multiple versions of the same package in one lock graph; the release-security owner must not let an admitted 0.13 entry conceal another reqwest line in the same standalone transport graph. + +Automatically widening the allowed range when crates.io publishes a new reqwest line was rejected. Dependency publication is external supply-chain input, not a BandScope architecture decision. + +## Claim boundary + +This repair prevents the current pre-compilation admission gate from silently accepting an unreviewed reqwest downgrade or SemVer-line change. It does not prove that reqwest 0.13.5 is vulnerability-free, that the future production HTTP adapter is correctly configured, or that the exact current branch has hosted GREEN evidence. The actual adapter must still arrive with a committed resolved lock that selects unaffected rustls, then pass dependency review, OSV/Trivy, SBOM, Windows/macOS/Linux Distribution tests and the subsequent real-network/fault acceptance path. + +## Follow-up + +Implement the production HTTP adapter only after generating a real standalone lock for the reviewed reqwest 0.13.x line with unaffected rustls. Do not copy reqwest's upstream development lock: BandScope must resolve and commit its own dependency graph. Then verify explicit rustls backend selection, no implicit redirect/decompression/proxy behavior, bounded chunk streaming, cancellation, DNS/TLS/network errors, disk-full behavior and captive-portal/non-release responses before moving to authenticated metadata and sealed-artifact cryptographic promotion. + +## References + +Reqwest project. (2026). *reqwest 0.13.5: Cargo.toml and feature configuration*. Docs.rs. https://docs.rs/crate/reqwest/0.13.5/source/Cargo.toml.orig + +Reqwest project. (2026). *reqwest 0.13.5: TLS configuration and types*. Docs.rs. https://docs.rs/reqwest/0.13.5/reqwest/tls/ + +Rust Project. (2026). *Dependency resolution: Version requirements*. The Cargo Book. https://doc.rust-lang.org/cargo/reference/resolver.html#version-numbers + +RustSec. (2026, September 14). *RUSTSEC-2026-0285: rustls: TLS 1.3 handshake messages incorrectly accepted across encryption level boundaries*. RustSec Advisory Database. https://rustsec.org/advisories/RUSTSEC-2026-0285.html From 85647419a5cfa4e7e55a6846f902ac063125e65d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 09:06:14 +0900 Subject: [PATCH 282/308] fix(distribution): expose in-flight staging path --- apps/desktop/distribution-transport/src/lib.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/desktop/distribution-transport/src/lib.rs b/apps/desktop/distribution-transport/src/lib.rs index 3b0d12866..6ae3e3b32 100644 --- a/apps/desktop/distribution-transport/src/lib.rs +++ b/apps/desktop/distribution-transport/src/lib.rs @@ -325,6 +325,17 @@ pub struct TransportDownload { } impl TransportDownload { + /// Return the direct child path reserved for this in-flight response. + /// + /// This borrows the path already owned by `StagedArtifactFile`; it does not + /// reopen the artifact or expose the underlying file descriptor. + pub fn path(&self) -> &Path { + self.staged + .as_ref() + .expect("transport staging file remains present before finish") + .path() + } + /// Admit one already-bounded network chunk into the staged artifact. pub fn admit_chunk(&mut self, chunk: &[u8]) -> Result<(), TransportDownloadError> { let admission = self From fd29aa1c238c4c0d0c7bec914f219b877b2e88b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 10:05:22 +0900 Subject: [PATCH 283/308] test(distribution): bind redirects to full provisional identity --- .../tests/redirect_policy_identity.rs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 apps/desktop/distribution-transport/tests/redirect_policy_identity.rs diff --git a/apps/desktop/distribution-transport/tests/redirect_policy_identity.rs b/apps/desktop/distribution-transport/tests/redirect_policy_identity.rs new file mode 100644 index 000000000..e4c14fc01 --- /dev/null +++ b/apps/desktop/distribution-transport/tests/redirect_policy_identity.rs @@ -0,0 +1,53 @@ +use bandscope_distribution_runtime::admit_untrusted_raw_json; +use bandscope_distribution_transport::{ + ReleaseTransportPolicy, ResponseDecision, TransportPolicyError, +}; + +const SOURCE_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; +const OTHER_SOURCE_COMMIT: &str = "1111111111111111111111111111111111111111"; +const DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const INITIAL_URL: &str = "https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-windows-x86_64.zip"; +const CDN_URL: &str = "https://release-assets.githubusercontent.com/github-production-release-asset/1178322014/update.zip?sp=r&sv=2021-08-06&sr=b"; + +fn policy(source_commit: &str, minimum_supported_version: &str) -> ReleaseTransportPolicy { + let document = format!( + r#"{{"version":"1.2.3","platforms":{{"windows-x86_64":{{"signature":"c2ln","url":"{INITIAL_URL}"}},"windows-aarch64":{{"signature":"c2ln","url":"https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-windows-aarch64.zip"}},"darwin-x86_64":{{"signature":"c2ln","url":"https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-darwin-x86_64.tar.gz"}},"darwin-aarch64":{{"signature":"c2ln","url":"https://github.com/ContextualWisdomLab/bandscope/releases/download/v1.2.3/BandScope-darwin-aarch64.tar.gz"}}}},"bandscope":{{"schemaVersion":1,"sourceCommit":"{source_commit}","minimumSupportedVersion":"{minimum_supported_version}","artifacts":{{"windows-x86_64":{{"sizeBytes":4,"sha256":"{DIGEST}"}},"windows-aarch64":{{"sizeBytes":5,"sha256":"{DIGEST}"}},"darwin-x86_64":{{"sizeBytes":6,"sha256":"{DIGEST}"}},"darwin-aarch64":{{"sizeBytes":7,"sha256":"{DIGEST}"}}}}}}}}"# + ); + let metadata = admit_untrusted_raw_json(document.as_bytes(), "windows-x86_64") + .expect("fixture must satisfy provisional metadata admission"); + ReleaseTransportPolicy::from_provisional(&metadata).expect("transport projection") +} + +fn redirect_from(policy: &ReleaseTransportPolicy) -> bandscope_distribution_transport::AdmittedRedirect { + match policy + .admit_initial_response(302, INITIAL_URL, Some(CDN_URL)) + .expect("originating policy admits one redirect") + { + ResponseDecision::FollowRedirect(redirect) => redirect, + ResponseDecision::Download(_) => panic!("302 must require a redirect follow-up"), + } +} + +#[test] +fn redirect_decision_rejects_different_source_commit_with_same_artifact_evidence() { + let originating_policy = policy(SOURCE_COMMIT, "0.1.3"); + let different_policy = policy(OTHER_SOURCE_COMMIT, "0.1.3"); + let redirect = redirect_from(&originating_policy); + + assert_eq!( + different_policy.admit_redirect_response(&redirect, 200, CDN_URL), + Err(TransportPolicyError::RedirectPolicyMismatch) + ); +} + +#[test] +fn redirect_decision_rejects_different_minimum_supported_version() { + let originating_policy = policy(SOURCE_COMMIT, "0.1.3"); + let different_policy = policy(SOURCE_COMMIT, "0.1.2"); + let redirect = redirect_from(&originating_policy); + + assert_eq!( + different_policy.admit_redirect_response(&redirect, 200, CDN_URL), + Err(TransportPolicyError::RedirectPolicyMismatch) + ); +} From 6b0fe81407c6c20492dc31164124309ebc83dafa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 10:06:08 +0900 Subject: [PATCH 284/308] fix(distribution): bind redirect token to full candidate identity --- .../desktop/distribution-transport/src/lib.rs | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/apps/desktop/distribution-transport/src/lib.rs b/apps/desktop/distribution-transport/src/lib.rs index 6ae3e3b32..9c287ec00 100644 --- a/apps/desktop/distribution-transport/src/lib.rs +++ b/apps/desktop/distribution-transport/src/lib.rs @@ -34,6 +34,14 @@ impl fmt::Debug for RedactedUrl<'_> { } } +#[derive(Clone, Eq, PartialEq)] +struct ProvisionalPolicyIdentity { + version_components: (u64, u64, u64), + source_commit: String, + target: String, + minimum_supported_version_components: (u64, u64, u64), +} + /// Fail-closed reasons for updater transport-policy admission. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum TransportPolicyError { @@ -71,6 +79,7 @@ pub enum TransportDownloadError { pub struct AdmittedRedirect { source_url: String, location: String, + policy_identity: ProvisionalPolicyIdentity, expected_size_bytes: u64, expected_artifact_sha256: String, artifact_signature: String, @@ -188,6 +197,7 @@ pub enum ResponseDecision { pub struct ReleaseTransportPolicy { initial_url: String, artifact_name: String, + policy_identity: ProvisionalPolicyIdentity, expected_size_bytes: u64, expected_artifact_sha256: String, artifact_signature: String, @@ -228,6 +238,13 @@ impl ReleaseTransportPolicy { Ok(Self { initial_url: initial_url.to_owned(), artifact_name: artifact_name.to_owned(), + policy_identity: ProvisionalPolicyIdentity { + version_components: metadata.version_components(), + source_commit: metadata.source_commit().to_owned(), + target: metadata.target().to_owned(), + minimum_supported_version_components: metadata + .minimum_supported_version_components(), + }, expected_size_bytes: metadata.artifact_size_bytes(), expected_artifact_sha256: metadata.expected_artifact_sha256().to_owned(), artifact_signature: metadata.artifact_signature().to_owned(), @@ -264,6 +281,7 @@ impl ReleaseTransportPolicy { Ok(ResponseDecision::FollowRedirect(AdmittedRedirect { source_url: self.initial_url.clone(), location: location.to_owned(), + policy_identity: self.policy_identity.clone(), expected_size_bytes: self.expected_size_bytes, expected_artifact_sha256: self.expected_artifact_sha256.clone(), artifact_signature: self.artifact_signature.clone(), @@ -275,12 +293,13 @@ impl ReleaseTransportPolicy { /// 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`. + /// The redirect token is bound to the full provisional release candidate + /// identity plus the same artifact URL, size, digest and updater signature + /// that admitted its first response. It cannot be replayed across another + /// metadata projection that changes source commit or minimum-supported + /// version while reusing the same release artifact evidence. A second + /// redirect is never followed. Only a final `200` at the exact admitted + /// Location can expose a body to `distribution-download`. pub fn admit_redirect_response( &self, redirect: &AdmittedRedirect, @@ -288,6 +307,7 @@ impl ReleaseTransportPolicy { effective_url: &str, ) -> Result { if redirect.source_url != self.initial_url + || redirect.policy_identity != self.policy_identity || redirect.expected_size_bytes != self.expected_size_bytes || redirect.expected_artifact_sha256 != self.expected_artifact_sha256 || redirect.artifact_signature != self.artifact_signature From b79a250b9257201bc9be9c6b1ccffe7744ab14f4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 10:08:01 +0900 Subject: [PATCH 285/308] docs(distribution): trace full redirect identity binding --- docs/traceability/updater-transport-policy.md | 81 +++++++++---------- 1 file changed, 37 insertions(+), 44 deletions(-) diff --git a/docs/traceability/updater-transport-policy.md b/docs/traceability/updater-transport-policy.md index 54ba2a8d1..2f3646b3e 100644 --- a/docs/traceability/updater-transport-policy.md +++ b/docs/traceability/updater-transport-policy.md @@ -1,80 +1,73 @@ # Updater transport admission traceability -Status: implemented policy boundary; production network adapter still pending. +Status: implemented deterministic policy boundary; production network adapter still pending. ## Problem -BandScope already has a strict provisional updater-metadata parser and a bounded streaming/staging primitive, 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. +BandScope already has a strict provisional updater-metadata parser and a bounded streaming/staging primitive. Distribution still needs an executable boundary between those owners so a future HTTP adapter cannot reparse `raw_json`, follow redirects implicitly, transform response bytes before staging, or lose the exact response origin that produced an updater artifact. -GitHub'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. +GitHub release-asset delivery may terminate directly with `200` or use a `302` hop. Rejecting every redirect would therefore break the supported release path, while delegating redirect decisions to an HTTP client would make the final network destination library-controlled rather than Distribution-controlled. -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 describe exact published artifact bytes. Content codings such as gzip or brotli can make a client expose decoded bytes while response framing still describes the encoded representation. Distribution therefore rejects transformed response bodies before filesystem mutation and requires the eventual network adapter to disable transparent decoding. -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. +A redirect decision is also attempt state, not just a destination URL. The first repair bound `AdmittedRedirect` to the initial URL plus provisional artifact size, digest and updater signature. Fresh review found that this was still narrower than the release candidate carried by `ProvisionalUpdateMetadata`: two metadata projections could reuse the same versioned artifact URL, size, digest and signature while changing `sourceCommit` or `minimumSupportedVersion`. The old redirect token would then be accepted by the second policy even though it originated from a different provisional release candidate. This does not by itself create cryptographic trust, but it breaks evidence continuity before metadata authentication and makes later sealed-descriptor promotion ambiguous. ## 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. +- URL, signature, expected size, SHA-256, version, source commit and minimum-supported version remain provisional until a later authenticated metadata binding succeeds. +- `distribution-runtime` owns the four-target updater document schema and requires every supported platform signature to use the canonical RFC 4648 standard-base64 outer envelope before it can construct `ProvisionalUpdateMetadata`. This is syntax admission, not minisign verification. +- `distribution-transport` must not duplicate the signature-envelope parser or invent a second metadata interpretation. +- Disable automatic redirect semantics in the eventual network adapter. Every followed location must originate from an explicit `ResponseDecision`. +- Admit a direct `200` only when the HTTP client's effective URL exactly equals the canonical BandScope release URL already admitted by `distribution-runtime`. +- Admit at most one `302` hop, currently to `https://release-assets.githubusercontent.com/`. A CDN-host change fails closed until this BandScope egress decision is reviewed. +- A redirect token is valid only for the same provisional release candidate and artifact evidence that created it: version components, source commit, target, minimum-supported-version components, initial URL, declared size, SHA-256 and updater signature must all remain identical. +- A redirected request must terminate in `200` at the exact admitted redirect URL. Redirect chaining is rejected. +- Reject every response `Content-Encoding` other than explicit `identity` before staging-file creation. Omitted `Content-Encoding` remains admissible. The production adapter must also disable transparent decompression so header evidence and delivered bytes cannot diverge. +- Response bodies reach disk only through `distribution-download`, preserving expected-size, optional `Content-Length`, per-chunk, cumulative-overrun, poison and cleanup contracts. - Content-encoding and content-length mismatch are evaluated before staging-file creation. -- A successfully staged artifact remains unverified and cleanup-on-drop. This layer performs no signature/digest trust promotion. +- A successfully staged artifact is still unverified scratch. This owner performs no updater-signature verification, digest trust promotion, installation or highest-seen mutation. ## Alternatives considered -Implicit HTTP-client redirects were rejected because they conceal 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. +Implicit HTTP-client redirects were rejected because they conceal origin changes. Rejecting all redirects was rejected because GitHub release-asset delivery may legitimately use `302`. Re-parsing updater JSON in the network adapter was rejected because it would create a second, potentially looser interpretation of untrusted metadata. Adding a random per-attempt nonce was also rejected: the complete candidate and artifact identity needed to prove deterministic policy continuity is already bounded by `distribution-runtime`, so a nonce would add nondeterminism without adding release semantics. -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. +Binding a redirect token only to source/destination URLs was rejected because metadata evidence can change while a canonical release URL remains stable. The earlier size/digest/signature binding closed one form of cross-policy replay but still omitted source commit and minimum-supported version. The selected design therefore carries the full bounded candidate identity needed by this owner, while keeping ordinary `Debug` output free of updater signatures and opaque CDN queries. -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. +Allowing HTTP content coding and trusting a client to reconstruct equivalent bytes was rejected because automatic decompression is library/configuration dependent. Reimplementing minisign verification here was also rejected: Tauri/updater verification remains the cryptographic owner, while BandScope's transport layer only admits bounded response semantics and exact bytes. ## Selected design -`apps/desktop/distribution-runtime` owns 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-runtime` owns strict updater-document parsing. It validates all four platform entries, including canonical standard-base64 signature envelopes, and returns one selected `ProvisionalUpdateMetadata`. The result remains unauthenticated remote metadata. + +`apps/desktop/distribution-transport` sits between that metadata owner and `distribution-download`. `ReleaseTransportPolicy::from_provisional` copies the canonical initial URL, safe artifact basename, declared byte size, SHA-256 and updater signature. It also builds a private `ProvisionalPolicyIdentity` from the already-bounded version components, source commit, target and minimum-supported-version components. No raw metadata is reparsed. + +`admit_initial_response` accepts exact-URL `200`, or returns an explicit one-hop `AdmittedRedirect` for an allowed GitHub release-asset CDN location. The redirect token privately carries both the full provisional candidate identity and the artifact evidence that created it. `admit_redirect_response` first requires that complete identity to equal the current policy, then requires the response to terminate in `200` at the exact admitted location. Only after those checks can `AdmittedDownloadHead::start_staging` create the bounded staging lifecycle. -`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. +`distribution-download` remains the filesystem/descriptor owner. `TransportDownload` only forwards bounded chunks and completion into its existing staging/seal contract. Cancellation retains the same owner cleanup behavior; the transport crate does not reopen an artifact or create a second descriptor authority. -`scripts/release/build_updater_manifest.py` 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. +`scripts/release/build_updater_manifest.py` remains the publication-side companion for the outer signature-envelope contract after exact `.sig` receipt binding. It does not claim to perform minisign verification. -The transport API intentionally contains no socket/client, 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. +The transport API intentionally contains no socket/client, installer, freshness repository, project persistence dependency, or verified-artifact type. The eventual HTTP adapter must be added separately with the repository's reviewed dependency admission and exact standalone lock graph. ## 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. +- `8f39dfc57026a25389f985e06dacee025818c5b2` introduced the original hostile/product transport cases for direct download, one-hop redirect, hostile origin, redirect chaining, framing and cancellation. +- `1b4f7a0a840d917f54fdb6b78ec861ba4b5ba0f7` placed the standalone transport crate in the native CI suite; `4964c3cd1472ed6ac9c7a9223d3da533e1af6096` connected one-hop `302` admission to bounded staging. +- `8313e9fb2fe66711e2c3e0432413a94355fdf6e7` / `6e5e42f2a20001009330c438178afa1ca811ab51` established the first canonical base64 envelope contract. `03c1314884a4044129ead75db59d341b80ed4499` / `2c7c772abcceff96dafceaaaa3b6a4e2af5f8cbc` added the matching publication-side envelope gate. +- `e37632589960cd3571c99eafafdcf205734bb21b` / `606095ec6f2ae9b5d22a777f70806dc79baa8f36` established fail-closed content-coding admission before staging. +- `8d56ab1015077e256e560deba9611979cb81ec5d` / `4e28d0cf5edfb399e3ced07b12daf5c4a7aace62` moved canonical signature-envelope admission to the metadata owner for every supported target. `9b691d7f1e67dff23b26b1427a8c7bf63b6fd025` and `5b85e03fde690240df62ac18c4e49b9052047f83` then removed the duplicate transport parser and aligned the test contract; `26ff403041958c433239a2359e6cfc32a2b633b9` repaired the remaining strict-admission fixture. +- `11a5a47784a405e5cad973d3c40aa8fe18b40940` proved that a redirect created with one updater signature could cross into another policy. `35b851e4724ca625351a14df80f78e121a4f3d6a` repaired that class by binding size/digest/signature to the redirect token. +- `fd29aa1c238c4c0d0c7bec914f219b877b2e88b3` adds the current source-level RED: the same artifact URL/size/digest/signature must not let a redirect token cross to metadata with a different source commit or minimum-supported version. This RED was introduced before the causal source change; it was not separately claimed as a hosted failing run. +- `6b0fe81407c6c20492dc31164124309ebc83dafa` is the causal repair. `ReleaseTransportPolicy` and `AdmittedRedirect` now carry a private candidate-identity projection consisting of version, source commit, target and minimum-supported-version components, and redirect admission requires exact equality before 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`. +Untrusted inputs are the four-target provisional metadata document, signature envelopes, HTTP status, client-reported effective URL, redirect `Location`, `Content-Encoding`, `Content-Length` and response chunks. The metadata owner applies structural admission before transport policy exists. Transport then uses exact URL equality, bounded HTTPS redirect admission, one-hop depth, full provisional candidate plus artifact-evidence binding, fail-closed content-coding admission and existing bounded chunk/file admission. -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. +The private redirect identity is intentionally not a trust promotion. A malicious or compromised metadata source can still provide a self-consistent false candidate until the later metadata-authentication owner verifies it. This boundary also does not cryptographically verify the updater signature, hash the sealed descriptor, disable decompression by itself, perform network I/O, or prove packaged Windows/macOS behavior. Those claims remain release gates. ## References From 9901b2d906f6f0d418bfd4860b9e404f9da27533 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 11:06:02 +0900 Subject: [PATCH 286/308] test(distribution): preserve candidate identity through sealing --- .../tests/transport_policy.rs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/apps/desktop/distribution-transport/tests/transport_policy.rs b/apps/desktop/distribution-transport/tests/transport_policy.rs index f7ff39377..cd69b7507 100644 --- a/apps/desktop/distribution-transport/tests/transport_policy.rs +++ b/apps/desktop/distribution-transport/tests/transport_policy.rs @@ -116,6 +116,41 @@ fn github_release_redirect_is_one_hop_and_streams_through_bounded_staging() { fs::remove_dir(directory).expect("remove staging directory"); } +#[test] +fn sealed_transport_artifact_keeps_candidate_identity_with_descriptor_evidence() { + let policy = policy(); + let head = match policy + .admit_initial_response(200, INITIAL_URL, None) + .expect("direct response") + { + ResponseDecision::Download(head) => head, + ResponseDecision::FollowRedirect(_) => panic!("200 must be final"), + }; + let directory = scratch_dir("sealed-identity"); + let mut download = head + .start_staging(&directory, Some(4), None) + .expect("start bounded staging"); + download.admit_chunk(b"data").expect("exact artifact chunk"); + let sealed = download.finish().expect("exact response seals"); + + assert_eq!(sealed.version_components(), (1, 2, 3)); + assert_eq!(sealed.source_commit(), SOURCE_COMMIT); + assert_eq!(sealed.target(), "windows-x86_64"); + assert_eq!(sealed.minimum_supported_version_components(), (0, 1, 3)); + assert_eq!(sealed.effective_url(), INITIAL_URL); + assert_eq!(sealed.artifact_name(), "BandScope-windows-x86_64.zip"); + assert_eq!(sealed.expected_size_bytes(), 4); + assert_eq!(sealed.expected_artifact_sha256(), DIGEST); + assert_eq!(sealed.artifact_signature(), "c2ln"); + assert_eq!(sealed.bytes_written(), 4); + + let path = sealed.path().to_path_buf(); + drop(sealed); + assert_platform_drop_cleanup(&path); + remove_staging_lease(&directory); + fs::remove_dir(directory).expect("remove staging directory"); +} + #[test] fn redirect_decision_cannot_cross_provisional_policy_identity() { let originating_policy = policy_with_signature("c2ln"); From e42f6e6d8b035e6a87ee4b5eb7845874f5d76465 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 11:06:53 +0900 Subject: [PATCH 287/308] fix(distribution): bind sealed artifacts to release identity --- .../desktop/distribution-transport/src/lib.rs | 134 ++++++++++++++++-- 1 file changed, 122 insertions(+), 12 deletions(-) diff --git a/apps/desktop/distribution-transport/src/lib.rs b/apps/desktop/distribution-transport/src/lib.rs index 9c287ec00..6c19cdf64 100644 --- a/apps/desktop/distribution-transport/src/lib.rs +++ b/apps/desktop/distribution-transport/src/lib.rs @@ -10,8 +10,8 @@ #![forbid(unsafe_code)] use bandscope_distribution_download::{ - ArtifactDownloadAdmission, DownloadAdmissionError, SealedArtifactFile, StagedArtifactFile, - StagingArtifactError, + ArtifactDownloadAdmission, DownloadAdmissionError, SealedArtifactFile, SealedArtifactReader, + StagedArtifactFile, StagingArtifactError, }; use bandscope_distribution_runtime::ProvisionalUpdateMetadata; use std::fmt; @@ -107,6 +107,7 @@ impl AdmittedRedirect { pub struct AdmittedDownloadHead { effective_url: String, artifact_name: String, + policy_identity: ProvisionalPolicyIdentity, expected_size_bytes: u64, expected_artifact_sha256: String, artifact_signature: String, @@ -153,13 +154,15 @@ impl AdmittedDownloadHead { /// 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. + /// The admitted response head is consumed so its candidate identity and + /// artifact evidence cannot be detached from the staging attempt. Content- + /// encoding and content-length admission run before filesystem mutation. + /// Updater signatures and digests are defined over exact release artifact + /// bytes, so any response content coding other than the explicit identity + /// coding is rejected rather than relying on HTTP-client decompression + /// behavior. `None` means the response omitted the header. pub fn start_staging( - &self, + self, staging_directory: &Path, response_content_length: Option, response_content_encoding: Option<&str>, @@ -177,6 +180,7 @@ impl AdmittedDownloadHead { let staged = StagedArtifactFile::create(staging_directory, &self.artifact_name) .map_err(TransportDownloadError::Staging)?; Ok(TransportDownload { + head: Some(self), admission: Some(admission), staged: Some(staged), }) @@ -330,6 +334,7 @@ impl ReleaseTransportPolicy { AdmittedDownloadHead { effective_url: effective_url.to_owned(), artifact_name: self.artifact_name.clone(), + policy_identity: self.policy_identity.clone(), expected_size_bytes: self.expected_size_bytes, expected_artifact_sha256: self.expected_artifact_sha256.clone(), artifact_signature: self.artifact_signature.clone(), @@ -337,9 +342,105 @@ impl ReleaseTransportPolicy { } } +/// Synchronized but still-unverified artifact bound to its transport evidence. +/// +/// This value owns the exact `SealedArtifactFile` descriptor together with the +/// provisional release-candidate identity, final effective URL, expected size, +/// digest, and updater signature that admitted its bytes. Keeping those values +/// in one move-only object prevents later verification code from accidentally +/// pairing a sealed descriptor with evidence copied from another candidate. +/// This is evidence continuity only, not metadata authentication or verified- +/// artifact promotion. +pub struct SealedTransportArtifact { + head: AdmittedDownloadHead, + sealed: SealedArtifactFile, +} + +impl fmt::Debug for SealedTransportArtifact { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SealedTransportArtifact") + .field("effective_url", &RedactedUrl(&self.head.effective_url)) + .field("artifact_name", &self.head.artifact_name) + .field("expected_size_bytes", &self.head.expected_size_bytes) + .field("bytes_written", &self.sealed.bytes_written()) + .field("artifact_signature", &REDACTED_SIGNATURE) + .finish() + } +} + +impl SealedTransportArtifact { + /// Return the provisional release version bound to this exact descriptor. + pub const fn version_components(&self) -> (u64, u64, u64) { + self.head.policy_identity.version_components + } + + /// Return the provisional source commit bound to this exact descriptor. + pub fn source_commit(&self) -> &str { + &self.head.policy_identity.source_commit + } + + /// Return the provisional target bound to this exact descriptor. + pub fn target(&self) -> &str { + &self.head.policy_identity.target + } + + /// Return the provisional minimum-supported version bound to this descriptor. + pub const fn minimum_supported_version_components(&self) -> (u64, u64, u64) { + self.head + .policy_identity + .minimum_supported_version_components + } + + /// Return the exact final response URL that produced this descriptor. + pub fn effective_url(&self) -> &str { + &self.head.effective_url + } + + /// Return the admitted app-owned artifact basename. + pub fn artifact_name(&self) -> &str { + &self.head.artifact_name + } + + /// Return the provisional expected byte length bound to this descriptor. + pub const fn expected_size_bytes(&self) -> u64 { + self.head.expected_size_bytes + } + + /// Return the provisional SHA-256 value bound to this descriptor. + pub fn expected_artifact_sha256(&self) -> &str { + &self.head.expected_artifact_sha256 + } + + /// Return the provisional updater signature bound to this descriptor. + pub fn artifact_signature(&self) -> &str { + &self.head.artifact_signature + } + + /// Return the direct child path held by the sealed descriptor owner. + pub fn path(&self) -> &Path { + self.sealed.path() + } + + /// Return the exact admitted byte count held by the sealed descriptor. + pub const fn bytes_written(&self) -> u64 { + self.sealed.bytes_written() + } + + /// Borrow a positional read-only stream over the exact sealed descriptor. + /// + /// This delegates to `distribution-download` and never reopens the staging + /// pathname, preserving the descriptor identity required by later digest + /// and signature verification. + pub fn reader(&self) -> SealedArtifactReader<'_> { + self.sealed.reader() + } +} + /// One response body being admitted into an exclusive staging artifact. #[derive(Debug)] pub struct TransportDownload { + head: Option, admission: Option, staged: Option, } @@ -371,11 +472,13 @@ impl TransportDownload { .map_err(TransportDownloadError::Download) } - /// Finish an exact response and return the still-unverified sealed descriptor. + /// Finish an exact response and keep its identity with the sealed descriptor. /// /// Failure leaves the staging value owned by this consumed object, so its - /// existing drop cleanup removes partial or unverified bytes. - pub fn finish(mut self) -> Result { + /// existing drop cleanup removes partial or unverified bytes. Success still + /// does not promote trust: the returned value remains cleanup-on-drop until + /// later metadata, digest, and updater-signature verification succeeds. + pub fn finish(mut self) -> Result { let admission = self .admission .take() @@ -385,7 +488,14 @@ impl TransportDownload { .staged .take() .expect("transport staging file remains present before finish"); - staged.seal(receipt).map_err(TransportDownloadError::Staging) + let sealed = staged + .seal(receipt) + .map_err(TransportDownloadError::Staging)?; + let head = self + .head + .take() + .expect("transport response identity remains present before finish"); + Ok(SealedTransportArtifact { head, sealed }) } } From 3ef753fb5c6b195c75c9b483d65506256f60765d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 11:07:42 +0900 Subject: [PATCH 288/308] test(distribution): read sealed artifact through owned descriptor --- .../distribution-transport/tests/transport_policy.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/desktop/distribution-transport/tests/transport_policy.rs b/apps/desktop/distribution-transport/tests/transport_policy.rs index cd69b7507..216ab0200 100644 --- a/apps/desktop/distribution-transport/tests/transport_policy.rs +++ b/apps/desktop/distribution-transport/tests/transport_policy.rs @@ -4,6 +4,7 @@ use bandscope_distribution_transport::{ ReleaseTransportPolicy, ResponseDecision, TransportDownloadError, TransportPolicyError, }; use std::fs; +use std::io::Read; use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; @@ -144,6 +145,13 @@ fn sealed_transport_artifact_keeps_candidate_identity_with_descriptor_evidence() assert_eq!(sealed.artifact_signature(), "c2ln"); assert_eq!(sealed.bytes_written(), 4); + let mut reader = sealed.reader(); + let mut exact_bytes = Vec::new(); + reader + .read_to_end(&mut exact_bytes) + .expect("read exact sealed descriptor bytes"); + assert_eq!(exact_bytes, b"data"); + let path = sealed.path().to_path_buf(); drop(sealed); assert_platform_drop_cleanup(&path); From 50f072e25e85e65fcfcce8d66c73eda847a73342 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 11:08:18 +0900 Subject: [PATCH 289/308] docs(distribution): trace sealed candidate identity binding --- docs/traceability/updater-transport-policy.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/traceability/updater-transport-policy.md b/docs/traceability/updater-transport-policy.md index 2f3646b3e..61a48e817 100644 --- a/docs/traceability/updater-transport-policy.md +++ b/docs/traceability/updater-transport-policy.md @@ -12,6 +12,8 @@ Updater signatures and SHA-256 evidence describe exact published artifact bytes. A redirect decision is also attempt state, not just a destination URL. The first repair bound `AdmittedRedirect` to the initial URL plus provisional artifact size, digest and updater signature. Fresh review found that this was still narrower than the release candidate carried by `ProvisionalUpdateMetadata`: two metadata projections could reuse the same versioned artifact URL, size, digest and signature while changing `sourceCommit` or `minimumSupportedVersion`. The old redirect token would then be accepted by the second policy even though it originated from a different provisional release candidate. This does not by itself create cryptographic trust, but it breaks evidence continuity before metadata authentication and makes later sealed-descriptor promotion ambiguous. +The same continuity problem existed one transition later. `AdmittedDownloadHead` exposed the artifact URL/size/digest/signature, but did not retain the full provisional candidate identity. `TransportDownload::finish` then returned a bare `SealedArtifactFile`. A later verifier could therefore hold the correct descriptor while accidentally pairing it with source-commit, target, version, minimum-supported-version or artifact evidence retained from a different provisional candidate. The bytes remained bounded and synchronized, but the type system no longer proved which admitted candidate produced them. + ## Constraints - Consume `ProvisionalUpdateMetadata` directly; do not introduce a second remote-JSON parser. @@ -26,6 +28,7 @@ A redirect decision is also attempt state, not just a destination URL. The first - Reject every response `Content-Encoding` other than explicit `identity` before staging-file creation. Omitted `Content-Encoding` remains admissible. The production adapter must also disable transparent decompression so header evidence and delivered bytes cannot diverge. - Response bodies reach disk only through `distribution-download`, preserving expected-size, optional `Content-Length`, per-chunk, cumulative-overrun, poison and cleanup contracts. - Content-encoding and content-length mismatch are evaluated before staging-file creation. +- Starting staging consumes the admitted response head. Candidate identity, effective URL, expected size, SHA-256 and updater signature remain attached to that one staging attempt and must survive sealing in the same move-only value as the exact descriptor. - A successfully staged artifact is still unverified scratch. This owner performs no updater-signature verification, digest trust promotion, installation or highest-seen mutation. ## Alternatives considered @@ -34,6 +37,8 @@ Implicit HTTP-client redirects were rejected because they conceal origin changes Binding a redirect token only to source/destination URLs was rejected because metadata evidence can change while a canonical release URL remains stable. The earlier size/digest/signature binding closed one form of cross-policy replay but still omitted source commit and minimum-supported version. The selected design therefore carries the full bounded candidate identity needed by this owner, while keeping ordinary `Debug` output free of updater signatures and opaque CDN queries. +Returning a bare `SealedArtifactFile` and asking future verification code to keep a separate metadata object synchronized was rejected for the same reason. Descriptor identity is only useful if the digest/signature/candidate evidence being checked belongs to that descriptor. The selected `SealedTransportArtifact` keeps the exact sealed descriptor and its provisional transport evidence together without reopening the path or promoting trust. + Allowing HTTP content coding and trusting a client to reconstruct equivalent bytes was rejected because automatic decompression is library/configuration dependent. Reimplementing minisign verification here was also rejected: Tauri/updater verification remains the cryptographic owner, while BandScope's transport layer only admits bounded response semantics and exact bytes. ## Selected design @@ -42,13 +47,15 @@ Allowing HTTP content coding and trusting a client to reconstruct equivalent byt `apps/desktop/distribution-transport` sits between that metadata owner and `distribution-download`. `ReleaseTransportPolicy::from_provisional` copies the canonical initial URL, safe artifact basename, declared byte size, SHA-256 and updater signature. It also builds a private `ProvisionalPolicyIdentity` from the already-bounded version components, source commit, target and minimum-supported-version components. No raw metadata is reparsed. -`admit_initial_response` accepts exact-URL `200`, or returns an explicit one-hop `AdmittedRedirect` for an allowed GitHub release-asset CDN location. The redirect token privately carries both the full provisional candidate identity and the artifact evidence that created it. `admit_redirect_response` first requires that complete identity to equal the current policy, then requires the response to terminate in `200` at the exact admitted location. Only after those checks can `AdmittedDownloadHead::start_staging` create the bounded staging lifecycle. +`admit_initial_response` accepts exact-URL `200`, or returns an explicit one-hop `AdmittedRedirect` for an allowed GitHub release-asset CDN location. The redirect token privately carries both the full provisional candidate identity and the artifact evidence that created it. `admit_redirect_response` first requires that complete identity to equal the current policy, then requires the response to terminate in `200` at the exact admitted location. Only after those checks can an `AdmittedDownloadHead` expose a body to bounded staging. + +`AdmittedDownloadHead` now carries the same private candidate identity. `start_staging` consumes that head and moves it into `TransportDownload`, so a response head cannot be detached from or reused independently of the staging attempt it admitted. `TransportDownload::finish` returns `SealedTransportArtifact`, which owns both the exact `SealedArtifactFile` and the head evidence. Read-only access to the bytes delegates to `SealedArtifactFile::reader`, so later digest/signature verification can consume the same descriptor without reopening the staging pathname. `distribution-download` remains the filesystem/descriptor owner. `TransportDownload` only forwards bounded chunks and completion into its existing staging/seal contract. Cancellation retains the same owner cleanup behavior; the transport crate does not reopen an artifact or create a second descriptor authority. `scripts/release/build_updater_manifest.py` remains the publication-side companion for the outer signature-envelope contract after exact `.sig` receipt binding. It does not claim to perform minisign verification. -The transport API intentionally contains no socket/client, installer, freshness repository, project persistence dependency, or verified-artifact type. The eventual HTTP adapter must be added separately with the repository's reviewed dependency admission and exact standalone lock graph. +The transport API intentionally contains no socket/client, installer, freshness repository, project persistence dependency, or verified-artifact type. `SealedTransportArtifact` is explicitly still unverified and cleanup-on-drop. The eventual HTTP adapter must be added separately with the repository's reviewed dependency admission and exact standalone lock graph; cryptographic verification and verified-artifact promotion remain later owners. ## RED → repair evidence @@ -58,8 +65,10 @@ The transport API intentionally contains no socket/client, installer, freshness - `e37632589960cd3571c99eafafdcf205734bb21b` / `606095ec6f2ae9b5d22a777f70806dc79baa8f36` established fail-closed content-coding admission before staging. - `8d56ab1015077e256e560deba9611979cb81ec5d` / `4e28d0cf5edfb399e3ced07b12daf5c4a7aace62` moved canonical signature-envelope admission to the metadata owner for every supported target. `9b691d7f1e67dff23b26b1427a8c7bf63b6fd025` and `5b85e03fde690240df62ac18c4e49b9052047f83` then removed the duplicate transport parser and aligned the test contract; `26ff403041958c433239a2359e6cfc32a2b633b9` repaired the remaining strict-admission fixture. - `11a5a47784a405e5cad973d3c40aa8fe18b40940` proved that a redirect created with one updater signature could cross into another policy. `35b851e4724ca625351a14df80f78e121a4f3d6a` repaired that class by binding size/digest/signature to the redirect token. -- `fd29aa1c238c4c0d0c7bec914f219b877b2e88b3` adds the current source-level RED: the same artifact URL/size/digest/signature must not let a redirect token cross to metadata with a different source commit or minimum-supported version. This RED was introduced before the causal source change; it was not separately claimed as a hosted failing run. -- `6b0fe81407c6c20492dc31164124309ebc83dafa` is the causal repair. `ReleaseTransportPolicy` and `AdmittedRedirect` now carry a private candidate-identity projection consisting of version, source commit, target and minimum-supported-version components, and redirect admission requires exact equality before URL/status admission. +- `fd29aa1c238c4c0d0c7bec914f219b877b2e88b3` proved at source level that the same artifact URL/size/digest/signature could still let a redirect token cross to metadata with a different source commit or minimum-supported version. `6b0fe81407c6c20492dc31164124309ebc83dafa` repaired that class by carrying the private full candidate identity through redirect admission. +- `9901b2d906f6f0d418bfd4860b9e404f9da27533` adds the current source-level RED: after sealing, the returned value must still expose the exact provisional candidate identity and artifact evidence that admitted the same descriptor. The RED was committed before the causal source change; it was not separately claimed as a hosted failing run. +- `e42f6e6d8b035e6a87ee4b5eb7845874f5d76465` is the causal repair. `AdmittedDownloadHead` now owns the private candidate identity, staging consumes the head, and `TransportDownload::finish` returns a move-only `SealedTransportArtifact` that owns both evidence and descriptor. +- `3ef753fb5c6b195c75c9b483d65506256f60765d` extends the regression through the descriptor-preserving read path and verifies the exact sealed bytes without reopening the pathname. Hosted exact-head checks remain authoritative for compilation and cross-platform evidence; predecessor results do not transfer after a head change. @@ -67,7 +76,7 @@ Hosted exact-head checks remain authoritative for compilation and cross-platform Untrusted inputs are the four-target provisional metadata document, signature envelopes, HTTP status, client-reported effective URL, redirect `Location`, `Content-Encoding`, `Content-Length` and response chunks. The metadata owner applies structural admission before transport policy exists. Transport then uses exact URL equality, bounded HTTPS redirect admission, one-hop depth, full provisional candidate plus artifact-evidence binding, fail-closed content-coding admission and existing bounded chunk/file admission. -The private redirect identity is intentionally not a trust promotion. A malicious or compromised metadata source can still provide a self-consistent false candidate until the later metadata-authentication owner verifies it. This boundary also does not cryptographically verify the updater signature, hash the sealed descriptor, disable decompression by itself, perform network I/O, or prove packaged Windows/macOS behavior. Those claims remain release gates. +The private candidate identity and `SealedTransportArtifact` are intentionally not trust promotion. A malicious or compromised metadata source can still provide a self-consistent false candidate until the later metadata-authentication owner verifies it. This boundary also does not cryptographically verify the updater signature, hash the sealed descriptor, disable decompression by itself, perform network I/O, or prove packaged Windows/macOS behavior. Those claims remain release gates. ## References From dea8f7bd68470871701b5dc01d44875bc33c0909 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 11:10:52 +0900 Subject: [PATCH 290/308] test(distribution): redact sealed transport diagnostics --- .../tests/transport_diagnostics.rs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/apps/desktop/distribution-transport/tests/transport_diagnostics.rs b/apps/desktop/distribution-transport/tests/transport_diagnostics.rs index 6d48a1b82..9a11f315a 100644 --- a/apps/desktop/distribution-transport/tests/transport_diagnostics.rs +++ b/apps/desktop/distribution-transport/tests/transport_diagnostics.rs @@ -1,5 +1,8 @@ use bandscope_distribution_runtime::admit_untrusted_raw_json; use bandscope_distribution_transport::{ReleaseTransportPolicy, ResponseDecision}; +use std::fs; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; const SOURCE_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; const DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; @@ -15,6 +18,29 @@ fn policy() -> ReleaseTransportPolicy { ReleaseTransportPolicy::from_provisional(&metadata).expect("transport projection") } +fn scratch_dir(label: &str) -> std::path::PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock after unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "bandscope-distribution-transport-diagnostics-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir(&path).expect("create isolated diagnostics staging directory"); + path +} + +fn remove_sealed_fixture(path: &Path) { + #[cfg(unix)] + assert!(!path.exists(), "Unix removes the descriptor-owned staging path"); + + #[cfg(not(unix))] + if path.is_file() { + fs::remove_file(path).expect("remove deferred sealed diagnostics fixture"); + } +} + #[test] fn redirect_query_and_signature_are_redacted_from_debug_surfaces() { let policy = policy(); @@ -47,3 +73,41 @@ fn redirect_query_and_signature_are_redacted_from_debug_surfaces() { assert_eq!(head.effective_url(), CDN_URL_WITH_QUERY); assert_eq!(head.artifact_signature(), "c2ln"); } + +#[test] +fn sealed_transport_debug_keeps_candidate_and_provider_evidence_out_of_logs() { + let policy = policy(); + let redirect = match policy + .admit_initial_response(302, INITIAL_URL, Some(CDN_URL_WITH_QUERY)) + .expect("admit one release-asset redirect") + { + ResponseDecision::FollowRedirect(redirect) => redirect, + ResponseDecision::Download(_) => panic!("302 must produce a redirect decision"), + }; + let head = policy + .admit_redirect_response(&redirect, 200, CDN_URL_WITH_QUERY) + .expect("redirect should terminate in an admitted body"); + let directory = scratch_dir("sealed-redaction"); + let mut download = head + .start_staging(&directory, Some(4), None) + .expect("start exact bounded staging"); + download.admit_chunk(b"data").expect("write exact fixture bytes"); + let sealed = download.finish().expect("seal exact fixture bytes"); + + let sealed_debug = format!("{sealed:?}"); + assert!(sealed_debug.contains("")); + assert!(sealed_debug.contains("")); + assert!(!sealed_debug.contains("provider-query-value")); + assert!(!sealed_debug.contains("c2ln")); + assert!(!sealed_debug.contains(SOURCE_COMMIT)); + assert!(!sealed_debug.contains(DIGEST)); + + let path = sealed.path().to_path_buf(); + drop(sealed); + remove_sealed_fixture(&path); + let lease = directory.join(".bandscope-staging.lock"); + if lease.is_file() { + fs::remove_file(lease).expect("remove diagnostics staging lease fixture"); + } + fs::remove_dir(directory).expect("remove diagnostics staging directory"); +} From 1136d34f01638b339e85c3294bb0e0956161bdee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 11:11:17 +0900 Subject: [PATCH 291/308] docs(distribution): trace sealed transport diagnostics --- .../updater-transport-diagnostics.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/traceability/updater-transport-diagnostics.md b/docs/traceability/updater-transport-diagnostics.md index e5c7ef22f..1f804f738 100644 --- a/docs/traceability/updater-transport-diagnostics.md +++ b/docs/traceability/updater-transport-diagnostics.md @@ -12,11 +12,14 @@ The same diagnostics surface also retained the provisional Tauri signature strin Fresh review found the same signature exposure one boundary earlier. `ProvisionalUpdateMetadata` still derived `Debug`, so formatting the strictly parsed but unauthenticated metadata copied the exact selected signature before `distribution-transport` had any opportunity to redact it. Fixing only transport types therefore left a direct 64 KiB remote-input log-amplification surface in the metadata-admission owner itself. +The later sealed-evidence repair introduced another diagnostic surface. `SealedTransportArtifact` deliberately owns the full provisional candidate identity and artifact evidence together with the exact sealed descriptor. A derived debug representation would therefore risk copying the source commit, digest, updater signature, or opaque CDN query into ordinary logs at the point where the artifact is most likely to be inspected during verification failures. The sealed wrapper requires the same bounded diagnostic contract as its predecessor types. + ## Constraints - Preserve the exact redirect URL internally and through `AdmittedRedirect::location()` because the production network adapter must request exactly the admitted value. -- Preserve exact `AdmittedDownloadHead::effective_url()` 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. +- Preserve exact `AdmittedDownloadHead::effective_url()` and `SealedTransportArtifact::effective_url()` for response/evidence binding. Redaction must affect diagnostics only, never transport equality or verification input. +- Preserve exact `artifact_signature()` in provisional metadata, admitted heads, and sealed transport evidence for the later Tauri verification boundary; diagnostic redaction must not mutate or replace verification input. +- Keep the full provisional source commit, target, versions and SHA-256 bound to `SealedTransportArtifact`, while omitting those high-cardinality/verification values from ordinary sealed debug output. - Do not guess the provider's query parameter names or attempt semantic parsing of opaque query data. - Do not add a URL or logging dependency for this narrow boundary. - Keep ordinary `Debug` usability for tests and diagnostics while preventing opaque query payloads or full provisional signatures from appearing in formatted values. @@ -30,20 +33,23 @@ Fresh review found the same signature exposure one boundary earlier. `Provisiona - `1a4e8f541e3017f0e666935c3e003027b871d131` replaces policy derived debug with bounded custom formatting and redacts the signature field in both transport policy and download-head diagnostics. Signature validation, storage, equality and exact accessor behavior are unchanged. - `027ba1474281b0ed968f039eb20073f1b7b9b2e9` adds a runtime-boundary regression requiring `ProvisionalUpdateMetadata` diagnostics to exclude the exact remote signature while its verification accessor remains byte-for-byte unchanged. The predecessor derived `Debug` violates this contract. - `90855ccdb8ecb1a1166a6c2e614b6851ae26f662` replaces the provisional metadata derived `Debug` with bounded custom formatting. Candidate identity, declared size and canonical release URL remain diagnosable; only the full signature field becomes the fixed `` marker. +- `dea8f7bd68470871701b5dc01d44875bc33c0909` extends the current diagnostics contract through `SealedTransportArtifact`: after a real bounded staging/seal transition, debug output must retain the redaction markers while excluding the provider query value, updater signature, source commit and SHA-256. Exact accessors and descriptor ownership remain unchanged. ## Selected design A private `RedactedUrl` formatter owns URL diagnostic rendering in `distribution-transport`. It does not allocate a second transport identity, modify stored state, normalize the URL, or feed back into policy decisions. URLs without a query render unchanged. URLs with a query retain the scheme/authority/path for operational diagnosis and render only a fixed redaction marker for the query component. -The query redaction is intentionally applied to 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. +The query redaction is intentionally applied to `AdmittedRedirect`, `AdmittedDownloadHead`, and `SealedTransportArtifact`: the first holds the URL before the follow-up request, the second retains the same effective URL after the admitted `200`, and the sealed wrapper carries that evidence beside the exact descriptor for later verification. Fixing only an earlier type would let the same opaque query reappear after the next state transition. + +Signature diagnostics use the same fixed `` marker in `ProvisionalUpdateMetadata`, `ReleaseTransportPolicy`, `AdmittedDownloadHead`, and `SealedTransportArtifact`. The actual signature remains private state exposed through the exact verification accessor. This bounds normal debug output independently of the remote signature-size allowance and closes the earlier metadata-owner leak rather than relying on every downstream caller to remember not to format the provisional aggregate. -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. +`SealedTransportArtifact` also omits source commit and SHA-256 from its custom debug representation. Both remain exact typed evidence through explicit accessors, but neither is necessary for a routine debug summary that already identifies the artifact name, redacted effective URL, expected size and actual sealed byte count. This keeps verification material available to the cryptographic owner without making it the default log payload. The canonical initial GitHub release URL remains visible in provisional/transport diagnostics because strict admission rejects query, fragment, whitespace, alternate authority and path-like asset syntax before the value exists in these types. That bounded URL is operationally useful for identifying the release target. The opaque CDN query remains redacted because its contents are not part of BandScope's release identity and need not be copied into diagnostic systems. ## Claim boundary and remaining work -This repair prevents automatic Rust `Debug` output for provisional updater metadata, Distribution transport policy, redirect decisions, 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. +This repair prevents automatic Rust `Debug` output for provisional updater metadata, Distribution transport policy, redirect decisions, final download heads, and sealed transport artifacts from exposing full provisional signature text; URL-bearing transport values also omit CDN redirect query contents, and sealed transport debug omits source commit and SHA-256. It does not prove that callers never log the explicit `artifact_signature()`, `source_commit()`, `expected_artifact_sha256()`, `location()`, or `effective_url()` accessors. Those exact accessors remain necessary for verification/network boundaries and must be handled as transport/security data. OWASP's Logging Cheat Sheet explicitly treats event data from other trust zones as untrusted and recommends excluding, masking, sanitizing, hashing, or encrypting data that should not be recorded directly. The fixed diagnostic markers implement that minimization at the type boundary rather than relying only on call-site discipline. @@ -53,6 +59,6 @@ Remote metadata authentication, sealed-descriptor digest/signature verification, ## 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 +Berners-Lee, T., Fielding, R., & Masinter, L. (2005). *Uniform Resource Identifier: Generic Syntax* (RFC 3986). RFC Editor. https://www.rfc-editor.org/rfc/rfc3986 OWASP Foundation. (2026). *Logging Cheat Sheet*. OWASP Cheat Sheet Series. https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html From dbe79884db3ba93db082688994beb140b8d58aad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 12:05:51 +0900 Subject: [PATCH 292/308] test(distribution): reject prerelease TLS boundary drift --- ...on_http_dependency_prerelease_admission.py | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 services/analysis-engine/tests/test_distribution_http_dependency_prerelease_admission.py diff --git a/services/analysis-engine/tests/test_distribution_http_dependency_prerelease_admission.py b/services/analysis-engine/tests/test_distribution_http_dependency_prerelease_admission.py new file mode 100644 index 000000000..a0ab0bf01 --- /dev/null +++ b/services/analysis-engine/tests/test_distribution_http_dependency_prerelease_admission.py @@ -0,0 +1,71 @@ +"""Pre-release boundary contracts for Distribution's security-owned HTTP graph.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from conftest import load_module + +POLICY = load_module( + "scripts/checks/verify_distribution_http_dependencies.py", + "verify_distribution_http_dependencies_prerelease_admission", +) + + +def _write_fixture( + root: Path, + *, + locked_reqwest_version: str = "0.13.5", + locked_rustls_version: str = "0.23.45", +) -> None: + """Write one crates.io-backed Distribution HTTP graph for version-bound tests.""" + crate = root / "apps/desktop/distribution-transport" + crate.mkdir(parents=True) + (crate / "Cargo.toml").write_text( + '[package]\nname = "fixture"\nversion = "0.0.0"\n\n' + "[dependencies]\n" + 'reqwest = { version = "0.13.5", default-features = false, ' + 'features = ["rustls"] }\n', + encoding="utf-8", + ) + (crate / "Cargo.lock").write_text( + "version = 4\n\n" + f'[[package]]\nname = "reqwest"\nversion = "{locked_reqwest_version}"\n' + 'source = "registry+https://github.com/rust-lang/crates.io-index"\n' + 'checksum = "fixture"\n\n' + f'[[package]]\nname = "rustls"\nversion = "{locked_rustls_version}"\n' + 'source = "registry+https://github.com/rust-lang/crates.io-index"\n' + 'checksum = "fixture"\n', + encoding="utf-8", + ) + + +def test_reqwest_prerelease_lock_is_not_treated_as_reviewed_stable_line(tmp_path: Path) -> None: + """A pre-release package must not inherit the owner review for stable 0.13.5+.""" + _write_fixture(tmp_path, locked_reqwest_version="0.13.5-alpha.1") + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any("reviewed reqwest range" in violation for violation in violations) + + +@pytest.mark.parametrize("rustls_version", ["0.23.45-alpha.1", "0.23.45-rc.1"]) +def test_rustls_prerelease_before_patched_stable_is_still_advisory_affected( + tmp_path: Path, + rustls_version: str, +) -> None: + """RustSec's >=0.23.45 patch boundary excludes 0.23.45 pre-releases.""" + _write_fixture(tmp_path, locked_rustls_version=rustls_version) + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any(POLICY.RUSTLS_ENCRYPTION_LEVEL_ADVISORY in violation for violation in violations) + + +def test_rustls_stable_patched_boundary_remains_admitted(tmp_path: Path) -> None: + """Keep the exact first stable patched release admitted.""" + _write_fixture(tmp_path, locked_rustls_version="0.23.45") + + assert POLICY.verify_distribution_http_dependency_admission(tmp_path) == [] From 98a53d6fed8fe89b28a9e22b9c9c0d7c417d8072 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 12:06:27 +0900 Subject: [PATCH 293/308] fix(distribution): compare TLS advisory prereleases correctly --- .../verify_distribution_http_dependencies.py | 47 +++++++++++++------ 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/scripts/checks/verify_distribution_http_dependencies.py b/scripts/checks/verify_distribution_http_dependencies.py index c32d1b13f..24bf9451c 100755 --- a/scripts/checks/verify_distribution_http_dependencies.py +++ b/scripts/checks/verify_distribution_http_dependencies.py @@ -22,14 +22,21 @@ CRATES_IO_LOCK_SOURCE = "registry+https://github.com/rust-lang/crates.io-index" -def _version_triplet(raw: str) -> tuple[int, int, int] | None: - """Return the numeric core used by owner version and advisory ranges.""" - core = raw.split("+", 1)[0].split("-", 1)[0] +def _parsed_version(raw: str) -> tuple[tuple[int, int, int], bool] | None: + """Return numeric SemVer core plus whether a pre-release identifier is present.""" + without_build = raw.split("+", 1)[0] + core, separator, _prerelease = without_build.partition("-") parts = core.split(".") if len(parts) != 3 or any(not part.isdigit() for part in parts): return None major, minor, patch = (int(part) for part in parts) - return major, minor, patch + return (major, minor, patch), bool(separator) + + +def _version_triplet(raw: str) -> tuple[int, int, int] | None: + """Return the numeric core used by owner version and advisory ranges.""" + parsed = _parsed_version(raw) + return None if parsed is None else parsed[0] def _is_bounded_three_component_requirement(raw: str) -> bool: @@ -42,18 +49,30 @@ def _is_bounded_three_component_requirement(raw: str) -> bool: def _is_reviewed_reqwest_version(raw: str) -> bool: - """Return whether reqwest stays inside the currently reviewed 0.13 release line.""" - version = _version_triplet(raw) + """Return whether reqwest stays inside the reviewed stable 0.13 release line.""" + parsed = _parsed_version(raw) + if parsed is None: + return False + version, has_prerelease = parsed return ( - version is not None + not has_prerelease and REQWEST_REVIEWED_MIN <= version < REQWEST_REVIEWED_UPPER ) def _is_affected_rustls(raw: str) -> bool: - """Return whether a rustls version is inside RUSTSEC-2026-0285's affected range.""" - version = _version_triplet(raw) - return version is not None and RUSTLS_AFFECTED_MIN <= version < RUSTLS_PATCHED_MIN + """Return whether a rustls SemVer is inside RUSTSEC-2026-0285's affected range.""" + parsed = _parsed_version(raw) + if parsed is None: + return False + version, has_prerelease = parsed + at_or_after_affected_min = version > RUSTLS_AFFECTED_MIN or ( + version == RUSTLS_AFFECTED_MIN and not has_prerelease + ) + before_patched_stable = version < RUSTLS_PATCHED_MIN or ( + version == RUSTLS_PATCHED_MIN and has_prerelease + ) + return at_or_after_affected_min and before_patched_stable def _dependency_package_name(dependency_name: str, declaration: Any) -> Any: @@ -252,8 +271,8 @@ def verify_distribution_http_dependency_admission(repo_root: Path) -> list[str]: elif not _is_reviewed_reqwest_version(version): violations.append( f"{DISTRIBUTION_TRANSPORT_LOCK}: resolved reqwest {version} is outside " - "the reviewed reqwest range >=0.13.5,<0.14.0; refresh only within the " - "reviewed line or record a new Distribution owner decision" + "the reviewed stable reqwest range >=0.13.5,<0.14.0; refresh only " + "within the reviewed stable line or record a new Distribution owner decision" ) rustls_packages = [ @@ -281,8 +300,8 @@ def verify_distribution_http_dependency_admission(repo_root: Path) -> list[str]: elif _is_affected_rustls(version): violations.append( f"{DISTRIBUTION_TRANSPORT_LOCK}: rustls {version} is affected by " - f"{RUSTLS_ENCRYPTION_LEVEL_ADVISORY}; use rustls >=0.23.45 or an " - "unaffected line" + f"{RUSTLS_ENCRYPTION_LEVEL_ADVISORY}; use the stable patched boundary " + "rustls >=0.23.45 or another unaffected line" ) return violations From a3737a3da9d31b3448684bd92d15856ae01c93b6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 12:06:53 +0900 Subject: [PATCH 294/308] docs(distribution): trace prerelease advisory boundary repair --- ...bution-http-semver-prerelease-admission.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 docs/traceability/distribution-http-semver-prerelease-admission.md diff --git a/docs/traceability/distribution-http-semver-prerelease-admission.md b/docs/traceability/distribution-http-semver-prerelease-admission.md new file mode 100644 index 000000000..90611d886 --- /dev/null +++ b/docs/traceability/distribution-http-semver-prerelease-admission.md @@ -0,0 +1,32 @@ +# Distribution HTTP SemVer pre-release admission traceability + +Status: implemented owner-policy repair; production HTTP adapter remains pending. + +## Problem + +`RUSTSEC-2026-0285` marks `rustls >=0.23.13,<0.23.45` as affected and stable `rustls >=0.23.45` as patched. The Distribution dependency gate previously reduced every lockfile version to its numeric `MAJOR.MINOR.PATCH` core before comparing advisory boundaries. That made `0.23.45-alpha.1` or `0.23.45-rc.1` compare as if it were the patched stable `0.23.45`, even though SemVer orders a pre-release below the corresponding normal release. + +The same numeric-core projection treated a hypothetical locked `reqwest 0.13.5-alpha.1` as if it belonged to the owner-reviewed stable `0.13.5+` line. The production manifest still rejects pre-release requirements, but the lockfile gate is security evidence in its own right and must not mislabel pre-release packages as reviewed stable releases. + +## Constraints and decision + +- Keep the manifest owner contract unchanged: direct reqwest must use the canonical three-component stable requirement form such as `0.13.5`. +- Preserve build metadata as precedence-neutral for lockfile range decisions, consistent with SemVer/Cargo behavior. +- Reject reqwest pre-releases from the reviewed stable 0.13 line. +- Compare the RustSec interval against stable boundaries: a pre-release at `0.23.13` is below the advisory's stable lower bound, while a pre-release at `0.23.45` is still below the first stable patched release and therefore remains inside the affected interval when it is otherwise at or above the affected floor. +- Do not broaden this small checker into a general package resolver. Cargo remains the dependency-resolution authority; this gate only models the owner ranges it explicitly claims. + +## RED -> repair evidence + +- `dbe79884db3ba93db082688994beb140b8d58aad` adds regression contracts requiring locked `reqwest 0.13.5-alpha.1` to fail the reviewed-stable-line check and `rustls 0.23.45-alpha.1` / `0.23.45-rc.1` to remain rejected under `RUSTSEC-2026-0285`. Stable `rustls 0.23.45` remains admitted. The RED was committed before the causal checker change; it is not claimed as a separately hosted failing run. +- `98a53d6fed8fe89b28a9e22b9c9c0d7c417d8072` preserves pre-release presence while parsing lock versions, rejects reqwest pre-releases from the reviewed stable line, and evaluates the RustSec stable lower/upper boundaries without promoting `0.23.45-*` to patched status. + +## Claim boundary + +These synthetic Cargo fixtures prove the repository policy function only. They do not prove that a production HTTP client exists, that a given rustls version is reachable in a built binary, or that real TLS/network behavior has passed acceptance. Production evidence still requires a BandScope-owned standalone lock, exact-head dependency review/SBOM/security checks, platform builds, and real network/fault tests once the adapter is implemented. + +## References + +Rust Secure Code Working Group. (2026, September 14). *RUSTSEC-2026-0285: rustls: TLS 1.3 handshake messages incorrectly accepted across encryption level boundaries*. RustSec Advisory Database. https://rustsec.org/advisories/RUSTSEC-2026-0285.html + +The Rust Project Developers. (2026). *The manifest format*. The Cargo Book. https://doc.rust-lang.org/cargo/reference/manifest.html From 269bd4a72b5bcbd4e76b9174b272fd5aa14772e9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 12:08:48 +0900 Subject: [PATCH 295/308] test(distribution): align prerelease rejection receipt --- .../test_distribution_http_dependency_prerelease_admission.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/analysis-engine/tests/test_distribution_http_dependency_prerelease_admission.py b/services/analysis-engine/tests/test_distribution_http_dependency_prerelease_admission.py index a0ab0bf01..0f9ede64f 100644 --- a/services/analysis-engine/tests/test_distribution_http_dependency_prerelease_admission.py +++ b/services/analysis-engine/tests/test_distribution_http_dependency_prerelease_admission.py @@ -48,7 +48,7 @@ def test_reqwest_prerelease_lock_is_not_treated_as_reviewed_stable_line(tmp_path violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) - assert any("reviewed reqwest range" in violation for violation in violations) + assert any("reviewed stable reqwest range" in violation for violation in violations) @pytest.mark.parametrize("rustls_version", ["0.23.45-alpha.1", "0.23.45-rc.1"]) From c85402f9062106ce82fec70b1ccb227783cd325b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 12:09:05 +0900 Subject: [PATCH 296/308] docs(distribution): record prerelease regression verification --- .../distribution-http-semver-prerelease-admission.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/traceability/distribution-http-semver-prerelease-admission.md b/docs/traceability/distribution-http-semver-prerelease-admission.md index 90611d886..af2f57099 100644 --- a/docs/traceability/distribution-http-semver-prerelease-admission.md +++ b/docs/traceability/distribution-http-semver-prerelease-admission.md @@ -20,6 +20,7 @@ The same numeric-core projection treated a hypothetical locked `reqwest 0.13.5-a - `dbe79884db3ba93db082688994beb140b8d58aad` adds regression contracts requiring locked `reqwest 0.13.5-alpha.1` to fail the reviewed-stable-line check and `rustls 0.23.45-alpha.1` / `0.23.45-rc.1` to remain rejected under `RUSTSEC-2026-0285`. Stable `rustls 0.23.45` remains admitted. The RED was committed before the causal checker change; it is not claimed as a separately hosted failing run. - `98a53d6fed8fe89b28a9e22b9c9c0d7c417d8072` preserves pre-release presence while parsing lock versions, rejects reqwest pre-releases from the reviewed stable line, and evaluates the RustSec stable lower/upper boundaries without promoting `0.23.45-*` to patched status. +- `269bd4a72b5bcbd4e76b9174b272fd5aa14772e9` aligns the reqwest pre-release regression with the checker's stable-line diagnostic after the causal fix, without changing the production policy. ## Claim boundary From 340e7eb795570e244c89890102c88aa043550456 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 13:06:30 +0900 Subject: [PATCH 297/308] test(distribution): reject transitive HTTP source substitution --- ...bution_http_dependency_source_admission.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/services/analysis-engine/tests/test_distribution_http_dependency_source_admission.py b/services/analysis-engine/tests/test_distribution_http_dependency_source_admission.py index c9f8cfa5c..3b01e62a1 100644 --- a/services/analysis-engine/tests/test_distribution_http_dependency_source_admission.py +++ b/services/analysis-engine/tests/test_distribution_http_dependency_source_admission.py @@ -88,3 +88,36 @@ def test_rustls_noncanonical_lock_source_is_rejected(tmp_path: Path) -> None: violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) assert any("rustls source" in violation for violation in violations) + + +def test_transitive_noncanonical_lock_sources_are_rejected(tmp_path: Path) -> None: + """Reject transitive git/path substitution behind canonical reqwest and rustls.""" + cases = ( + ( + "git-provider", + "aws-lc-rs", + 'source = "git+https://example.invalid/aws-lc-rs#deadbeef"\n', + ), + ("path-webpki", "rustls-webpki", ""), + ) + for fixture_name, package_name, source_line in cases: + fixture = tmp_path / fixture_name + _write_source_fixture( + fixture, + dependency_fields='version = "0.13.5"', + reqwest_source="registry+https://github.com/rust-lang/crates.io-index", + ) + lock = fixture / "apps/desktop/distribution-transport/Cargo.lock" + lock.write_text( + lock.read_text(encoding="utf-8") + + f'\n[[package]]\nname = "{package_name}"\nversion = "1.0.0"\n' + + source_line, + encoding="utf-8", + ) + + violations = POLICY.verify_distribution_http_dependency_admission(fixture) + + assert any( + package_name in violation and "source" in violation + for violation in violations + ), fixture_name From 92162dad8965095d7ca9852c37f7448184fad3ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 13:07:07 +0900 Subject: [PATCH 298/308] fix(distribution): bind full HTTP lock graph provenance --- .../verify_distribution_http_dependencies.py | 67 +++++++++++++++---- 1 file changed, 53 insertions(+), 14 deletions(-) diff --git a/scripts/checks/verify_distribution_http_dependencies.py b/scripts/checks/verify_distribution_http_dependencies.py index 24bf9451c..478e9a2dd 100755 --- a/scripts/checks/verify_distribution_http_dependencies.py +++ b/scripts/checks/verify_distribution_http_dependencies.py @@ -20,6 +20,14 @@ {"version", "package", "default-features", "features"} ) CRATES_IO_LOCK_SOURCE = "registry+https://github.com/rust-lang/crates.io-index" +LOCAL_DISTRIBUTION_LOCK_PACKAGES = frozenset( + { + "bandscope-distribution-core", + "bandscope-distribution-download", + "bandscope-distribution-runtime", + "bandscope-distribution-transport", + } +) def _parsed_version(raw: str) -> tuple[tuple[int, int, int], bool] | None: @@ -226,6 +234,41 @@ def _validate_crates_io_lock_source( ) +def _validate_lock_graph_sources(packages: Any) -> list[str]: + """Require every external package in the standalone HTTP graph to be crates.io-backed.""" + if not isinstance(packages, list): + return [f"{DISTRIBUTION_TRANSPORT_LOCK}: package graph must be a TOML array"] + + violations: list[str] = [] + for package in packages: + if not isinstance(package, dict): + violations.append( + f"{DISTRIBUTION_TRANSPORT_LOCK}: package graph contains a non-table entry" + ) + continue + package_name = package.get("name") + if not isinstance(package_name, str) or not package_name: + violations.append( + f"{DISTRIBUTION_TRANSPORT_LOCK}: package graph contains an invalid package name" + ) + continue + if package_name in LOCAL_DISTRIBUTION_LOCK_PACKAGES: + source = package.get("source") + if source is not None: + violations.append( + f"{DISTRIBUTION_TRANSPORT_LOCK}: local package {package_name} must remain " + f"path-owned without a registry/git source; found {source!r}" + ) + continue + source_violation = _validate_crates_io_lock_source( + package_name=package_name, + package=package, + ) + if source_violation: + violations.append(source_violation) + return violations + + def verify_distribution_http_dependency_admission(repo_root: Path) -> list[str]: """Verify direct reqwest admission before the production Distribution client compiles.""" manifest_path = repo_root / DISTRIBUTION_TRANSPORT_MANIFEST @@ -248,8 +291,14 @@ def verify_distribution_http_dependency_admission(repo_root: Path) -> list[str]: lock = tomllib.loads(lock_path.read_text(encoding="utf-8")) packages = lock.get("package", []) + violations.extend(_validate_lock_graph_sources(packages)) + if not isinstance(packages, list): + return violations + reqwest_packages = [ - package for package in packages if package.get("name") == "reqwest" + package + for package in packages + if isinstance(package, dict) and package.get("name") == "reqwest" ] if not reqwest_packages: violations.append( @@ -257,12 +306,6 @@ def verify_distribution_http_dependency_admission(repo_root: Path) -> list[str]: "committed lock graph" ) for package in reqwest_packages: - source_violation = _validate_crates_io_lock_source( - package_name="reqwest", - package=package, - ) - if source_violation: - violations.append(source_violation) version = str(package.get("version", "")) if _version_triplet(version) is None: violations.append( @@ -276,7 +319,9 @@ def verify_distribution_http_dependency_admission(repo_root: Path) -> list[str]: ) rustls_packages = [ - package for package in packages if package.get("name") == "rustls" + package + for package in packages + if isinstance(package, dict) and package.get("name") == "rustls" ] if not rustls_packages: violations.append( @@ -286,12 +331,6 @@ def verify_distribution_http_dependency_admission(repo_root: Path) -> list[str]: return violations for package in rustls_packages: - source_violation = _validate_crates_io_lock_source( - package_name="rustls", - package=package, - ) - if source_violation: - violations.append(source_violation) version = str(package.get("version", "")) if _version_triplet(version) is None: violations.append( From 6cded37c4637940cd390bd3e436f31a253f591f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 13:08:43 +0900 Subject: [PATCH 299/308] docs(distribution): trace full HTTP lock provenance --- .../distribution-http-lock-provenance.md | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/traceability/distribution-http-lock-provenance.md diff --git a/docs/traceability/distribution-http-lock-provenance.md b/docs/traceability/distribution-http-lock-provenance.md new file mode 100644 index 000000000..869a03227 --- /dev/null +++ b/docs/traceability/distribution-http-lock-provenance.md @@ -0,0 +1,50 @@ +# Distribution HTTP lock provenance traceability + +Status: implemented pre-compilation supply-chain admission; production HTTP adapter remains pending. + +## Problem + +The Distribution HTTP admission gate already required the direct `reqwest` declaration and the resolved `reqwest` and `rustls` lock entries to come from crates.io. That was not sufficient to bind the implementation selected by the standalone lock graph. Cargo can override an individual registry dependency through `[patch]` using a git or path source, and the patched package can be a transitive TLS, certificate-validation, crypto, DNS, or protocol dependency rather than `reqwest` or `rustls` themselves. A safe-looking top-level `reqwest`/`rustls` pair therefore did not prove that the rest of the HTTP/TLS implementation came from the reviewed registry source. + +For this Distribution owner, that is a pre-compilation admission gap. A future production adapter is security-sensitive and the standalone `apps/desktop/distribution-transport/Cargo.lock` is intended to be the exact resolved graph used by `--locked` builds. A transitive git/path/alternate-registry substitution must not enter that graph without an explicit owner decision merely because the direct packages still look canonical. + +Cargo's own documentation distinguishes crates.io, alternate registries, git repositories, and local paths as dependency sources. It also documents `[patch]` as a mechanism that can override dependencies and notes that patches can be supplied from Cargo configuration as well as `Cargo.toml`. Source replacement is a separate mechanism intended for equivalent mirrors or vendored copies. This gate does not attempt to reimplement Cargo resolution; it validates the provenance recorded in the committed standalone lock after resolution. + +## Constraints + +- This check activates only after `apps/desktop/distribution-transport/Cargo.toml` declares a direct runtime `reqwest` package. Existing unrelated repository lock graphs remain outside this Distribution owner gate. +- The four BandScope Distribution crates in the standalone workspace are local path-owned packages and therefore intentionally have no lock `source`: `bandscope-distribution-core`, `bandscope-distribution-download`, `bandscope-distribution-runtime`, and `bandscope-distribution-transport`. +- Every other package in the active standalone lock graph must record Cargo's canonical crates.io registry source, `registry+https://github.com/rust-lang/crates.io-index`. +- A git source, alternate registry, or source-less external package fails admission even when its name and version match an expected transitive package. +- Adding another local BandScope path package to this standalone graph requires an explicit update to the local-package allow-list; the gate must not infer that an arbitrary source-less package is trusted. +- This policy checks lock provenance, not package integrity by itself. Cargo checksum verification, dependency review, OSV/audit controls, SBOM/provenance generation, platform builds, and release evidence remain separate gates. +- Synthetic lock fixtures are policy-unit evidence only. They do not prove that a production reqwest/rustls graph has been resolved, that a particular transitive package is reachable in a shipped binary, or that real TLS/network behavior is correct. + +## Alternatives considered + +Enumerating only known TLS transitive package names such as `rustls-webpki`, `ring`, or `aws-lc-rs` was rejected because the graph can change across compatible dependency updates; a package-name deny-list or allow-list would fail open when the TLS stack changes. The source invariant is simpler: once the direct production client is admitted, all external packages in this small standalone owner graph must come from the same reviewed registry source. + +Scanning every `Cargo.lock` in BandScope was rejected because it would conflate unrelated bounded contexts with the Distribution HTTP client. Repository-wide supply-chain tooling remains responsible for those graphs; this gate is intentionally attached to the standalone Distribution transport owner. + +Relying only on GitHub dependency review after accepting arbitrary git/path substitutions was rejected because the repository already has a pre-compilation owner gate and the production updater path should fail closed before platform builds. A source exception can still be made later, but it must be an explicit reviewed policy change with corresponding threat analysis and evidence rather than an implicit lock substitution. + +## Selected design + +`scripts/checks/verify_distribution_http_dependencies.py` now validates the provenance of every package entry in the standalone Distribution transport lock once a direct runtime reqwest dependency exists. The four known local Distribution crates must remain source-less path packages. Every other package must record the canonical crates.io registry source. Existing reqwest stable-line and rustls advisory checks then run over that already-admitted graph. + +This makes the committed lock graph the executable provenance boundary rather than treating only `reqwest` and `rustls` as security-owned packages. It deliberately does not parse `[patch]`, Cargo configuration, feature unification, or dependency edges itself; Cargo resolves those inputs and the gate judges the resulting committed lock. + +## RED -> repair evidence + +- `340e7eb795570e244c89890102c88aa043550456` adds policy regressions for two substitutions hidden behind otherwise canonical reqwest/rustls entries: a git-sourced `aws-lc-rs` package and a source-less `rustls-webpki` package. The RED was committed before the checker change; it is not claimed as a separately hosted failing run. +- `92162dad8965095d7ca9852c37f7448184fad3ca` adds whole-lock external-source admission and an explicit local Distribution package allow-list. It also keeps reqwest/rustls version/advisory checks scoped to table-shaped lock entries after graph provenance is validated. + +## Claim boundary and next action + +This closes one supply-chain admission path only. BandScope still has no production HTTP client in `distribution-transport`, and the current standalone lock contains only the local Distribution crates. The next implementation step remains to add the production reqwest adapter together with a genuinely Cargo-resolved BandScope-owned lock graph, then require exact-head hosted dependency review, SBOM/security gates, platform compilation, and real network/fault acceptance. No TLS safety or release-readiness claim should be inferred from the synthetic provenance fixtures alone. + +## Primary references + +- The Cargo Book, *Specifying Dependencies*: Cargo dependencies can originate from crates.io/registries, git repositories, or local paths, and local git/path locations can override registry resolution for development. +- The Cargo Book, *Configuration* (`[patch]`): dependency patches can be supplied from Cargo configuration as well as `Cargo.toml`, with configuration patches taking precedence where applicable. +- The Cargo Book, *Source Replacement*: source replacement is intended for equivalent mirrors/vendor sources and is distinct from patching a dependency. From 532411874679cd8a64bcb92e8dd7f325b28bff37 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 13:10:39 +0900 Subject: [PATCH 300/308] test(distribution): cover lock provenance admission edges --- ...bution_http_dependency_source_admission.py | 51 +++++++++++++++++-- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/services/analysis-engine/tests/test_distribution_http_dependency_source_admission.py b/services/analysis-engine/tests/test_distribution_http_dependency_source_admission.py index 3b01e62a1..fb57c6291 100644 --- a/services/analysis-engine/tests/test_distribution_http_dependency_source_admission.py +++ b/services/analysis-engine/tests/test_distribution_http_dependency_source_admission.py @@ -11,6 +11,8 @@ "verify_distribution_http_dependencies_source", ) +CRATES_IO_SOURCE = "registry+https://github.com/rust-lang/crates.io-index" + def _write_source_fixture( root: Path, @@ -34,7 +36,7 @@ def _write_source_fixture( '[[package]]\nname = "reqwest"\nversion = "0.13.5"\n' f"{source_line}" '\n[[package]]\nname = "rustls"\nversion = "0.23.45"\n' - 'source = "registry+https://github.com/rust-lang/crates.io-index"\n', + f'source = "{CRATES_IO_SOURCE}"\n', encoding="utf-8", ) @@ -72,13 +74,13 @@ def test_rustls_noncanonical_lock_source_is_rejected(tmp_path: Path) -> None: _write_source_fixture( tmp_path, dependency_fields='version = "0.13.5"', - reqwest_source="registry+https://github.com/rust-lang/crates.io-index", + reqwest_source=CRATES_IO_SOURCE, ) lock = tmp_path / "apps/desktop/distribution-transport/Cargo.lock" lock.write_text( lock.read_text(encoding="utf-8").replace( 'name = "rustls"\nversion = "0.23.45"\n' - 'source = "registry+https://github.com/rust-lang/crates.io-index"', + f'source = "{CRATES_IO_SOURCE}"', 'name = "rustls"\nversion = "0.23.45"\n' 'source = "git+https://example.invalid/rustls#deadbeef"', ), @@ -105,7 +107,7 @@ def test_transitive_noncanonical_lock_sources_are_rejected(tmp_path: Path) -> No _write_source_fixture( fixture, dependency_fields='version = "0.13.5"', - reqwest_source="registry+https://github.com/rust-lang/crates.io-index", + reqwest_source=CRATES_IO_SOURCE, ) lock = fixture / "apps/desktop/distribution-transport/Cargo.lock" lock.write_text( @@ -121,3 +123,44 @@ def test_transitive_noncanonical_lock_sources_are_rejected(tmp_path: Path) -> No package_name in violation and "source" in violation for violation in violations ), fixture_name + + +def test_canonical_crates_io_transitive_package_is_admitted(tmp_path: Path) -> None: + """Keep ordinary crates.io transitive packages admissible under full-graph checking.""" + _write_source_fixture( + tmp_path, + dependency_fields='version = "0.13.5"', + reqwest_source=CRATES_IO_SOURCE, + ) + lock = tmp_path / "apps/desktop/distribution-transport/Cargo.lock" + lock.write_text( + lock.read_text(encoding="utf-8") + + '\n[[package]]\nname = "rustls-webpki"\nversion = "0.103.8"\n' + + f'source = "{CRATES_IO_SOURCE}"\n', + encoding="utf-8", + ) + + assert POLICY.verify_distribution_http_dependency_admission(tmp_path) == [] + + +def test_local_distribution_package_cannot_gain_remote_source(tmp_path: Path) -> None: + """Keep the explicit local-package allow-list path-owned rather than name-trusted.""" + _write_source_fixture( + tmp_path, + dependency_fields='version = "0.13.5"', + reqwest_source=CRATES_IO_SOURCE, + ) + lock = tmp_path / "apps/desktop/distribution-transport/Cargo.lock" + lock.write_text( + lock.read_text(encoding="utf-8") + + '\n[[package]]\nname = "bandscope-distribution-core"\nversion = "0.1.0"\n' + + f'source = "{CRATES_IO_SOURCE}"\n', + encoding="utf-8", + ) + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any( + "bandscope-distribution-core" in violation and "path-owned" in violation + for violation in violations + ) From 4920cf2e2908f742d4a4617d2dd14775aafd473a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 13:11:10 +0900 Subject: [PATCH 301/308] docs(distribution): record lock provenance edge evidence --- docs/traceability/distribution-http-lock-provenance.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/traceability/distribution-http-lock-provenance.md b/docs/traceability/distribution-http-lock-provenance.md index 869a03227..ef2fa8955 100644 --- a/docs/traceability/distribution-http-lock-provenance.md +++ b/docs/traceability/distribution-http-lock-provenance.md @@ -38,6 +38,7 @@ This makes the committed lock graph the executable provenance boundary rather th - `340e7eb795570e244c89890102c88aa043550456` adds policy regressions for two substitutions hidden behind otherwise canonical reqwest/rustls entries: a git-sourced `aws-lc-rs` package and a source-less `rustls-webpki` package. The RED was committed before the checker change; it is not claimed as a separately hosted failing run. - `92162dad8965095d7ca9852c37f7448184fad3ca` adds whole-lock external-source admission and an explicit local Distribution package allow-list. It also keeps reqwest/rustls version/advisory checks scoped to table-shaped lock entries after graph provenance is validated. +- `532411874679cd8a64bcb92e8dd7f325b28bff37` adds edge evidence that an ordinary crates.io transitive package remains admitted and that a package using a local BandScope Distribution name cannot gain a remote registry source merely through the name allow-list. ## Claim boundary and next action From ed5be008b379f5f07c4325801f1bbba9676fcad1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 14:05:32 +0900 Subject: [PATCH 302/308] test(distribution): reject reqwest feature forwarding --- ...tion_http_dependency_feature_forwarding.py | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 services/analysis-engine/tests/test_distribution_http_dependency_feature_forwarding.py diff --git a/services/analysis-engine/tests/test_distribution_http_dependency_feature_forwarding.py b/services/analysis-engine/tests/test_distribution_http_dependency_feature_forwarding.py new file mode 100644 index 000000000..81260c97b --- /dev/null +++ b/services/analysis-engine/tests/test_distribution_http_dependency_feature_forwarding.py @@ -0,0 +1,71 @@ +"""Regression tests for Distribution reqwest feature-forwarding admission.""" + +from __future__ import annotations + +from pathlib import Path + +from conftest import load_module + +POLICY = load_module( + "scripts/checks/verify_distribution_http_dependencies.py", + "verify_distribution_http_dependencies_feature_forwarding", +) + +CRATES_IO_SOURCE = "registry+https://github.com/rust-lang/crates.io-index" + + +def _write_fixture(root: Path, *, dependency_name: str, package_field: str, feature: str) -> None: + """Write an otherwise-admitted reqwest graph with one forwarded dependency feature.""" + crate = root / "apps/desktop/distribution-transport" + crate.mkdir(parents=True) + (crate / "Cargo.toml").write_text( + '[package]\nname = "fixture"\nversion = "0.0.0"\n\n' + '[dependencies]\n' + f'{dependency_name} = {{ {package_field}version = "0.13.5", ' + 'default-features = false, features = ["rustls"] }}\n\n' + '[features]\n' + f'default = ["{dependency_name}/{feature}"]\n', + encoding="utf-8", + ) + (crate / "Cargo.lock").write_text( + "version = 4\n\n" + '[[package]]\nname = "reqwest"\nversion = "0.13.5"\n' + f'source = "{CRATES_IO_SOURCE}"\n\n' + '[[package]]\nname = "rustls"\nversion = "0.23.45"\n' + f'source = "{CRATES_IO_SOURCE}"\n', + encoding="utf-8", + ) + + +def test_root_feature_cannot_add_unapproved_reqwest_feature(tmp_path: Path) -> None: + """Reject Cargo feature forwarding that re-enables gzip outside the dependency table.""" + _write_fixture( + tmp_path, + dependency_name="reqwest", + package_field="", + feature="gzip", + ) + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any( + "reqwest/gzip" in violation and "feature" in violation + for violation in violations + ) + + +def test_renamed_reqwest_feature_forwarding_is_also_rejected(tmp_path: Path) -> None: + """Apply the same invariant when Cargo renames the reqwest dependency key.""" + _write_fixture( + tmp_path, + dependency_name="distribution_http", + package_field='package = "reqwest", ', + feature="brotli", + ) + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any( + "distribution_http/brotli" in violation and "feature" in violation + for violation in violations + ) From bc56faad9bd2582cd6bae4f0b4df43dafc64d9a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 14:06:19 +0900 Subject: [PATCH 303/308] fix(distribution): block reqwest feature forwarding --- .../verify_distribution_http_dependencies.py | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/scripts/checks/verify_distribution_http_dependencies.py b/scripts/checks/verify_distribution_http_dependencies.py index 478e9a2dd..77d1ed019 100755 --- a/scripts/checks/verify_distribution_http_dependencies.py +++ b/scripts/checks/verify_distribution_http_dependencies.py @@ -156,6 +156,63 @@ def _direct_reqwest_declarations(manifest: dict[str, Any]) -> list[tuple[str, An return declarations +def _direct_reqwest_dependency_names(manifest: dict[str, Any]) -> frozenset[str]: + """Return Cargo dependency keys that resolve to the reqwest package.""" + workspace_dependencies = _workspace_dependencies(manifest) + names: set[str] = set() + + def collect(dependencies: Any) -> None: + if not isinstance(dependencies, dict): + return + for dependency_name, declaration in dependencies.items(): + if ( + _resolved_package_name( + dependency_name, + declaration, + workspace_dependencies, + ) + == "reqwest" + ): + names.add(dependency_name) + + collect(manifest.get("dependencies", {})) + targets = manifest.get("target", {}) + if isinstance(targets, dict): + for target_table in targets.values(): + if isinstance(target_table, dict): + collect(target_table.get("dependencies", {})) + return frozenset(names) + + +def _validate_reqwest_feature_forwarding(manifest: dict[str, Any]) -> list[str]: + """Reject root features that can add reqwest features outside its declaration.""" + dependency_names = _direct_reqwest_dependency_names(manifest) + if not dependency_names: + return [] + features = manifest.get("features", {}) + if not isinstance(features, dict): + return [] + + violations: list[str] = [] + for feature_name, members in features.items(): + if not isinstance(members, list): + continue + for member in members: + if not isinstance(member, str): + continue + for dependency_name in dependency_names: + direct_prefix = f"{dependency_name}/" + weak_prefix = f"{dependency_name}?/" + if member.startswith(direct_prefix) or member.startswith(weak_prefix): + violations.append( + f"{DISTRIBUTION_TRANSPORT_MANIFEST} [features.{feature_name}]: " + f"reqwest feature forwarding {member!r} is not admitted; select " + "the complete approved reqwest feature set only in the direct " + "runtime dependency declaration" + ) + return violations + + def _validate_reqwest_declaration(location: str, reqwest: Any) -> list[str]: """Validate one direct runtime reqwest declaration against owner policy.""" prefix = f"{DISTRIBUTION_TRANSPORT_MANIFEST} [{location}]" @@ -281,6 +338,7 @@ def verify_distribution_http_dependency_admission(repo_root: Path) -> list[str]: violations: list[str] = [] for location, reqwest in declarations: violations.extend(_validate_reqwest_declaration(location, reqwest)) + violations.extend(_validate_reqwest_feature_forwarding(manifest)) if not lock_path.exists(): violations.append( From 0f74972089c4240dbcb7e47d65a8e116bbf099cf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 14:06:40 +0900 Subject: [PATCH 304/308] test(distribution): cover weak reqwest feature forwarding --- ...tion_http_dependency_feature_forwarding.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/services/analysis-engine/tests/test_distribution_http_dependency_feature_forwarding.py b/services/analysis-engine/tests/test_distribution_http_dependency_feature_forwarding.py index 81260c97b..2851e7816 100644 --- a/services/analysis-engine/tests/test_distribution_http_dependency_feature_forwarding.py +++ b/services/analysis-engine/tests/test_distribution_http_dependency_feature_forwarding.py @@ -69,3 +69,28 @@ def test_renamed_reqwest_feature_forwarding_is_also_rejected(tmp_path: Path) -> "distribution_http/brotli" in violation and "feature" in violation for violation in violations ) + + +def test_weak_dependency_feature_forwarding_is_rejected(tmp_path: Path) -> None: + """Reject Cargo's dependency?/feature form as another feature-activation path.""" + _write_fixture( + tmp_path, + dependency_name="distribution_http", + package_field='package = "reqwest", ', + feature="zstd", + ) + manifest = tmp_path / "apps/desktop/distribution-transport/Cargo.toml" + manifest.write_text( + manifest.read_text(encoding="utf-8").replace( + "distribution_http/zstd", + "distribution_http?/zstd", + ), + encoding="utf-8", + ) + + violations = POLICY.verify_distribution_http_dependency_admission(tmp_path) + + assert any( + "distribution_http?/zstd" in violation and "feature" in violation + for violation in violations + ) From ae98559c83939b75ae02cadcbd05894883cc0a37 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 14:07:06 +0900 Subject: [PATCH 305/308] docs(distribution): trace reqwest feature forwarding gate --- .../distribution-http-feature-forwarding.md | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 docs/traceability/distribution-http-feature-forwarding.md diff --git a/docs/traceability/distribution-http-feature-forwarding.md b/docs/traceability/distribution-http-feature-forwarding.md new file mode 100644 index 000000000..8ea5700a4 --- /dev/null +++ b/docs/traceability/distribution-http-feature-forwarding.md @@ -0,0 +1,42 @@ +# Distribution HTTP reqwest feature-forwarding admission + +Status: Draft implementation evidence on PR #1126. This note records a source-level dependency-admission invariant; it is not proof of a production HTTP client, resolved TLS graph, or real-network acceptance. + +## Problem + +The Distribution HTTP gate already required a direct `reqwest` declaration with `default-features = false` and direct features exactly `{rustls}`. Cargo, however, permits dependency features to be activated again from the root package's `[features]` table with `dependency/feature` and `dependency?/feature` entries. Cargo also unifies enabled features for a dependency. A manifest could therefore keep the reviewed dependency declaration unchanged while adding, for example, `default = ["reqwest/gzip"]`, causing an ordinary build to compile reqwest with an unreviewed transport feature. + +For BandScope this matters because the future updater adapter must preserve exact artifact bytes and make TLS/backend, redirect, decoding, proxy, and DNS behavior explicit. A policy that inspects only `dependencies.reqwest.features` does not establish that invariant if the same manifest can activate additional reqwest features elsewhere. + +## Constraints + +- Keep the reviewed direct reqwest feature set owned by the runtime dependency declaration rather than reproducing Cargo's complete feature resolver in Python. +- Preserve dependency renaming: `distribution_http = { package = "reqwest", ... }` must be treated exactly like a dependency named `reqwest`. +- Cover Cargo's direct `dependency/feature` and weak `dependency?/feature` forwarding syntax. +- Do not claim that a static manifest check proves the final resolved feature graph. Cargo feature unification can also be influenced by another package that depends on the same reqwest package. Exact production acceptance still requires a genuinely Cargo-resolved BandScope lock and resolved-feature evidence (`cargo tree -e features -i reqwest` or an equivalent Cargo-owned projection) once the production client exists. + +## Decision + +When a normal unconditional or target-scoped dependency resolves to the `reqwest` package, `scripts/checks/verify_distribution_http_dependencies.py` now scans the root `[features]` table and rejects any member beginning with that dependency key followed by `/` or `?/`. This includes renamed dependency keys. The complete approved reqwest feature set must therefore remain in the direct dependency declaration inspected by the existing gate. + +We deliberately do not attempt to evaluate whether a forwarding feature is currently reachable from `default` or a particular CI command. The security invariant is narrower and easier to audit: the Distribution transport manifest must not contain a second source of reqwest feature activation at all. + +## RED → repair evidence + +- `ed5be008b379f5f07c4325801f1bbba9676fcad1` adds RED regressions for `reqwest/gzip` and a renamed `distribution_http/brotli` forwarding path. These fixtures are policy-unit evidence only; they are not claimed as a separately hosted failing run. +- `bc56faad9bd2582cd6bae4f0b4df43dafc64d9a8` adds dependency-key discovery across unconditional and target-scoped runtime dependencies and rejects root feature forwarding for every reqwest key. +- `0f74972089c4240dbcb7e47d65a8e116bbf099cf` adds the weak `distribution_http?/zstd` edge case so both Cargo forwarding syntaxes are executable contracts. + +## Rejected alternatives + +Allowing approved forwarding such as `reqwest/rustls` was rejected because it creates a second feature authority without buyer value. Following only `default` feature reachability was rejected because non-default features can be enabled by build commands and would leave the manifest with a latent unreviewed transport configuration. Reimplementing Cargo's transitive feature resolver in this Python gate was also rejected; the production client must instead add Cargo-owned resolved-feature evidence when its real graph exists. + +## External basis + +Cargo Reference, *Features* (Rust Project, current documentation accessed 2026-09-16): dependency features may be activated in `[features]` with `package-name/feature-name` or `package-name?/feature-name`, and enabled features for the same dependency are unified. https://doc.rust-lang.org/cargo/reference/features.html + +Cargo Reference, *Dependency Resolution* (Rust Project, current documentation accessed 2026-09-16): lockfile resolution considers package features and compilation performs a second feature-selection pass; dependencies are built with the union of features enabled on them. https://doc.rust-lang.org/cargo/reference/resolver.html + +## Claim boundary and next evidence + +This repair closes root-manifest feature forwarding as an admission bypass. It does not prove that no future transitive package activates additional reqwest features. Before the production adapter can be called release-ready, the genuinely resolved standalone graph must demonstrate the actual reqwest feature set with Cargo-owned feature-graph evidence, alongside the existing lock provenance, rustls advisory, SBOM, dependency-review, platform CI, and real-network tests. From da5c690c70d2ba3ebde625a0e6d4eed43cb80998 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 04:02:36 +0900 Subject: [PATCH 306/308] test(distribution): document integration test crates --- .../distribution-download/tests/path_replacement_cleanup.rs | 2 ++ apps/desktop/distribution-download/tests/sealed_reader.rs | 2 ++ apps/desktop/distribution-download/tests/staged_artifact.rs | 2 ++ 3 files changed, 6 insertions(+) diff --git a/apps/desktop/distribution-download/tests/path_replacement_cleanup.rs b/apps/desktop/distribution-download/tests/path_replacement_cleanup.rs index 9f4fba81a..c01b0be2f 100644 --- a/apps/desktop/distribution-download/tests/path_replacement_cleanup.rs +++ b/apps/desktop/distribution-download/tests/path_replacement_cleanup.rs @@ -1,3 +1,5 @@ +//! Integration tests for staging-path replacement and deferred cleanup safety. + #![cfg(any(unix, windows))] use bandscope_distribution_download::{ArtifactDownloadAdmission, StagedArtifactFile}; diff --git a/apps/desktop/distribution-download/tests/sealed_reader.rs b/apps/desktop/distribution-download/tests/sealed_reader.rs index ef293cbcf..88d815436 100644 --- a/apps/desktop/distribution-download/tests/sealed_reader.rs +++ b/apps/desktop/distribution-download/tests/sealed_reader.rs @@ -1,3 +1,5 @@ +//! Integration tests for descriptor-bound reads from sealed staging artifacts. + use bandscope_distribution_download::{ArtifactDownloadAdmission, StagedArtifactFile}; use std::fs::{self, OpenOptions}; use std::io::{ErrorKind, Read, Write}; diff --git a/apps/desktop/distribution-download/tests/staged_artifact.rs b/apps/desktop/distribution-download/tests/staged_artifact.rs index 986fd41df..8d22cc5e2 100644 --- a/apps/desktop/distribution-download/tests/staged_artifact.rs +++ b/apps/desktop/distribution-download/tests/staged_artifact.rs @@ -1,3 +1,5 @@ +//! Integration tests for staging admission, cleanup, and cross-process lease safety. + use bandscope_distribution_download::{ ArtifactDownloadAdmission, StagedArtifactFile, StagingArtifactError, }; From 2cd906c8b1a868bb52d41744a5a92946163e87d7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 04:05:49 +0900 Subject: [PATCH 307/308] fix(release): revalidate publication asset list --- .github/workflows/build-baseline.yml | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-baseline.yml b/.github/workflows/build-baseline.yml index 1458381d8..3ba71dba2 100644 --- a/.github/workflows/build-baseline.yml +++ b/.github/workflows/build-baseline.yml @@ -410,7 +410,7 @@ 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-artifacts.txt + run: python3 scripts/release/select_release_assets.py --output release-assets.txt - name: Build receipt-bound updater manifest run: | python3 scripts/release/build_updater_manifest.py \ @@ -418,8 +418,6 @@ jobs: --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 }} @@ -434,16 +432,14 @@ 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-artifacts.txt + python3 scripts/release/select_release_assets.py --input release-assets.txt python3 scripts/release/build_updater_manifest.py \ --git-sha "${{ github.sha }}" \ --repository "${{ github.repository }}" \ --server-url "${{ github.server_url }}" \ --output latest.json \ --check - 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 + printf '%s\n' latest.json >> release-assets.txt mapfile -t release_assets < release-assets.txt (( ${#release_assets[@]} > 0 )) gh release create "$RELEASE_TAG" \ From 528ba04cbaf2228bd819e3313f06f849ab027fac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 04:08:29 +0900 Subject: [PATCH 308/308] docs(distribution): explain consumed staging cleanup --- scripts/release/build_updater_manifest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release/build_updater_manifest.py b/scripts/release/build_updater_manifest.py index 67af54316..860cf74c3 100644 --- a/scripts/release/build_updater_manifest.py +++ b/scripts/release/build_updater_manifest.py @@ -370,6 +370,7 @@ def _write_atomically(path: Path, payload: bytes) -> None: try: stage.unlink() except FileNotFoundError: + # A successful os.replace consumed the staged pathname. pass