diff --git a/.forgejo/workflows/nigig-site.yml b/.forgejo/workflows/nigig-site.yml index c7d14bb..3b02428 100644 --- a/.forgejo/workflows/nigig-site.yml +++ b/.forgejo/workflows/nigig-site.yml @@ -38,7 +38,7 @@ on: workflow_dispatch: inputs: enforce_release_gates: - description: 'Fail unless runtime, migration, media, and real-server gates are enabled' + description: 'Enforce exact SITE-02 approval plus all later release capabilities' required: true type: boolean default: false @@ -98,9 +98,15 @@ jobs: fi printf 'matched %d Site-owned source/manifest/tool files\n' "${#files[@]}" test -f crates/apps/nigig-site/src/store.rs + test -f crates/apps/nigig-site/src/repository.rs + test -f crates/apps/nigig-site/SITE_02_SECURITY_REVIEW.md + test -f crates/apps/nigig-site/SITE_02_KEY_LIFECYCLE_DESIGN.md test -f crates/apps/nigig-site/tests/sync_e2e.rs test -f crates/apps/nigig-site/tools/ci-cargo.sh test -x crates/apps/nigig-site/tools/check-production-deps.sh + test -x crates/apps/nigig-site/tools/runtime-smoke.sh + test -x crates/apps/nigig-site/tools/native-keyring-smoke.sh + test -x crates/apps/nigig-site/tools/audit-production-deps.py test -f .forgejo/workflows/nigig-site.yml - name: SITE-01 production containment is structural and fail-closed @@ -113,8 +119,13 @@ jobs: root = Path('crates/apps/nigig-site') lib = (root / 'src/lib.rs').read_text() for module in ('doc_export', 'gif', 'ocr', 'report_pdf', 'video'): - pattern = rf'#\[cfg\(test\)\]\s+pub mod {module};' - assert re.search(pattern, lib), f'{module} must remain test-only' + pattern = ( + rf'#\[cfg\(all\(test, target_os = "linux"\)\)\]' + rf'\s+pub mod {module};' + ) + assert re.search(pattern, lib), ( + f'{module} must remain Linux-CI-only test code' + ) manifest = tomllib.loads((root / 'Cargo.toml').read_text()) features = set(manifest.get('features', {})) @@ -122,6 +133,20 @@ jobs: 'feature configuration could bypass SITE-01 containment', features ) production_deps = manifest.get('dependencies', {}) + assert not manifest.get('dev-dependencies'), ( + 'broad fixture dependencies must not enter every native test target' + ) + linux_dev = manifest.get('target', {}).get( + 'cfg(target_os = "linux")', {} + ).get('dev-dependencies', {}) + for dependency in ( + 'makepad-test', 'nigig-core', 'nigig-pdf-cos', + 'nigig-pdf-document', 'nigig-pdf-graphics', + 'nigig_doc_scanner', 'image', 'weezl', 'zip', 'reqwest', + ): + assert dependency in linux_dev, ( + f'Linux-owned fixture dependency missing: {dependency}' + ) for dependency in ( 'nigig-core', 'nigig-uikit', 'doc-ui', 'reqwest', 'makepad-ai-hub', 'makepad-system-speech', @@ -168,6 +193,7 @@ jobs: 'CameraWidget', 'get_latest_location', 'makepad_system_speech', 'robius_notification::', 'photos_to_gif_file', 'photos_to_clip_file', 'request_send_text', 'demo-site', + 'Muthaiga Villas', ): assert token not in active, f'reachable contained capability found: {token}' @@ -199,10 +225,89 @@ jobs: store = (root / 'src/store.rs').read_text() assert 'pub fn save(' not in store and 'pub fn save_async(' not in store assert 'pub fn load()' not in store - assert 'flush_writer_queue' not in store crypto = (root / 'src/crypto.rs').read_text() assert 'set_password' not in crypto and 'load_or_create' not in crypto - print('SITE-01 containment contract passed') + + # SITE-02 candidate: strict envelope, exact native-key lookup, + # bounded/coalesced persistence, explicit health, and hard locks. + repository = (root / 'src/repository.rs').read_text() + manifest_targets = manifest.get('target', {}) + assert 'keyring' not in production_deps, 'all-in-one keyring facade restored' + assert 'keyring-core' in production_deps and 'zeroize' in production_deps + aes_gcm = production_deps.get('aes-gcm', {}) + assert aes_gcm.get('default-features') is False + assert {'aes', 'alloc', 'zeroize'} <= set(aes_gcm.get('features', [])) + for dependency in ('aes', 'ghash', 'polyval'): + configured = production_deps.get(dependency, {}) + assert 'zeroize' in configured.get('features', []), ( + f'crypto backend zeroization feature missing: {dependency}' + ) + target_text = (root / 'Cargo.toml').read_text() + for provider in ( + 'zbus-secret-service-keyring-store', + 'apple-native-keyring-store', + 'windows-native-keyring-store', + ): + assert provider in target_text, f'explicit native provider missing: {provider}' + assert 'windows-sys' in target_text and 'Win32_Storage_FileSystem' in target_text + for token in ( + 'NIGIG2', 'Payload {', 'aad:', 'MAX_PLAINTEXT_BYTES', + 'NonceInvocationLimit', 'AuthenticationFailed', 'cipher_for', + 'envelope_matches_independent_aes_gcm_known_answer', + ): + assert token in crypto, f'SITE-02 crypto contract missing: {token}' + for token in ( + 'CredentialPersistence::UntilDelete', 'create_new(true)', + 'O_NOFOLLOW', 'try_lock()', 'read_expected_current', + 'flush()', 'FlushFailed', 'sync_all()', 'std::fs::rename', + 'CanonicalReadbackFailed', + 'rollback_publication', 'pending: Option>', + 'pending_depth', 'flush_and_shutdown', + 'impl Drop for RepositoryWriter', + 'abrupt_process_termination_is_fail_closed_at_every_commit_stage', + 'concurrent_writers_serialize_and_exactly_one_stale_commit_fails', + 'SchemaVersionProbe', 'open_versioned_json', + 'linux_native_provider_real_vault_lifecycle', + 'apple_windows_native_provider_real_vault_lifecycle', + 'attributes.get("persistence")', 'value == "Local"', + 'FILE_ATTRIBUTE_REPARSE_POINT', 'GetFileInformationByHandle', + 'nNumberOfLinks', 'windows-reparse-real', + 'validate_canonical_permissions', 'reject_symlink_chain', + ): + assert token in repository, f'SITE-02 repository contract missing: {token}' + for token in ( + 'mutate_scoped', 'profile_mutation_fence', 'site_mutation_fence', + 'PersistenceHealth', 'accepted_revision', 'durable_revision', + 'deny_unknown_fields', 'open_versioned_json::(STORE_VERSION)', + 'OlderVersionMigrationRequired', 'SecurityReviewRequired', + 'setup_review_locked', 'migration_review_locked', + ): + assert token in store, f'SITE-02 runtime contract missing: {token}' + assert '#[cfg(test)]\n fn migrate_legacy_json' in repository, ( + 'migration execution harness must remain test-only while review is pending' + ) + assert 'pub(crate) mod repository;' in lib + assert 'pub mod repository;' not in lib + assert 'impl Drop for SiteStandaloneApp' in main + assert 'persistence_health' in main and 'flush_and_shutdown(5_000)' in main + assert 'SITE-02-SECURITY-REVIEW-REQUIRED' in store + workflow = Path('.forgejo/workflows/nigig-site.yml').read_text() + assert 'cargo metadata --locked --format-version 1 --all-features' in workflow, ( + 'RustSec production-graph audit must retain conservative feature resolution' + ) + + # Every confidential screen mutation is explicitly scoped. The only + # broad mutations left are profile-level site create/select actions. + for screen in ('approvals.rs', 'meetings.rs', 'procurement.rs', 'report_editor.rs'): + text = (root / 'src/site_frame/screens' / screen).read_text() + assert 'SiteStore::mutate(' not in text, f'unscoped mutation in {screen}' + assert 'SiteStore::mutate_profile' not in text, f'profile mutation in {screen}' + assert 'SiteStore::mutate_scoped' in text, f'no scoped mutation in {screen}' + sites = (root / 'src/site_frame/screens/sites.rs').read_text() + assert 'SiteStore::mutate_profile' in sites + assert 'pub fn mutate(' not in store + assert 'selected_or_first' not in store + print('SITE-01 containment and SITE-02 hard-lock contracts passed') PY - name: Live E2E must be explicitly ignored, never early-return green @@ -212,6 +317,9 @@ jobs: from pathlib import Path p = Path('crates/apps/nigig-site/tests/sync_e2e.rs') s = p.read_text() + assert '#![cfg(target_os = "linux")]' in s, ( + 'live transport fixture must not enter native provider test builds' + ) assert '#[ignore = ' in s, 'live test must be an explicit ignored test' assert 'NIMANYATTA_E2E_URL' in s, 'live test must name required config' assert 'fn live_round_trip()' in s, 'live test entry point missing' @@ -263,7 +371,16 @@ jobs: command: cargo test --locked -p nigig-site --tests -- --test-threads=1 - label: containment-storage-crypto artifact: containment-storage - command: cargo test --locked -p nigig-site --lib containment::tests -- --test-threads=1 && cargo test --locked -p nigig-site --lib ai_refine::tests -- --test-threads=1 && cargo test --locked -p nigig-site --lib crypto::tests -- --test-threads=1 && cargo test --locked -p nigig-site --lib store::tests -- --test-threads=1 + command: cargo test --locked -p nigig-site --lib containment::tests -- --test-threads=1 && cargo test --locked -p nigig-site --lib ai_refine::tests -- --test-threads=1 && cargo test --locked -p nigig-site --lib crypto::tests -- --test-threads=1 && cargo test --locked -p nigig-site --lib repository::tests -- --test-threads=1 && cargo test --locked -p nigig-site --lib store::tests -- --test-threads=1 + - label: site02-crypto + artifact: site02-crypto + command: cargo test --locked -p nigig-site --lib crypto::tests -- --test-threads=1 + - label: site02-repository + artifact: site02-repository + command: cargo test --locked -p nigig-site --lib repository::tests -- --test-threads=1 + - label: site02-store + artifact: site02-store + command: cargo test --locked -p nigig-site --lib store::tests -- --test-threads=1 - label: contained-media-export-fixtures artifact: contained-media command: cargo test --locked -p nigig-site --lib gif::tests -- --test-threads=1 && cargo test --locked -p nigig-site --lib video::tests -- --test-threads=1 && cargo test --locked -p nigig-site --lib ocr::tests -- --test-threads=1 && cargo test --locked -p nigig-site --lib report_pdf::tests -- --test-threads=1 && cargo test --locked -p nigig-site --lib doc_export::tests -- --test-threads=1 @@ -289,20 +406,87 @@ jobs: if-no-files-found: error retention-days: 14 + native-platform-contracts: + name: SITE-02 native provider/filesystem (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 65 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Install Linux native build and disposable-vault dependencies + if: runner.os == 'Linux' + shell: bash + env: + NIGIG_SITE_INSTALL_NATIVE_KEYRING: '1' + run: crates/apps/nigig-site/tools/ci-setup-ubuntu.sh + - name: Compile the target-native provider and repository + shell: bash + run: | + set -euo pipefail + mkdir -p /tmp/nigig-site-native-evidence + { + rustc --version --verbose + cargo --version --verbose + cargo check --locked -p nigig-site --tests + cargo clippy --locked -p nigig-site --tests --no-deps -- -D warnings + } 2>&1 | tee /tmp/nigig-site-native-evidence/compile.log + - name: Execute target-native crypto/repository/store contracts + shell: bash + run: | + set -euo pipefail + { + cargo test --locked -p nigig-site --lib crypto::tests -- --test-threads=1 + cargo test --locked -p nigig-site --lib repository::tests -- --test-threads=1 + cargo test --locked -p nigig-site --lib store::tests -- --test-threads=1 + } 2>&1 | tee /tmp/nigig-site-native-evidence/contracts.log + - name: Exercise a disposable real Secret Service vault + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + crates/apps/nigig-site/tools/native-keyring-smoke.sh \ + /tmp/nigig-site-native-keyring 2>&1 | \ + tee /tmp/nigig-site-native-evidence/linux-secret-service.log + test ! -e /tmp/nigig-site-native-keyring + - name: Exercise a disposable Apple or Windows native vault + if: runner.os == 'macOS' || runner.os == 'Windows' + shell: bash + env: + NIGIG_SITE_LIVE_KEYRING_TEST: isolated-ci-native-v1 + run: | + set -euo pipefail + cargo test --locked -p nigig-site --lib \ + repository::tests::apple_windows_native_provider_real_vault_lifecycle -- \ + --ignored --exact --test-threads=1 2>&1 | \ + tee /tmp/nigig-site-native-evidence/native-vault.log + - name: Upload target-native evidence + if: always() + uses: forgejo/upload-artifact@16871d9e8cfcf27ff31822cac382bbb5450f1e1e # v4 + with: + name: nigig-site-native-${{ runner.os }}-${{ github.sha }} + path: /tmp/nigig-site-native-evidence/ + if-no-files-found: warn + retention-days: 14 + runtime-ui: - name: Runtime UI (explicitly skipped until enabled) - if: ${{ vars.NIGIG_SITE_RUNTIME_UI_ENABLED == 'true' }} + name: SITE-02 desktop runtime and normal shutdown runs-on: ubuntu-latest timeout-minutes: 65 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - run: crates/apps/nigig-site/tools/ci-setup-ubuntu.sh - - name: Execute real runtime assertions + - name: Build the real desktop binary + run: | + crates/apps/nigig-site/tools/ci-cargo.sh runtime-build \ + cargo build --locked -p nigig-site --bin nigig-site + - name: Render safe mode, close normally, and prove startup wrote no repository run: | set -euo pipefail - test -f crates/apps/nigig-site/tests/runtime_ui.rs - crates/apps/nigig-site/tools/ci-cargo.sh runtime-ui \ - cargo test --locked -p nigig-site --test runtime_ui -- --test-threads=1 + crates/apps/nigig-site/tools/runtime-smoke.sh \ + target/debug/nigig-site /tmp/nigig-site-ci - if: always() uses: forgejo/upload-artifact@16871d9e8cfcf27ff31822cac382bbb5450f1e1e # v4 with: @@ -312,19 +496,20 @@ jobs: retention-days: 14 migration-recovery: - name: Migration and recovery (explicitly skipped until enabled) - if: ${{ vars.NIGIG_SITE_MIGRATION_GATE_ENABLED == 'true' }} + name: SITE-02 migration, recovery, and fault corpus runs-on: ubuntu-latest timeout-minutes: 65 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - run: crates/apps/nigig-site/tools/ci-setup-ubuntu.sh - - name: Execute recovery and fault corpus + - name: Execute strict envelope and locked migration/recovery tests run: | set -euo pipefail - test -f crates/apps/nigig-site/tests/storage_recovery.rs - crates/apps/nigig-site/tools/ci-cargo.sh migration-recovery \ - cargo test --locked -p nigig-site --test storage_recovery -- --test-threads=1 + crates/apps/nigig-site/tools/ci-cargo.sh migration-recovery bash -lc ' + cargo test --locked -p nigig-site --lib crypto::tests -- --test-threads=1 + cargo test --locked -p nigig-site --lib repository::tests -- --test-threads=1 + cargo test --locked -p nigig-site --lib store::tests -- --test-threads=1 + ' - if: always() uses: forgejo/upload-artifact@16871d9e8cfcf27ff31822cac382bbb5450f1e1e # v4 with: @@ -384,7 +569,7 @@ jobs: security-supply-chain: name: Security and supply-chain baseline runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 35 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -392,9 +577,28 @@ jobs: run: | set -euo pipefail test -f Cargo.lock - cargo metadata --locked --format-version 1 >/tmp/nigig-site-metadata.json + # Resolve a conservative all-feature superset as well as every target + # predicate; the Site crate itself has no feature bypass surface. + cargo metadata --locked --format-version 1 --all-features \ + >/tmp/nigig-site-metadata.json git diff --exit-code -- Cargo.lock + - name: Audit the Site production graph against RustSec + run: | + set -euo pipefail + cargo install cargo-audit --version 0.22.2 --locked + set +e + cargo audit --file Cargo.lock --json > /tmp/nigig-site-audit.json + audit_status=$? + set -e + test "$audit_status" -eq 0 || test "$audit_status" -eq 1 + test -s /tmp/nigig-site-audit.json + crates/apps/nigig-site/tools/audit-production-deps.py \ + /tmp/nigig-site-metadata.json \ + /tmp/nigig-site-audit.json \ + /tmp/nigig-site-rustsec-report.txt + echo "workspace cargo-audit exit=${audit_status}; scoped report is authoritative for this production graph" + - name: Every live git dependency has a full immutable revision run: | set -euo pipefail @@ -441,14 +645,83 @@ jobs: fi echo 'no Cargo failure suppression found' + - name: SITE-02 review packet and plaintext policy are explicit + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + + root = Path('crates/apps/nigig-site') + review = (root / 'SITE_02_SECURITY_REVIEW.md').read_text() + lifecycle = (root / 'SITE_02_KEY_LIFECYCLE_DESIGN.md').read_text() + assert '**Decision status:** `PROPOSED — NOT APPROVED`' in lifecycle + assert '**Production implementation:** `ABSENT / BLOCKED`' in lifecycle + assert '**Independent cryptography reviewer:** `UNASSIGNED`' in lifecycle + pending = '**Security decision:** `NOT APPROVED`' in review + approved = '**Security decision:** `APPROVED`' in review + assert pending != approved, 'review decision must be exactly pending or approved' + if pending: + assert '**Production activation:** `BLOCKED`' in review + else: + assert '**Production activation:** `APPROVED`' in review + assert '**Independent reviewer:** `UNASSIGNED`' not in review + assert '**Decision status:** `APPROVED`' in lifecycle + assert '**Production implementation:** `IMPLEMENTED / ENABLED`' in lifecycle + assert '**Independent cryptography reviewer:** `UNASSIGNED`' not in lifecycle + for blocker in [f'B{i}' for i in range(1, 13)]: + assert f'| {blocker} |' in review, f'missing review blocker {blocker}' + + crypto = (root / 'src/crypto.rs').read_text() + repository = (root / 'src/repository.rs').read_text() + production_repository = repository.split('\n#[cfg(test)]\nmod tests {', 1)[0] + assert len(production_repository) < len(repository), 'test boundary not found' + assert 'const ALG_PLAINTEXT:' not in crypto + assert 'plaintext fallback' in crypto.lower() + assert 'write_all(envelope)' in production_repository + assert 'write_all(&plaintext)' not in production_repository + assert 'set_password' not in production_repository + assert 'set_secret' not in production_repository + assert 'fn create_key' not in production_repository + print('pending review is explicit; production has no plaintext/key-creation path') + PY + + - name: Upload RustSec evidence + if: always() + uses: forgejo/upload-artifact@16871d9e8cfcf27ff31822cac382bbb5450f1e1e # v4 + with: + name: nigig-site-rustsec-${{ github.sha }} + path: | + /tmp/nigig-site-audit.json + /tmp/nigig-site-rustsec-report.txt + if-no-files-found: warn + retention-days: 14 + release-capability-gate: name: Release capability gate + if: always() + needs: + - ownership-and-contracts + - cargo-gates + - native-platform-contracts + - runtime-ui + - migration-recovery + - media-limits + - sync-interoperability + - security-supply-chain runs-on: ubuntu-latest timeout-minutes: 10 env: + OWNERSHIP_RESULT: ${{ needs.ownership-and-contracts.result }} + CARGO_RESULT: ${{ needs.cargo-gates.result }} + NATIVE_PLATFORM_RESULT: ${{ needs.native-platform-contracts.result }} + RUNTIME_RESULT: ${{ needs.runtime-ui.result }} + MIGRATION_RESULT: ${{ needs.migration-recovery.result }} + SECURITY_RESULT: ${{ needs.security-supply-chain.result }} + MEDIA_RESULT: ${{ needs.media-limits.result }} + SYNC_RESULT: ${{ needs.sync-interoperability.result }} ENFORCE_RELEASE_GATES: ${{ inputs.enforce_release_gates }} - RUNTIME_ENABLED: ${{ vars.NIGIG_SITE_RUNTIME_UI_ENABLED }} - MIGRATION_ENABLED: ${{ vars.NIGIG_SITE_MIGRATION_GATE_ENABLED }} + SITE02_APPROVED: ${{ vars.NIGIG_SITE_02_SECURITY_APPROVED }} + SITE02_APPROVED_COMMIT: ${{ vars.NIGIG_SITE_02_APPROVED_COMMIT }} MEDIA_ENABLED: ${{ vars.NIGIG_SITE_MEDIA_LIMITS_ENABLED }} SYNC_ENABLED: ${{ vars.NIGIG_SITE_SYNC_E2E_ENABLED }} NIMANYATTA_E2E_URL: ${{ secrets.NIMANYATTA_E2E_URL }} @@ -457,25 +730,48 @@ jobs: - name: Development status or hard release gate run: | set -euo pipefail + for status in \ + "$OWNERSHIP_RESULT" "$CARGO_RESULT" "$NATIVE_PLATFORM_RESULT" \ + "$RUNTIME_RESULT" "$MIGRATION_RESULT" "$SECURITY_RESULT"; do + test "$status" = success + done + release=false case "${GITHUB_REF:-}" in refs/tags/*) release=true ;; esac if [ "${ENFORCE_RELEASE_GATES:-false}" = true ]; then release=true; fi if [ "$release" != true ]; then echo 'Development CI capability status:' - echo " runtime-ui=${RUNTIME_ENABLED:-false} (disabled jobs are shown as skipped)" - echo " migration=${MIGRATION_ENABLED:-false} (disabled jobs are shown as skipped)" - echo " media-limits=${MEDIA_ENABLED:-false} (disabled jobs are shown as skipped)" - echo " sync-e2e=${SYNC_ENABLED:-false} (disabled jobs are shown as skipped)" + echo ' runtime-ui=mandatory job' + echo ' migration/recovery=mandatory locked-design job' + echo " site02-security-approved=${SITE02_APPROVED:-false}" + echo " media-limits=${MEDIA_ENABLED:-false} (later tranche; disabled job is skipped)" + echo " sync-e2e=${SYNC_ENABLED:-false} (later tranche; disabled job is skipped)" exit 0 fi - test "${RUNTIME_ENABLED:-false}" = true - test "${MIGRATION_ENABLED:-false}" = true + # Two independent facts are required: a repository variable naming + # the exact reviewed commit, and a signed-off packet in that commit. + test "${SITE02_APPROVED:-false}" = true + test -n "${SITE02_APPROVED_COMMIT:-}" + test "${SITE02_APPROVED_COMMIT}" = "${GITHUB_SHA}" + grep -Fq '**Security decision:** `APPROVED`' \ + crates/apps/nigig-site/SITE_02_SECURITY_REVIEW.md + ! grep -Fq '**Independent reviewer:** `UNASSIGNED`' \ + crates/apps/nigig-site/SITE_02_SECURITY_REVIEW.md + grep -Fq '**Decision status:** `APPROVED`' \ + crates/apps/nigig-site/SITE_02_KEY_LIFECYCLE_DESIGN.md + grep -Fq '**Production implementation:** `IMPLEMENTED / ENABLED`' \ + crates/apps/nigig-site/SITE_02_KEY_LIFECYCLE_DESIGN.md + ! grep -Fq '**Independent cryptography reviewer:** `UNASSIGNED`' \ + crates/apps/nigig-site/SITE_02_KEY_LIFECYCLE_DESIGN.md + + # Full application release still requires later-tranche capabilities + # to be enabled and to have actually succeeded for this exact run. + test "$MEDIA_RESULT" = success + test "$SYNC_RESULT" = success test "${MEDIA_ENABLED:-false}" = true test "${SYNC_ENABLED:-false}" = true test -n "$NIMANYATTA_E2E_URL" - test -f crates/apps/nigig-site/tests/runtime_ui.rs - test -f crates/apps/nigig-site/tests/storage_recovery.rs test -f crates/apps/nigig-site/tests/media_limits.rs test -d crates/nimanyatta/src - echo 'all required release capabilities are configured; their jobs still decide pass/fail' + echo 'reviewed commit and all later release capabilities are configured; jobs still decide pass/fail' diff --git a/Cargo.lock b/Cargo.lock index e6224c7..77b6cc0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,7 @@ dependencies = [ "cfg-if 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)", "cipher 0.4.4", "cpufeatures 0.2.17", + "zeroize", ] [[package]] @@ -63,6 +64,7 @@ dependencies = [ "ctr", "ghash", "subtle", + "zeroize", ] [[package]] @@ -1802,6 +1804,7 @@ checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" dependencies = [ "opaque-debug", "polyval", + "zeroize", ] [[package]] @@ -4046,11 +4049,15 @@ name = "nigig-site" version = "0.1.0" dependencies = [ "aead", + "aes 0.8.4", "aes-gcm", + "apple-native-keyring-store", "chrono", "getrandom 0.2.17", + "ghash", "image", - "keyring", + "keyring-core", + "libc", "makepad-test", "makepad-widgets", "nigig-core", @@ -4058,6 +4065,7 @@ dependencies = [ "nigig-pdf-document", "nigig-pdf-graphics", "nigig_doc_scanner", + "polyval", "reqwest", "robius-directories", "serde", @@ -4065,6 +4073,10 @@ dependencies = [ "ulid", "uuid", "weezl 0.1.12 (registry+https://github.com/rust-lang/crates.io-index)", + "windows-native-keyring-store", + "windows-sys 0.61.2", + "zbus-secret-service-keyring-store", + "zeroize", "zip", ] @@ -4755,6 +4767,7 @@ dependencies = [ "cpufeatures 0.2.17", "opaque-debug", "universal-hash", + "zeroize", ] [[package]] diff --git a/crates/apps/nigig-site/Cargo.toml b/crates/apps/nigig-site/Cargo.toml index 9053c23..94df968 100644 --- a/crates/apps/nigig-site/Cargo.toml +++ b/crates/apps/nigig-site/Cargo.toml @@ -13,12 +13,37 @@ chrono = { version = "0.4", features = ["serde"] } ulid = { version = "1", features = ["serde"] } uuid = { version = "1", features = ["v4", "serde"] } # Fail-closed authenticated storage (see src/crypto.rs and src/store.rs). -aes-gcm = "0.10" +# `aes-gcm` 0.10 does not propagate its optional zeroization into the AES and +# GHASH backends, so the feature-only direct pins below deliberately unify those +# already-transitive crates with their drop-time zeroization support enabled. +aes-gcm = { version = "0.10", default-features = false, features = ["aes", "alloc", "zeroize"] } +aes = { version = "0.8.4", features = ["zeroize"] } +ghash = { version = "0.5.1", features = ["zeroize"] } +polyval = { version = "0.6.2", features = ["zeroize"] } aead = "0.5" -keyring = "4" getrandom = "0.2" +zeroize = "1" +keyring-core = "1" -[dev-dependencies] +# Use one explicit native credential-store provider per desktop platform. The +# all-in-one `keyring` facade is intentionally not linked by production code. +[target.'cfg(target_os = "linux")'.dependencies] +zbus-secret-service-keyring-store = { version = "1", features = ["crypto-rust"] } + +[target.'cfg(target_os = "macos")'.dependencies] +apple-native-keyring-store = { version = "1", features = ["keychain"] } + +[target.'cfg(target_os = "windows")'.dependencies] +windows-native-keyring-store = { version = "1", default-features = false } +windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +# Contained media/export and live-network fixtures execute only in the owned +# Linux CI jobs. Keeping their broad dependency graph out of macOS/Windows test +# builds lets the target-native repository contracts compile independently. +[target.'cfg(target_os = "linux")'.dev-dependencies] makepad-test = { workspace = true } nigig-core = { path = "../../nigig-core" } # Legacy media/export implementations are test-only containment fixtures. diff --git a/crates/apps/nigig-site/SITE_02_KEY_LIFECYCLE_DESIGN.md b/crates/apps/nigig-site/SITE_02_KEY_LIFECYCLE_DESIGN.md new file mode 100644 index 0000000..c44d14a --- /dev/null +++ b/crates/apps/nigig-site/SITE_02_KEY_LIFECYCLE_DESIGN.md @@ -0,0 +1,443 @@ +# SITE-02 Key Lifecycle and Recovery Design Proposal + +**Document date:** 2026-09-12 +**Decision status:** `PROPOSED — NOT APPROVED` +**Production implementation:** `ABSENT / BLOCKED` +**Independent cryptography reviewer:** `UNASSIGNED` +**Applies to:** the `NIGIG2` encrypted repository candidate + +This is a review input, not authorization to activate setup, recovery, rotation, +migration, or deletion. The compiled application must continue returning +`SITE-02-SECURITY-REVIEW-REQUIRED` for setup and migration until this design is +approved, implemented, exercised on every supported platform, and tied to an +exact release commit. + +## 1. Goals and non-goals + +### Goals + +1. A missing or locked native-vault record never causes creation of a replacement + key for an existing repository. +2. Initial setup is explicit, recoverable, and transactional across the native + credential store and encrypted repository as far as platform APIs permit. +3. Recovery is controlled by the user and does not depend on a Nigig-operated + plaintext-key service. +4. Key rotation is resumable after a crash and never retires the only key capable + of opening the canonical repository. +5. Deletion distinguishes cryptographic erasure from physical media erasure and + never makes claims the operating system, SSD, backup service, or filesystem + cannot prove. +6. Restored or replaced old ciphertext is detected against a trusted state outside + the repository file. +7. Every lifecycle operation is auditable without logging domain data, DEKs, + recovery secrets, nonces, or credential-store payloads. + +### Non-goals + +- No password-derived encryption will be invented in this crate. +- No silent cloud escrow, administrator escrow, or telemetry is permitted. +- No recovery secret may be derived from email, phone number, site name, device + identifier, or another low-entropy value. +- “Secure delete” will not mean guaranteed physical overwrite on flash storage, + copy-on-write filesystems, snapshots, backups, or synchronized folders. +- This proposal does not split the legacy whole-store aggregate. SITE-03 must + assign or quarantine records before per-site repositories become authoritative. + +## 2. Assets and threat model + +### Protected assets + +- The 256-bit repository data-encryption key (DEK). +- Recovery identities and recovery packages. +- Confidential site records in memory and at rest. +- Repository identity, key identity, revision, and rollback-anchor state. +- The user's ability to recover data after device loss or vault reset. + +### Threats in scope + +- Theft or offline copying of the repository directory. +- Accidental native-vault deletion, reset, lock, or transient unavailability. +- Application or operating-system crash at every lifecycle step. +- Restoration of an older repository file or backup. +- Two cooperative Nigig processes attempting lifecycle or write operations. +- Wrong, missing, retired, malformed, or mismatched credentials. +- Support logs, crash artifacts, temporary files, and CI artifacts leaking secrets. +- A user being tricked into believing an unverified recovery kit works. + +### Threats not solved solely by this design + +- A process already executing as the user can read application plaintext and may + call the user's credential APIs. +- A compromised kernel, unlocked session, malicious accessibility service, or + hostile native-vault implementation is outside this repository boundary. +- An uncooperative writer can ignore advisory locks. Directory ownership and OS + access controls remain mandatory. +- Physical recovery from SSD cells, filesystem snapshots, cloud backups, and + external backup media cannot be disproved by application-level deletion. + +## 3. Mandatory invariants + +The implementation must make these machine-checkable: + +- Existing-envelope open is lookup-only using the exact authenticated + `(store_id, key_id)` pair. +- `Missing`, `Locked/Unavailable`, `Invalid`, `Retired`, and `AuthenticationFailed` + remain distinct typed states and never fall through to setup. +- Key creation is reachable only from an explicit setup or rotation state machine. +- A key is written as `pending` before ciphertext may reference it. +- A key is marked `active` only after canonical ciphertext has been reopened, + authenticated, deserialized, and compared with the intended plaintext. +- An old active key is not retired until the new key and canonical revision are + verified and the user-selected recovery requirement is satisfied. +- Key deletion never occurs in the same unconfirmed action as repository deletion. +- Lifecycle state and audit events contain identifiers and support codes only, + never key material or domain payloads. +- Any unknown lifecycle state fails closed into recovery UI. + +## 4. Proposed key records + +The current candidate reads a minimal native record: + +- `0x01 || 32-byte non-zero DEK`: active; +- `0x02 || 32-byte DEK`: retired. + +That format is sufficient only for read-only candidate evaluation. Production +setup requires a versioned, authenticated record with explicit transition data. +The proposed logical record is: + +```text +record_version +store_id +key_id +key_state = pending | active | retired +DEK (32 bytes) +committed_revision +committed_ciphertext_digest +optional_pending_revision +optional_pending_ciphertext_digest +created_at +activated_at +optional_retired_at +``` + +The native credential store is the confidentiality boundary for this record. +Fields also present in the envelope are repeated to detect account-name/provider +mismatches. Timestamps are audit metadata, not security clocks. The exact binary +encoding, digest algorithm, provider size ceilings, update atomicity, and rollback +semantics require reviewer and platform-owner approval. + +A credential provider must advertise persistence equivalent to `UntilDelete`. +Session-only or reboot-only stores are rejected. That capability is insufficient +by itself: setup must also explicitly select and verify whether each record is +local, synchronized, or roamable. In particular, the locked Windows adapter +currently documents `Enterprise` as its default for newly written records, which +may roam to another computer for the same user; silent cross-device DEK sharing +would invalidate nonce/device assumptions. The candidate read path rejects a +Windows record not reported as exactly `Local`, but approved setup must still +create and verify that persistence on a real target. Providers must be instantiated +explicitly for Linux Secret Service, Apple Keychain, and Windows Credential +Manager; no plaintext file fallback is permitted. Same-entry lifecycle updates +must be serialized because provider documentation warns that concurrent access +may not be reliably ordered. + +## 5. User-controlled recovery proposal + +### Selected review candidate: an offline asymmetric recovery identity + +The preferred proposal is a standard, independently reviewed `age` v1 X25519 +recipient/identity flow, restricted to asymmetric recipients. The crate must not +implement the protocol itself. + +During setup: + +1. Generate the repository DEK and an independent recovery identity with the OS + CSPRNG. +2. Encrypt a small, versioned recovery payload to the recovery recipient. The + payload contains the DEK, `store_id`, `key_id`, envelope algorithm, and creation + metadata; it contains no domain records. +3. Present the recovery identity once as an offline printable/QR recovery kit. +4. Require the user to re-import or confirm a separately stored kit before setup + becomes active. Merely clicking “I saved it” is insufficient for the default + recoverable mode. +5. Store only the public recipient and encrypted recovery package with application + state. Never store the recovery identity beside the repository or in logs, + clipboard history, analytics, screenshots, or crash reports. + +Why this candidate: + +- It uses a high-entropy generated identity rather than password crypto. +- It uses an existing interoperable format and implementation instead of a custom + KDF/wrapping construction. +- The public recipient can be retained without enabling recovery. +- Multiple explicitly chosen recipients can support user-held and organization-held + recovery without revealing the DEK to a Nigig service. + +Approval is still required for the exact `age` version, dependency provenance, +algorithm agility, payload/AAD binding, printable-kit UX, QR rendering, memory +zeroization, recipient replacement, and loss/abuse model. No `age` dependency is +present in the production candidate today. + +### Explicitly rejected shortcuts + +- A user password directly used as an AES key. +- Unsalted or ad-hoc hashing of a PIN/passphrase. +- A DEK encoded as an ordinary QR code without encryption and confirmation UX. +- Emailing or uploading the recovery identity by default. +- A “forgot password” endpoint that can silently unwrap every user's repository. +- Reusing the native-vault DEK as the recovery identity or vice versa. + +### Optional organization escrow + +Organization escrow must be opt-in, visible, revocable, and represented as an +additional public recipient. Policy must identify who controls the private key, +how access is approved and audited, how personnel changes trigger rotation, and +how compromise is handled. The user-held recovery option must not silently become +organization-only escrow. + +## 6. Initial setup state machine + +Setup starts only when no canonical repository exists and the user chooses +“Create encrypted storage.” The proposed states are: + +```text +Absent + -> ConsentRecorded + -> RecoveryPreparedAndConfirmed + -> NativeKeyPending + -> CiphertextPublishedAndVerified + -> NativeKeyActive + -> ReadyEncrypted +``` + +Required order: + +1. Revalidate that the canonical target and managed publication artifacts are + absent under the repository process lock. +2. Record explicit consent without PII. +3. Generate non-zero random `store_id`, `key_id`, and DEK. +4. Prepare and independently re-import/verify the selected recovery kit. +5. Store the DEK as `pending` in the native vault. +6. Publish revision 1 containing an empty, versioned store. Do not seed examples, + demo sites, workers, tasks, or contacts. +7. Reopen and authenticate the canonical file using the exact pending key; verify + identity, revision, and empty schema. +8. Promote the native record to `active` and persist the rollback anchor. +9. Enter `ReadyEncrypted` only after a second readback of the active state. + +### Setup crash reconciliation + +| Observed state | Required behavior | +|---|---| +| No canonical, no pending key | Remain `Absent`; no cleanup needed | +| No canonical, one matching pending key | Show resumable setup; user may explicitly resume or delete the orphan | +| Canonical references matching pending key | Authenticate/read back, then offer to complete activation | +| Canonical references active key | Verify anchor and open normally | +| Canonical identity and pending key disagree | Recovery-required; never guess or delete | +| Multiple candidate pending keys | Recovery-required with content-free support identifiers | + +Automatic deletion of an orphaned native credential is prohibited because the +canonical file may be temporarily unavailable, moved, or awaiting restoration. + +## 7. Rotation state machine + +Rotation is an explicit operation performed under the repository process lock: + +```text +OldActive + -> NewPending + -> NewRecoveryPackageVerified + -> NewCiphertextPublishedAndVerified + -> NewActive + -> OldRetired + -> OptionalOldDeletionAfterRetention +``` + +1. Reopen the current canonical file and verify its rollback anchor. +2. Generate a new independent DEK and `key_id`; store it as `pending`. +3. Create and verify a recovery package for the new key. +4. Seal a newer revision under the new key, publish it atomically, and read it back. +5. Promote the new record to `active` and update the trusted anchor. +6. Mark the old record `retired`; do not delete its material yet. +7. Retain the old key for an approved bounded rollback window if policy requires. +8. Delete the retired record only after explicit confirmation that preserved old + ciphertext/backups are no longer expected to be recoverable with it. + +A pending key may open only a canonical envelope that names its exact identity and +is in a recognized resumable transition. It must not become a general fallback. +A retired key may support an explicit rollback/recovery tool but never normal +writes. + +## 8. Rollback anchor and crash consistency + +AEAD authentication does not detect replacement of the entire file with an older, +otherwise valid envelope. The proposed trusted anchor is stored with the native +credential record and contains: + +- committed repository revision and ciphertext digest; +- optionally one pending revision and ciphertext digest during publication. + +Proposed publication protocol: + +1. Under the process lock, re-read and authenticate the exact current canonical revision, then encrypt the complete candidate and compute its digest. +2. Write the pending `(revision, digest)` to the native record before publication. +3. Publish/sync/read back the ciphertext using the repository protocol. +4. Promote pending to committed in the native record. +5. Clear the pending slot only after both stores agree. + +On open: + +- exact committed match opens; +- exact pending match enters resumable reconciliation and may be promoted only + after authentication/readback; +- a lower revision, unknown digest, missing expected file, or contradictory state + enters recovery-required mode without modifying either store. + +A native credential record is **not trusted merely because it is outside the repository file**. Platform backup, Keychain synchronization, profile restore, VM/device imaging, or administrator tooling may roll back the ciphertext and credential record together; that coordinated rollback would defeat this anchor and could also repeat a nonce reservation epoch. Approval therefore requires evidence about backup/restore/roaming semantics on each provider and either a genuinely non-rollback monotonic authority or an explicit statement that coordinated rollback is not detected. A remote transparency/monotonic service, hardware-backed counter, or user-verified recovery checkpoint may be required, each with its own availability and privacy trade-offs. + +This also requires proof that native-record replacement is sufficiently atomic on each provider. If it is not, a two-record journal with explicit generations may be needed. A recovery restore intentionally resets the anchor only after explicit user authentication and confirmation; that reset must produce an audit event. + +## 9. Nonce strategy decision required + +The current AES-256-GCM candidate uses random 96-bit nonces and a process-local +`2^32` invocation guard. That does not durably count invocations across restarts, +processes, restored device images, or devices sharing a key. + +The reviewer must approve one of these directions before setup is enabled: + +1. **Durable reservation:** maintain a monotonic per-key nonce counter/reservation + in the native record, update it before use, and prove crash/concurrency/restore + behavior. Derive the 96-bit nonce from a per-key random epoch plus the reserved + counter. This depends on a trusted non-rollback anchor. +2. **Misuse-resistant envelope algorithm:** adopt an independently reviewed + nonce-misuse-resistant AEAD with a new algorithm identifier and migration plan, + while still using random nonces and bounded use. This changes the primitive and + requires fresh cryptographic review and test vectors. +3. **Keep random GCM nonces:** retain the current construction only with a reviewed + collision/invocation analysis, a durable total-invocation policy, forced key + rotation well below the bound, and device/key-sharing restrictions. + +No option is approved by this document. Reusing a deterministic revision nonce +without a trusted anti-rollback mechanism is explicitly prohibited because an old +revision could then reuse a nonce with different plaintext under the same key. + +## 10. Recovery operation + +Recovery must never be an automatic reaction to `KeyMissing`. + +1. Show the authenticated envelope's non-secret store/key identifiers and a + content-free support code. +2. Ask the user to choose an offline recovery identity/package. +3. Parse and decrypt in bounded, zeroizing memory; verify payload version and exact + `store_id/key_id` binding. +4. Use the recovered DEK to authenticate the existing canonical ciphertext before + writing anything. +5. Ask whether to restore the key into the native vault. Store it as `pending`, + read back, then promote to `active` only after canonical verification. +6. Reconcile or explicitly reset the rollback anchor with a visible warning. +7. Never rewrite the canonical repository merely to prove the recovered key. + +Wrong recovery identities, malformed packages, and mismatched store IDs are +recoverable errors. They do not alter the repository or native vault. + +## 11. Deletion and retention + +Deletion requires two independently confirmed choices: + +- deletion of active application ciphertext; and +- deletion of native/recovery key material. + +The UI must explain that deleting only one side has different consequences. A +recommended cryptographic-erasure flow is: + +1. stop acceptance and durably drain or explicitly abandon unsaved changes; +2. enumerate canonical/temp/backup/journal artifacts without following links; +3. obtain explicit confirmation naming the repository, not domain data; +4. remove managed ciphertext and synchronize the parent directory where supported; +5. delete active, pending, and retired native records only after ciphertext handling + has succeeded or the user explicitly accepts key-only destruction; +6. explain that offline recovery kits and external backups remain independently + recoverable until separately destroyed; +7. emit a content-free deletion receipt containing operation ID, time, platform, + support result, and the categories attempted. + +The product may claim only “application ciphertext removed” and/or “native key +record deletion requested and confirmed by provider.” It must not claim forensic +physical erasure. + +## 12. Content-free lifecycle audit + +Allowed audit fields: + +- random operation ID; +- lifecycle operation and transition; +- store/key identifiers in bounded hexadecimal form; +- old/new revision numbers; +- provider/platform identifier; +- support-code result; +- trusted timestamp source and app version; +- independent approval/reference identifiers. + +Forbidden fields include site names, addresses, contacts, report text, worker IDs, +DEKs, recovery identities, wrapped-key plaintext, vault error strings, file +contents, and full user-selected paths. + +Audit storage itself must be authenticated, bounded, and included in the backup, +retention, and deletion threat model. Ordinary console logs are not the audit log. + +## 13. Required implementation and test evidence + +Before approval, attach exact-commit evidence for all of the following: + +### Model/property tests + +- Every setup/rotation/recovery state and every legal/illegal transition. +- Crash or injected failure before and after each native-store and filesystem step. +- At most one active key per store after reconciliation. +- Canonical ciphertext always has one known opening path or is explicitly reported + unrecoverable; no code silently creates a replacement. +- Rollback-anchor committed/pending reconciliation matrix. +- Nonce uniqueness/reservation/exhaustion properties for the approved strategy. +- Recovery package wrong-recipient, tamper, version, identity, and size tests. + +### Platform tests (Linux, macOS, Windows) + +- New, locked, unavailable, read-only, corrupt, duplicate, missing, pending, + active, and retired native-vault records. +- Provider record size and update atomicity under kill/restart. +- Concurrent open, rotation, recovery, and deletion attempts. +- Process-lock timeout and stale-writer conflict UX. +- Filesystem permissions, symlinks/reparse points, hard links, replace/rename, + antivirus/indexer interference, directory sync, and abrupt termination. +- Normal UI close and forced process termination with accepted/durable revisions. + +### Recovery drills + +- Fresh-device restore using only preserved ciphertext and the user-held kit. +- Wrong kit and damaged kit without mutation. +- Vault reset followed by recovery. +- Rotation with old/new kits and bounded retirement policy. +- Restored old ciphertext detected by the trusted anchor. +- Explicit anchor reset with visible warning and content-free audit. + +### Privacy checks + +- Sentinel scans over canonical/temp/backup/lock/audit/crash/support/CI artifacts. +- Clipboard and screenshot behavior for recovery identity display. +- Memory-zeroization review, including serialization and provider buffers. +- No external request during local setup/recovery unless organization escrow was + explicitly selected and its protocol independently approved. + +## 14. Approval record + +| Role | Name | Exact reviewed commit | Decision | Date | Signature/reference | +|---|---|---|---|---|---| +| Independent cryptography reviewer | UNASSIGNED | — | NOT REVIEWED | — | — | +| Independent application-security reviewer | UNASSIGNED | — | NOT REVIEWED | — | — | +| Linux credential-store owner | UNASSIGNED | — | NOT REVIEWED | — | — | +| macOS credential-store owner | UNASSIGNED | — | NOT REVIEWED | — | — | +| Windows credential-store owner | UNASSIGNED | — | NOT REVIEWED | — | — | +| Product/data-retention owner | UNASSIGNED | — | NOT REVIEWED | — | — | + +Until those approvals and all corresponding implementation evidence exist, this +proposal closes no SITE-02 blocker by itself. Setup, migration, recovery, rotation, +deletion, SITE-02 completion, and SITE-03 execution remain blocked. diff --git a/crates/apps/nigig-site/SITE_02_SECURITY_REVIEW.md b/crates/apps/nigig-site/SITE_02_SECURITY_REVIEW.md new file mode 100644 index 0000000..f091234 --- /dev/null +++ b/crates/apps/nigig-site/SITE_02_SECURITY_REVIEW.md @@ -0,0 +1,232 @@ +# SITE-02 Cryptography and Repository Security Review Packet + +**Packet date:** 2026-09-13 +**Security decision:** `NOT APPROVED` +**Production activation:** `BLOCKED` +**Independent reviewer:** `UNASSIGNED` +**Implementation status:** local candidate; Linux test/runtime/native-vault evidence and host cross-compiles only +**Scope:** `crates/apps/nigig-site` encrypted repository, native key binding, writer shutdown, migration design, and release gates + +This packet is deliberately not a self-approval. Passing unit tests, Clippy, or a Linux desktop smoke test cannot substitute for independent cryptographic review, native-vault evidence, crash testing, recovery policy, or release governance. First-run setup and all migration execution remain hard-locked with support code `SITE-02-SECURITY-REVIEW-REQUIRED`. + +## 1. Decision + +The candidate materially improves the SITE-01 containment baseline, but it is **not fit for production activation**. It provides a reviewable `NIGIG2` envelope, exact existing-key lookup, encrypted atomic-publication attempts, canonical readback, rollback attempts, a bounded process lock with stale-writer rejection, a one-slot coalescing writer, explicit accepted/durable health, and test-only migration exercises. It does **not** yet provide an approved setup, recovery, escrow, rotation, deletion, trusted anti-rollback, per-site storage, or production migration lifecycle. + +The detailed lifecycle proposal is in `SITE_02_KEY_LIFECYCLE_DESIGN.md`. It selects an offline asymmetric recovery candidate for review, defines setup/rotation/recovery/deletion state machines, and proposes a native-vault rollback anchor. It is explicitly unapproved and has no production implementation. + +No reviewer has approved: + +- AES-GCM primitive use on every supported processor; +- the 96-bit random-nonce policy across process restarts/devices; +- associated-data and envelope identity semantics; +- OS credential-store behavior on Linux, macOS, and Windows; +- key creation, rotation, recovery, escrow, and secure deletion; +- filesystem crash/power-loss behavior; +- legacy-original retention/disposition; or +- activation of setup or migration UI. + +## 2. Candidate boundary + +### Compiled production behavior + +- Normal open recognizes only strict `NIGIG2` authenticated envelopes and enables writes only for the exact current store schema. After authentication, it probes the scalar schema version as a `u64` before decoding the current shape: syntactically valid older/future shapes enter distinct preserved recovery/migration states, while an exact-current document is decoded with unknown root fields denied rather than silently discarded. +- The envelope is parsed before lookup of the exact `(store_id, key_id)` native credential. +- A missing, invalid, retired/rotated, unavailable, or wrong key is not replaced. +- Absence enters setup-required safe mode; it creates no directory, file, key, or demo data. +- Raw JSON, `NIGIG1` plaintext envelopes, and encrypted `NIGIG1` envelopes are classified for recovery/migration and are not opened by the normal runtime. +- First-run setup and migration entry points return `SITE-02-SECURITY-REVIEW-REQUIRED`. +- Confidential mutations require a valid explicit selected-site scope, except the profile-level site create/select operations; both mutation APIs are crate-private, serialize zeroizing before/after fences that discard out-of-scope changes, and no read helper silently selects the first site. +- Accepted revisions are visibly distinct from durable revisions. +- Normal application destruction requests a bounded five-second writer drain and logs only a content-free support code on failure. + +### Deliberately non-production behavior + +`migrate_legacy_json` and its plaintext/legacy decryptors compile only under `cfg(test)`. They exercise the proposed consent and preservation contract but cannot be invoked by a production binary. The broad media/export and live-network fixtures are additionally limited to Linux test builds, keeping them out of macOS/Windows native-repository test graphs. There is no production key-creation API in the candidate. + +## 3. Envelope specification under review + +The binary header is fixed at 68 bytes: + +| Offset | Bytes | Meaning | Validation / binding | +|---:|---:|---|---| +| 0 | 6 | ASCII `NIGIG2` | Exact match | +| 6 | 1 | format version (`1`) | Unsupported versions rejected | +| 7 | 1 | algorithm (`1` = AES-256-GCM) | Unsupported algorithms rejected | +| 8 | 16 | random `store_id` | Non-zero; native-key routing; AEAD AAD | +| 24 | 16 | random `key_id` | Non-zero; native-key routing; AEAD AAD | +| 40 | 8 | big-endian repository revision | Non-zero; AEAD AAD | +| 48 | 12 | random GCM nonce | AEAD AAD and nonce input | +| 60 | 8 | big-endian plaintext length | Checked before allocation; AEAD AAD | +| 68 | variable | ciphertext plus 16-byte GCM tag | Exact total length; authenticated | + +The full fixed header is passed as AES-GCM associated data. Header identity, revision, nonce, declared length, and algorithm/version are therefore authenticated once the exact key is available. A routing-identity change can fail as `KeyMissing` before AEAD because the altered identity deliberately selects a different native key record; changes that retain key routing fail authentication. + +### Primitive + +- AES-256-GCM via locked `aes-gcm` 0.10.x and `aead` 0.5.x. +- 256-bit DEK, 96-bit nonce, 128-bit tag. +- Plaintext and key buffers use `zeroize::Zeroizing` where owned by this crate. The locked `aes-gcm`, `aes`, `ghash`, and `polyval` dependency features enable their available temporary-key, key-schedule, and hash-state zeroization paths; this reduces residual state but is not a proof against compiler-created copies, process dumps, or abrupt termination. It is also incomplete upstream: `polyval` 0.6.2's autodetect union uses `ManuallyDrop`, and its ARMv8 PMULL backend explicitly leaves zeroization unimplemented. No complete cryptographic-state-erasure claim is made. +- No plaintext encryption fallback exists. +- A fixed envelope known-answer vector independently generated through Node.js/OpenSSL verifies the exact header, AAD, ciphertext, and tag bytes; every truncated prefix and an extended form are rejected. +- Maximum current whole-store plaintext: 64 MiB; declared and observed sizes are checked before decryption/large allocation, including a sparse oversized-file repository test. + +The upstream `aes-gcm` documentation reports a 2020 NCC Group audit with no significant findings, but also warns that its portable implementation is not suitable on processors with variable-time multiplication. That hardware precondition is unresolved (B7). + +### Nonce policy + +Each seal obtains a fresh 96-bit value from the operating-system RNG. A process-local atomic guard refuses more than `2^32` random-nonce invocations. This follows the broad SP 800-38D random-IV invocation ceiling but does **not** durably account per key across restarts, concurrent processes, restored device images, or multiple devices. Production approval requires a reviewed durable policy and rotation/reconciliation rules (B5). + +References for reviewers: + +- NIST SP 800-38D: +- `aes-gcm` 0.10.3 documentation: +- `keyring` ecosystem guidance: + +## 4. Native key binding under review + +The production crate links `keyring-core` plus one explicit provider per desktop target: + +| Target | Provider candidate | Evidence status | +|---|---|---| +| Linux | Secret Service through `zbus-secret-service-keyring-store` | Disposable real vault invalid/active/retired/missing/locked states plus two encrypted revisions/reopens, Unix mode/link checks, and wrong/missing-key preservation pass; no user vault touched | +| macOS | Keychain through `apple-native-keyring-store` | The `aarch64-apple-darwin` library/test graph cross-check compiles and lints; explicit disposable native-vault lifecycle test is declared but not executed locally | +| Windows | Credential Manager through `windows-native-keyring-store` | The `x86_64-pc-windows-msvc` library/test graph, including fail-closed `Local` persistence and reparse/link-count contracts, compiles and lints; explicit isolated native-vault/filesystem execution is declared but not run locally | + +Credential account names derive only from hexadecimal `store_id:key_id`; no PII is included. Native records are versioned as one state byte plus a 32-byte DEK: `0x01` active and `0x02` retired. Active all-zero key material is rejected as invalid. A retired marker is differentiated from a deleted/missing record. Provider initialization now rejects a credential store that does not advertise `UntilDelete` persistence. The candidate can read this representation but intentionally provides no production create, rotate, retire, recover, export, or delete operation. + +The all-in-one `keyring` facade is not a direct production dependency. `CredentialPersistence::UntilDelete` proves only lifetime class, not non-roaming or non-rollback behavior. The locked Windows adapter documents `Enterprise` as its default for newly written generic credentials and warns that operations on one entry from different threads are not reliably ordered; Microsoft documents that enterprise persistence may expose a credential to the same user on other computers. The candidate now rejects a Windows record whose reported persistence is not exactly `Local` before retrieving its secret. Production creation is absent today, so a reviewed setup/rotation design must still explicitly create and target-natively verify local records and serialize same-entry updates. The locked Apple adapter and feature selection use its legacy User/login keychain by default; Apple documents that a macOS keychain file can be restored from Time Machine. Apple also documents that an iOS/iPadOS local device keychain participates in same-device iCloud backup/restore. Exact deployed backup/restore behavior still requires target-native verification and therefore cannot close B8. These semantics prevent treating a native record as an inherently trusted monotonic anchor. + +Provider initialization and error mappings still require platform-owner review and real OS-vault tests. + +Platform-semantics references for reviewers: + +- Microsoft `CREDENTIAL` persistence values: +- Apple macOS Keychain and Time Machine restore: +- Apple iCloud Backup and local device-keychain restore: +- GNOME libsecret locking/error model: + +## 5. Publication and durability design under review + +For a newer revision, the repository currently attempts: + +1. reject symlinks in existing managed path components and managed artifacts; on Windows reject every reparse-point component; +2. open a non-following, owner-checked `0600` lock file and acquire an exclusive process lock within five seconds; Windows opens an existing lock with `FILE_FLAG_OPEN_REPARSE_POINT`, rejects reparse metadata, and requires one link through handle metadata; +3. reopen the canonical document without following a final symlink/reparse point; on Unix require a private file mode, one link, the parent owner, and a parent that is not group/other writable; on Windows require a one-link handle; then verify identity/revision still exactly match the caller's metadata; +4. look up the exact existing DEK; +5. authenticate the current canonical ciphertext under that DEK before creating any publication artifact, so a matching but tampered header cannot authorize overwrite; +6. serialize into a zeroizing in-memory buffer; +7. encrypt before opening a temporary file; +8. create a unique temporary file with `create_new` and, on Unix, no-follow/close-on-exec flags and mode `0600`; +9. write all ciphertext, explicitly `flush`, and `sync_all` the file; +10. rename the old canonical ciphertext to a unique rollback name; +11. rename the new ciphertext into the canonical name; +12. synchronize the parent directory where supported; +13. reopen without following a final symlink, parse, decrypt, and byte-compare canonical ciphertext; +14. remove the rollback ciphertext; and +15. synchronize the parent directory again. + +The only persistent companion is an empty `..lock` coordination file; it contains no identity, revision, key material, or domain data. Failures before publication clean the temporary file and preserve the canonical bytes. Injected failures after publication attempt to restore the prior canonical ciphertext. A subprocess harness now terminates without unwinding at all 12 exercised commit fault stages through readback, including the explicit flush stage, and verifies that original ciphertext remains recoverable, publication artifacts force recovery, and no sentinel plaintext is present. Unexpected temp/backup artifacts are checked both before and after lock acquisition and force recovery instead of automatic cleanup, including when a prior process dies while a writer waits. The process lock plus in-lock authenticated canonical revision check prevents a matching-but-tampered current envelope or a second cooperative instance from silently authorizing overwrite; a simultaneous two-writer test proves exactly one revision-2 commit wins and the stale peer fails. This is safer than silently choosing a revision, but it is not proof against hostile/uncooperative writers or all filesystem, kernel, device-cache, antivirus, cross-platform, or power-loss behaviors (B6, B8, B9, B11). + +`File::sync_all` only attempts to synchronize content and metadata; actual persistence guarantees remain filesystem/platform dependent: . + +## 6. Writer and UI health semantics + +- One process-wide pending slot coalesces complete snapshots; queue depth is at most one. +- Every accepted snapshot includes all prior accepted in-memory changes. +- The writer serializes publication and may skip intermediate revision numbers when pending snapshots coalesce. +- Health exposes accepted revision, durable revision, pending depth, active state, accepting state, and last typed failure. +- `accepted > durable` is explicitly labelled `UNSAVED`. +- A writer failure is sticky and blocks later closures before they mutate the canonical in-memory value. +- Graceful shutdown stops acceptance, drains the pending slot within a caller-supplied bound, and joins only after the worker reports completion; dropping a writer without that explicit path now at least wakes its idle worker and requests a non-blocking drain/stop. + +This is still one whole-store writer rather than the plan's final per-site repository/writer architecture. The scope fence excludes the legacy supplier directory because its records have no `site_id`; that shared/ambiguous model must be assigned or quarantined in SITE-03. Cooperative process exclusion and stale-revision rejection now exist, but contention/crash campaigns, per-site isolation, external change ingestion, and hostile-writer handling remain unresolved (B11). + +## 7. Migration contract exercised in tests + +The test-only migration controller enforces: + +- explicit consent before source/key access; +- a distinct, absent target path; +- an already-provisioned exact target key (no create-on-lookup-failure path); +- one bounded in-memory source snapshot; +- separate handling for raw/explicit plaintext and encrypted `NIGIG1`; +- failure without a target when the historical key is missing; +- authenticated `NIGIG2` target publication and semantic readback; and +- byte-identical preservation of the legacy original. + +Production migration remains absent. Retention duration, backup interaction, legal/user-confirmed deletion, secure-erasure claims, interrupted GUI resume behavior, and rollback tooling are unresolved (B3, B4, B10). + +## 8. Current evidence + +Evidence produced locally on Linux with Rust/Cargo 1.97.1: + +| Gate | Current result | +|---|---| +| `cargo test --locked -p nigig-site` | Library 80 passed and 1 live-vault test explicitly ignored; binary 1 passed; integration 6 passed and 1 live-server test explicitly ignored; doc tests 0 | +| `cargo test --locked -p nigig-site --lib` | 80 passed, 0 failed, 1 live-vault test explicitly ignored | +| `crypto::tests` | 13 passed, including the independent fixed vector and all-zero pre-use rejection | +| `repository::tests` | 28 normal tests passed; dedicated ignored live-vault test also passed separately | +| `store::tests` | 9 passed, including version-first arbitrary-shape classification, strict current root-schema rejection, and cross-site/profile mutation-fence rejection | +| `cargo check --locked -p nigig-site --all-targets` | Passed | +| crate-owned Clippy, all targets, `--no-deps -D warnings` | Passed | +| crypto feature resolution | `aes-gcm`, AES schedule, GHASH, and POLYVAL `zeroize` features present for Linux x64, Windows x64 MSVC, and macOS ARM64 target graphs | +| workflow source/ownership assertions | Passed locally, including path coverage, immutable action/dependency revisions, hard locks, and no Cargo failure suppression | +| `Cargo.lock` SHA-256 | `ad166a13f0b3e51f9b9b0cde743adbbd3413db2ee0d441faadfb24033042795b` (dependency versions unchanged; crypto-backend `zeroize` and existing `windows-sys` package edges enabled) | +| RustSec production graph | Conservative all-feature/all-target traversal: 0 findings across 356 reachable normal/build packages; all 6 workspace findings proved dev/unrelated-workspace only; DB commit `b50980aad8b8f14f77e25a97b32dd94bf008b0af` | +| desktop binary build | Passed; release ELF SHA-256 `fea50fa70e75a331b3d3e6625cac945332ad16de6a1a4f39fd7afcc487d38762` | +| production binary/source sentinel scan | No fixture plaintext/native-vault test markers in the release binary; no production native-key write/create or plaintext-publication token | +| X11 real-binary smoke | Rendered safe mode, closed through WM close, exited normally, created no repository data | +| Linux native vault | Disposable Gnome Keyring invalid/active/retired/missing/locked states, two encrypted revisions/reopens, private/single-link canonical checks, and wrong/missing-key preservation passed; disposable root removed | +| Windows/macOS provider compile | Host-side library-and-test-target check plus crate-owned Clippy passed for Windows x64 MSVC and macOS ARM64 targets; the Windows GNU library test executable also cross-linked as PE32+ | +| Windows/macOS execution | Not run; mandatory target-native CI now invokes an opt-in isolated credential lifecycle (invalid/zero/active/wrong/retired/missing plus two repository revisions), with Windows local/enterprise persistence, hardlink, and junction rejection checks; the workflow remains unpublished/unexecuted | +| independent review | Not performed | + +The broad media/export and live-network fixtures are now Linux-CI-only, so the exact Windows MSVC and macOS ARM64 library test graphs can be type-checked and linted from the Linux host. Those commands still do not link or execute native credential/filesystem behavior, so the workflow continues to require actual Windows and macOS runners; cross-checking is not counted as target-native evidence. + +These are candidate-development results, not release evidence. CI results must be attached to the exact reviewed commit, and the Gitdab branch/status policy must prevent bypass. + +## 9. Required blocker disposition + +| ID | Blocker | Required evidence to close | Status | +|---|---|---|---| +| B1 | No independent cryptography/security reviewer | Named qualified reviewer, dated review, exact commit hash, signed decision and findings disposition | OPEN | +| B2 | No approved first-run key setup | Reviewed UX/consent flow; atomic repository/key transaction; orphan cleanup; real vault evidence; no automatic setup | OPEN | +| B3 | No approved user-controlled recovery/escrow design | Threat model, key wrapping/KDF choice from established construction, recovery authentication, loss/abuse analysis, restore drill | OPEN | +| B4 | Rotation, retirement, revocation, and deletion are incomplete | State machine, old-key availability policy, crash recovery, backups, audit events, secure-deletion claims bounded by platform reality | OPEN | +| B5 | Nonce accounting is only process-local | Reviewed per-key durable/multi-process/device invocation strategy, collision analysis, ceilings, forced rotation and exhaustion tests | OPEN | +| B6 | No trusted anti-rollback anchor; a native record may be restored with ciphertext | Authenticated genuinely non-rollback monotonic authority or explicitly bounded reconciliation design; coordinated backup/device rollback analysis; downgrade and rollback tests | OPEN | +| B7 | AES-GCM hardware timing assumptions unresolved | Supported CPU/platform matrix proving constant-time multiplication requirements or approved alternative primitive/implementation | OPEN | +| B8 | Native providers/filesystem semantics remain unverified on macOS and Windows | Target-native locked/unlocked/missing/corrupt vault tests, permission/reparse tests, rename/replace, directory durability, and shutdown tests on both OSes | OPEN | +| B9 | Abrupt-exit harness exists, but no real power-loss/filesystem campaign | Native SIGKILL/termination plus remount/reboot or equivalent tests on each filesystem/OS, artifact-state matrix, recovery operator procedure | OPEN | +| B10 | Migration retention/disposition and GUI E2E absent | Existing raw and encrypted `NIGIG1` GUI tests; consent audit; restart/resume; backup treatment; explicit retain/delete policy | OPEN | +| B11 | Whole-store writer still lacks final per-site/external-change reconciliation | Per-site bounded writer/repository design; lock contention/crash and shutdown tests on every OS; external revision refresh/conflict UX; hostile-writer analysis | OPEN | +| B12 | Release governance is not independently enforced | Protected Gitdab branch, required exact-commit statuses, independent approval identity, non-bypassable activation and published artifact provenance | OPEN | + +## 10. Reviewer checklist + +A reviewer must independently verify, not merely accept author assertions: + +- [ ] Exact envelope parser bounds and integer arithmetic. +- [ ] Full-header AAD coverage and identity/key lookup behavior. +- [ ] AES-GCM implementation and supported-hardware assumptions. +- [ ] RNG failure behavior and nonce uniqueness/invocation analysis. +- [ ] Key-record format, provider selection, locked-vault behavior, concurrency, and lifecycle. +- [ ] No plaintext in canonical, temp, backup, journal, crash, support, or CI artifacts. +- [ ] Publication/rollback behavior under injected and real crash/power-loss faults. +- [ ] Writer coalescing, sticky failure, bounded shutdown, and unsaved-state UX. +- [ ] Explicit consent, old-key lookup-only behavior, preservation, verification, and disposition in migration. +- [ ] Recovery/escrow design and abuse/loss scenarios. +- [ ] Exact-commit CI, branch protection, approval identity, and activation controls. + +## 11. Sign-off (intentionally blank) + +| Role | Name | Organization | Exact commit | Decision | Date | Signature/reference | +|---|---|---|---|---|---|---| +| Independent cryptography reviewer | UNASSIGNED | — | — | NOT REVIEWED | — | — | +| Independent application-security reviewer | UNASSIGNED | — | — | NOT REVIEWED | — | — | +| Platform owner (Linux) | UNASSIGNED | — | — | NOT REVIEWED | — | — | +| Platform owner (macOS) | UNASSIGNED | — | — | NOT REVIEWED | — | — | +| Platform owner (Windows) | UNASSIGNED | — | — | NOT REVIEWED | — | — | + +Until every required blocker is closed and an exact-commit approval is published, setup/migration must remain locked, SITE-02 must not be called complete, and SITE-03 must not begin. diff --git a/crates/apps/nigig-site/src/crypto.rs b/crates/apps/nigig-site/src/crypto.rs index 2496b5b..9c6deac 100644 --- a/crates/apps/nigig-site/src/crypto.rs +++ b/crates/apps/nigig-site/src/crypto.rs @@ -1,245 +1,598 @@ -//! Fail-closed at-rest envelope handling for `store.json`. +//! Strict authenticated-at-rest envelopes for the SITE-02 repository candidate. //! -//! SITE-01 is an emergency containment boundary. Production code can only open -//! and write AES-256-GCM envelopes backed by an already-present OS-keystore -//! key. It cannot create a key, accept legacy/plaintext data, or fall back to -//! plaintext. Key setup, migration, rotation, and recovery belong to SITE-02. +//! `NIGIG2` authenticates every routing/identity field as AEAD associated data. +//! This module never creates or looks up keys and has no plaintext fallback. +//! Key lifecycle and atomic publication are repository responsibilities. -use aead::Aead; +use aead::{Aead, Payload}; use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; +use std::sync::atomic::{AtomicU64, Ordering}; +use zeroize::Zeroizing; -const MAGIC: &[u8; 6] = b"NIGIG1"; -const ALG_PLAINTEXT: u8 = 0x00; -const ALG_AES256_GCM: u8 = 0x01; +pub(crate) const MAGIC: &[u8; 6] = b"NIGIG2"; +const LEGACY_MAGIC: &[u8; 6] = b"NIGIG1"; +const FORMAT_VERSION: u8 = 1; +const ALG_AES256_GCM: u8 = 1; +const LEGACY_ALG_PLAINTEXT: u8 = 0; +const LEGACY_ALG_AES256_GCM: u8 = 1; const NONCE_LEN: usize = 12; const TAG_LEN: usize = 16; +const ID_LEN: usize = 16; +const HEADER_LEN: usize = 6 + 1 + 1 + ID_LEN + ID_LEN + 8 + NONCE_LEN + 8; -const KEYRING_SERVICE: &str = "nigig-site"; -const KEYRING_ACCOUNT: &str = "store-dek"; +/// SITE-02 still stores one legacy aggregate. The 64 MiB ceiling is enforced +/// before ciphertext or plaintext allocation; later repository segmentation +/// will reduce this to the plan's 16 MiB per-aggregate budget. +pub(crate) const MAX_PLAINTEXT_BYTES: usize = 64 * 1024 * 1024; +pub(crate) const MAX_ENVELOPE_BYTES: usize = HEADER_LEN + MAX_PLAINTEXT_BYTES + TAG_LEN; + +/// SP 800-38D permits at most 2^32 invocations under one key when using random +/// 96-bit IVs. This process-local guard is defense in depth; durable per-key +/// accounting/rotation remains a release blocker in the review packet. +const RANDOM_NONCE_INVOCATION_LIMIT: u64 = 1_u64 << 32; +static RANDOM_NONCE_INVOCATIONS: AtomicU64 = AtomicU64::new(0); + +pub(crate) type SecretKey = Zeroizing<[u8; 32]>; +pub(crate) type SecretBytes = Zeroizing>; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct StoreId(pub(crate) [u8; ID_LEN]); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct KeyId(pub(crate) [u8; ID_LEN]); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct EnvelopeHeader { + pub(crate) store_id: StoreId, + pub(crate) key_id: KeyId, + pub(crate) revision: u64, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct OpenedEnvelope { + pub(crate) header: EnvelopeHeader, + pub(crate) plaintext: SecretBytes, +} #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CryptoError { - KeyUnavailable, - InvalidKey, RandomUnavailable, - EncryptionFailed, + NonceInvocationLimit, + InvalidKey, + InputTooLarge, InvalidEnvelope, PlaintextMigrationRequired, + LegacyEncryptedMigrationRequired, + UnsupportedVersion, UnsupportedAlgorithm, AuthenticationFailed, + EncryptionFailed, } impl CryptoError { pub const fn support_code(self) -> &'static str { match self { - Self::KeyUnavailable => "SITE-KEY-UNAVAILABLE", - Self::InvalidKey => "SITE-KEY-INVALID", Self::RandomUnavailable => "SITE-RANDOM-UNAVAILABLE", - Self::EncryptionFailed => "SITE-ENCRYPTION-FAILED", + Self::NonceInvocationLimit => "SITE-NONCE-LIMIT", + Self::InvalidKey => "SITE-KEY-INVALID", + Self::InputTooLarge => "SITE-ENVELOPE-LIMIT", Self::InvalidEnvelope => "SITE-ENVELOPE-INVALID", Self::PlaintextMigrationRequired => "SITE-PLAINTEXT-MIGRATION-REQUIRED", + Self::LegacyEncryptedMigrationRequired => "SITE-LEGACY-ENCRYPTED-MIGRATION-REQUIRED", + Self::UnsupportedVersion => "SITE-ENVELOPE-VERSION-UNSUPPORTED", Self::UnsupportedAlgorithm => "SITE-ALGORITHM-UNSUPPORTED", Self::AuthenticationFailed => "SITE-AUTHENTICATION-FAILED", + Self::EncryptionFailed => "SITE-ENCRYPTION-FAILED", } } } impl std::fmt::Display for CryptoError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.support_code()) + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.support_code()) } } -fn unhex(s: &str) -> Option> { - if !s.len().is_multiple_of(2) { - return None; +impl std::error::Error for CryptoError {} + +pub(crate) trait SecureRandom: Send + Sync { + fn fill(&self, destination: &mut [u8]) -> Result<(), CryptoError>; +} + +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct OsRandom; + +impl SecureRandom for OsRandom { + fn fill(&self, destination: &mut [u8]) -> Result<(), CryptoError> { + getrandom::getrandom(destination).map_err(|_| CryptoError::RandomUnavailable) } - (0..s.len()) - .step_by(2) - .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()) - .collect() } #[cfg(test)] -fn test_dek_override() -> Option> { - let encoded = std::env::var("NIGIG_SITE_TEST_DEK").ok()?; - let bytes = match unhex(&encoded) { - Some(bytes) => bytes, - None => return Some(Err(CryptoError::InvalidKey)), - }; - Some(bytes.try_into().map_err(|_| CryptoError::InvalidKey)) +pub(crate) fn random_id(random: &dyn SecureRandom) -> Result<[u8; ID_LEN], CryptoError> { + let mut id = [0_u8; ID_LEN]; + random.fill(&mut id)?; + if id == [0_u8; ID_LEN] { + return Err(CryptoError::RandomUnavailable); + } + Ok(id) } -/// Load an existing key only. This deliberately has no create branch. -pub(crate) fn load_existing_dek() -> Result<[u8; 32], CryptoError> { - #[cfg(test)] - { - if std::env::var("NIGIG_SITE_NO_KEYSTORE").as_deref() == Ok("1") { - return Err(CryptoError::KeyUnavailable); - } - if let Some(result) = test_dek_override() { - return result; - } +pub(crate) fn parse_header(envelope: &[u8]) -> Result { + classify_size_and_magic(envelope)?; + if envelope[6] != FORMAT_VERSION { + return Err(CryptoError::UnsupportedVersion); + } + if envelope[7] != ALG_AES256_GCM { + return Err(CryptoError::UnsupportedAlgorithm); } - let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_ACCOUNT) - .map_err(|_| CryptoError::KeyUnavailable)?; - let encoded = entry - .get_password() - .map_err(|_| CryptoError::KeyUnavailable)?; - let bytes = unhex(&encoded).ok_or(CryptoError::InvalidKey)?; - bytes.try_into().map_err(|_| CryptoError::InvalidKey) + let store_id = StoreId(copy_array::(&envelope[8..24])?); + let key_id = KeyId(copy_array::(&envelope[24..40])?); + let revision = u64::from_be_bytes(copy_array::<8>(&envelope[40..48])?); + if store_id.0 == [0; ID_LEN] || key_id.0 == [0; ID_LEN] || revision == 0 { + return Err(CryptoError::InvalidEnvelope); + } + let declared = u64::from_be_bytes(copy_array::<8>(&envelope[60..68])?); + let declared = usize::try_from(declared).map_err(|_| CryptoError::InputTooLarge)?; + if declared > MAX_PLAINTEXT_BYTES { + return Err(CryptoError::InputTooLarge); + } + let expected = HEADER_LEN + .checked_add(declared) + .and_then(|size| size.checked_add(TAG_LEN)) + .ok_or(CryptoError::InputTooLarge)?; + if envelope.len() != expected { + return Err(CryptoError::InvalidEnvelope); + } + Ok(EnvelopeHeader { + store_id, + key_id, + revision, + }) } -pub(crate) fn encrypt_bytes(plaintext: &[u8], dek: &[u8; 32]) -> Result, CryptoError> { - let mut nonce = [0u8; NONCE_LEN]; - getrandom::getrandom(&mut nonce).map_err(|_| CryptoError::RandomUnavailable)?; - let cipher = Aes256Gcm::new_from_slice(dek).map_err(|_| CryptoError::InvalidKey)?; +fn plaintext_json_candidate(bytes: &[u8]) -> Option<&[u8]> { + let bytes = bytes.strip_prefix(&[0xef, 0xbb, 0xbf]).unwrap_or(bytes); + let offset = bytes.iter().position(|byte| !byte.is_ascii_whitespace())?; + matches!(bytes[offset], b'{' | b'[').then_some(&bytes[offset..]) +} + +fn classify_size_and_magic(envelope: &[u8]) -> Result<(), CryptoError> { + if envelope.len() > MAX_ENVELOPE_BYTES { + return Err(CryptoError::InputTooLarge); + } + if envelope.starts_with(LEGACY_MAGIC) { + if envelope.len() < LEGACY_MAGIC.len() + 1 { + return Err(CryptoError::InvalidEnvelope); + } + return match envelope[LEGACY_MAGIC.len()] { + LEGACY_ALG_PLAINTEXT => Err(CryptoError::PlaintextMigrationRequired), + LEGACY_ALG_AES256_GCM => Err(CryptoError::LegacyEncryptedMigrationRequired), + _ => Err(CryptoError::UnsupportedAlgorithm), + }; + } + if plaintext_json_candidate(envelope).is_some() { + return Err(CryptoError::PlaintextMigrationRequired); + } + if envelope.len() < HEADER_LEN + TAG_LEN || !envelope.starts_with(MAGIC) { + return Err(CryptoError::InvalidEnvelope); + } + Ok(()) +} + +fn copy_array(bytes: &[u8]) -> Result<[u8; N], CryptoError> { + bytes.try_into().map_err(|_| CryptoError::InvalidEnvelope) +} + +fn encode_header( + header: EnvelopeHeader, + nonce: &[u8; NONCE_LEN], + plaintext_len: usize, +) -> Result<[u8; HEADER_LEN], CryptoError> { + if header.store_id.0 == [0; ID_LEN] || header.key_id.0 == [0; ID_LEN] || header.revision == 0 { + return Err(CryptoError::InvalidEnvelope); + } + let plaintext_len = u64::try_from(plaintext_len).map_err(|_| CryptoError::InputTooLarge)?; + let mut bytes = [0_u8; HEADER_LEN]; + bytes[..6].copy_from_slice(MAGIC); + bytes[6] = FORMAT_VERSION; + bytes[7] = ALG_AES256_GCM; + bytes[8..24].copy_from_slice(&header.store_id.0); + bytes[24..40].copy_from_slice(&header.key_id.0); + bytes[40..48].copy_from_slice(&header.revision.to_be_bytes()); + bytes[48..60].copy_from_slice(nonce); + bytes[60..68].copy_from_slice(&plaintext_len.to_be_bytes()); + Ok(bytes) +} + +fn cipher_for(key: &SecretKey) -> Result { + if key.as_ref() == [0; 32] { + return Err(CryptoError::InvalidKey); + } + Aes256Gcm::new_from_slice(key.as_ref()).map_err(|_| CryptoError::InvalidKey) +} + +fn reserve_nonce_invocation() -> Result<(), CryptoError> { + RANDOM_NONCE_INVOCATIONS + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + (current < RANDOM_NONCE_INVOCATION_LIMIT).then_some(current + 1) + }) + .map(|_| ()) + .map_err(|_| CryptoError::NonceInvocationLimit) +} + +pub(crate) fn seal( + plaintext: &[u8], + header: EnvelopeHeader, + key: &SecretKey, + random: &dyn SecureRandom, +) -> Result, CryptoError> { + if plaintext.len() > MAX_PLAINTEXT_BYTES { + return Err(CryptoError::InputTooLarge); + } + let cipher = cipher_for(key)?; + reserve_nonce_invocation()?; + let mut nonce = [0_u8; NONCE_LEN]; + random.fill(&mut nonce)?; + let encoded_header = encode_header(header, &nonce, plaintext.len())?; let ciphertext = cipher - .encrypt(Nonce::from_slice(&nonce), plaintext) + .encrypt( + Nonce::from_slice(&nonce), + Payload { + msg: plaintext, + aad: &encoded_header, + }, + ) .map_err(|_| CryptoError::EncryptionFailed)?; - let mut out = Vec::with_capacity(MAGIC.len() + 1 + NONCE_LEN + ciphertext.len()); - out.extend_from_slice(MAGIC); - out.push(ALG_AES256_GCM); - out.extend_from_slice(&nonce); - out.extend_from_slice(&ciphertext); - Ok(out) + let capacity = HEADER_LEN + .checked_add(ciphertext.len()) + .ok_or(CryptoError::InputTooLarge)?; + let mut envelope = Vec::with_capacity(capacity); + envelope.extend_from_slice(&encoded_header); + envelope.extend_from_slice(&ciphertext); + Ok(envelope) } -pub(crate) fn decrypt_bytes(envelope: &[u8], dek: &[u8; 32]) -> Result, CryptoError> { - if envelope.len() < MAGIC.len() + 1 || &envelope[..MAGIC.len()] != MAGIC { - return Err(if envelope.first() == Some(&b'{') { - CryptoError::PlaintextMigrationRequired - } else { - CryptoError::InvalidEnvelope - }); - } - match envelope[MAGIC.len()] { - ALG_PLAINTEXT => Err(CryptoError::PlaintextMigrationRequired), - ALG_AES256_GCM => { - let rest = &envelope[MAGIC.len() + 1..]; - if rest.len() < NONCE_LEN + TAG_LEN { - return Err(CryptoError::InvalidEnvelope); - } - let cipher = Aes256Gcm::new_from_slice(dek).map_err(|_| CryptoError::InvalidKey)?; - cipher - .decrypt(Nonce::from_slice(&rest[..NONCE_LEN]), &rest[NONCE_LEN..]) - .map_err(|_| CryptoError::AuthenticationFailed) - } - _ => Err(CryptoError::UnsupportedAlgorithm), - } -} - -/// Seal bytes using an already-present key. No plaintext output is possible. -pub(crate) fn seal(json: &[u8]) -> Result, CryptoError> { - let dek = load_existing_dek()?; - encrypt_bytes(json, &dek) -} - -/// Open an encrypted envelope using an already-present key. Legacy raw JSON -/// and explicit-plaintext envelopes are preserved for future migration but are -/// not read by the application in SITE-01. -pub(crate) fn open_envelope(envelope: &[u8]) -> Result, CryptoError> { - if envelope.len() < MAGIC.len() + 1 || &envelope[..MAGIC.len()] != MAGIC { - return Err(if envelope.first() == Some(&b'{') { - CryptoError::PlaintextMigrationRequired - } else { - CryptoError::InvalidEnvelope - }); - } - match envelope[MAGIC.len()] { - ALG_PLAINTEXT => Err(CryptoError::PlaintextMigrationRequired), - ALG_AES256_GCM => { - let dek = load_existing_dek()?; - decrypt_bytes(envelope, &dek) - } - _ => Err(CryptoError::UnsupportedAlgorithm), +pub(crate) fn open(envelope: &[u8], key: &SecretKey) -> Result { + let header = parse_header(envelope)?; + let nonce = &envelope[48..60]; + let cipher = cipher_for(key)?; + let plaintext = cipher + .decrypt( + Nonce::from_slice(nonce), + Payload { + msg: &envelope[HEADER_LEN..], + aad: &envelope[..HEADER_LEN], + }, + ) + .map_err(|_| CryptoError::AuthenticationFailed)?; + if plaintext.len() > MAX_PLAINTEXT_BYTES { + return Err(CryptoError::InputTooLarge); } + Ok(OpenedEnvelope { + header, + plaintext: Zeroizing::new(plaintext), + }) } +/// Read-only compatibility extractor for explicit-consent migration tests. It +/// never writes, creates a key, or accepts encrypted legacy input without its +/// old key. Production migration remains security-review locked. #[cfg(test)] -fn plaintext_envelope(json: &[u8]) -> Vec { - let mut out = Vec::with_capacity(MAGIC.len() + 1 + json.len()); - out.extend_from_slice(MAGIC); - out.push(ALG_PLAINTEXT); - out.extend_from_slice(json); - out +pub(crate) fn expose_legacy_plaintext(envelope: &[u8]) -> Result { + if envelope.len() > MAX_PLAINTEXT_BYTES + LEGACY_MAGIC.len() + 1 { + return Err(CryptoError::InputTooLarge); + } + if let Some(json) = plaintext_json_candidate(envelope) { + return Ok(Zeroizing::new(json.to_vec())); + } + if envelope.starts_with(LEGACY_MAGIC) && envelope.get(6) == Some(&LEGACY_ALG_PLAINTEXT) { + return Ok(Zeroizing::new(envelope[7..].to_vec())); + } + Err( + if envelope.starts_with(LEGACY_MAGIC) && envelope.get(6) == Some(&LEGACY_ALG_AES256_GCM) { + CryptoError::LegacyEncryptedMigrationRequired + } else { + CryptoError::InvalidEnvelope + }, + ) +} + +/// Open the historical encrypted `NIGIG1` form with a key supplied by the +/// migration test controller. This is deliberately separate from normal open. +#[cfg(test)] +pub(crate) fn open_legacy_nigig1( + envelope: &[u8], + key: &SecretKey, +) -> Result { + if envelope.len() > MAX_ENVELOPE_BYTES { + return Err(CryptoError::InputTooLarge); + } + if !envelope.starts_with(LEGACY_MAGIC) || envelope.get(6) != Some(&LEGACY_ALG_AES256_GCM) { + return Err(CryptoError::InvalidEnvelope); + } + let rest = &envelope[7..]; + if rest.len() < NONCE_LEN + TAG_LEN { + return Err(CryptoError::InvalidEnvelope); + } + let cipher = cipher_for(key)?; + let plaintext = cipher + .decrypt(Nonce::from_slice(&rest[..NONCE_LEN]), &rest[NONCE_LEN..]) + .map_err(|_| CryptoError::AuthenticationFailed)?; + if plaintext.len() > MAX_PLAINTEXT_BYTES { + return Err(CryptoError::InputTooLarge); + } + Ok(Zeroizing::new(plaintext)) } #[cfg(test)] mod tests { use super::*; - fn test_dek() -> [u8; 32] { - [7u8; 32] + struct FixedRandom(u8); + impl SecureRandom for FixedRandom { + fn fill(&self, destination: &mut [u8]) -> Result<(), CryptoError> { + destination.fill(self.0); + Ok(()) + } + } + + struct NonceRandom([u8; NONCE_LEN]); + impl SecureRandom for NonceRandom { + fn fill(&self, destination: &mut [u8]) -> Result<(), CryptoError> { + if destination.len() != self.0.len() { + return Err(CryptoError::RandomUnavailable); + } + destination.copy_from_slice(&self.0); + Ok(()) + } + } + + struct FailedRandom; + impl SecureRandom for FailedRandom { + fn fill(&self, _destination: &mut [u8]) -> Result<(), CryptoError> { + Err(CryptoError::RandomUnavailable) + } + } + + fn key(byte: u8) -> SecretKey { + Zeroizing::new([byte; 32]) + } + + fn header() -> EnvelopeHeader { + EnvelopeHeader { + store_id: StoreId([1; 16]), + key_id: KeyId([2; 16]), + revision: 7, + } + } + + fn legacy_encrypted(plaintext: &[u8], key: &SecretKey) -> Vec { + let nonce = [3_u8; NONCE_LEN]; + let cipher = Aes256Gcm::new_from_slice(key.as_ref()).unwrap(); + let ciphertext = cipher + .encrypt(Nonce::from_slice(&nonce), plaintext) + .unwrap(); + [ + LEGACY_MAGIC.as_slice(), + &[LEGACY_ALG_AES256_GCM], + &nonce, + &ciphertext, + ] + .concat() } #[test] - fn round_trips_arbitrary_json() { - let plain = br#"{"sites":[],"reports":[{"id":"r1"}]}"#; - let envelope = encrypt_bytes(plain, &test_dek()).unwrap(); - assert_eq!(&envelope[..6], MAGIC); - assert_eq!(envelope[6], ALG_AES256_GCM); - assert_eq!(decrypt_bytes(&envelope, &test_dek()).unwrap(), plain); + fn round_trip_authenticates_identity_revision_and_length() { + let plaintext = br#"{"sites":[{"name":"Confidential Sentinel"}]}"#; + let envelope = seal(plaintext, header(), &key(7), &FixedRandom(9)).unwrap(); + assert_eq!(parse_header(&envelope).unwrap(), header()); + let opened = open(&envelope, &key(7)).unwrap(); + assert_eq!(opened.header, header()); + assert_eq!(opened.plaintext.as_slice(), plaintext); + assert!(!String::from_utf8_lossy(&envelope).contains("Confidential Sentinel")); } #[test] - fn nonces_differ_per_seal() { - let (a, b) = ( - encrypt_bytes(b"same", &test_dek()).unwrap(), - encrypt_bytes(b"same", &test_dek()).unwrap(), - ); - assert_ne!(a, b, "reused nonce would be catastrophic"); + fn envelope_matches_independent_aes_gcm_known_answer() { + fn decode_hex(encoded: &str) -> Vec { + encoded + .as_bytes() + .chunks_exact(2) + .map(|pair| { + let digit = |value: u8| match value { + b'0'..=b'9' => value - b'0', + b'a'..=b'f' => value - b'a' + 10, + _ => panic!("invalid test vector"), + }; + digit(pair[0]) << 4 | digit(pair[1]) + }) + .collect() + } + + let key = Zeroizing::new(std::array::from_fn(|index| u8::try_from(index).unwrap())); + let header = EnvelopeHeader { + store_id: StoreId(std::array::from_fn(|index| u8::try_from(index).unwrap())), + key_id: KeyId(std::array::from_fn(|index| { + u8::try_from(index + 16).unwrap() + })), + revision: 0x0102_0304_0506_0708, + }; + let nonce = std::array::from_fn(|index| u8::try_from(index + 0xa0).unwrap()); + // Generated independently with Node.js `crypto.createCipheriv`, backed + // by OpenSSL, using this fixed key/nonce/header/plaintext tuple. + let expected = decode_hex(concat!( + "4e49474947320101000102030405060708090a0b0c0d0e0f", + "101112131415161718191a1b1c1d1e1f0102030405060708", + "a0a1a2a3a4a5a6a7a8a9aaab0000000000000014", + "9d3a0a4826bf6dcd405fa5804e2e85f3409e7b6d", + "cffa6a6f134438389a37c30043091d54" + )); + let envelope = seal( + br#"{"vector":"SITE-02"}"#, + header, + &key, + &NonceRandom(nonce), + ) + .unwrap(); + assert_eq!(envelope, expected); + let opened = open(&expected, &key).unwrap(); + assert_eq!(opened.header, header); + assert_eq!(opened.plaintext.as_slice(), br#"{"vector":"SITE-02"}"#); } #[test] - fn tampered_ciphertext_is_refused() { - let mut envelope = encrypt_bytes(b"secret", &test_dek()).unwrap(); - let last = envelope.len() - 1; - envelope[last] ^= 0x01; + fn independent_random_nonces_change_ciphertext() { + let first = seal(b"same", header(), &key(7), &FixedRandom(3)).unwrap(); + let second = seal(b"same", header(), &key(7), &FixedRandom(4)).unwrap(); + assert_ne!(first, second); + assert_ne!(&first[48..60], &second[48..60]); + } + + #[test] + fn modified_header_and_ciphertext_are_authentication_failures() { + let envelope = seal(b"secret", header(), &key(7), &FixedRandom(3)).unwrap(); + for index in [16, envelope.len() - 1] { + let mut tampered = envelope.clone(); + tampered[index] ^= 1; + assert_eq!( + open(&tampered, &key(7)), + Err(CryptoError::AuthenticationFailed) + ); + } + } + + #[test] + fn every_truncated_or_extended_envelope_is_rejected() { + let envelope = seal(b"bounded parser", header(), &key(7), &FixedRandom(3)).unwrap(); + for end in 0..envelope.len() { + assert!( + parse_header(&envelope[..end]).is_err(), + "accepted prefix {end}" + ); + } + assert_eq!(parse_header(&envelope).unwrap(), header()); + let mut extended = envelope; + extended.push(0); + assert_eq!(parse_header(&extended), Err(CryptoError::InvalidEnvelope)); + } + + #[test] + fn wrong_key_is_distinct_from_invalid_structure() { + let envelope = seal(b"secret", header(), &key(7), &FixedRandom(3)).unwrap(); assert_eq!( - decrypt_bytes(&envelope, &test_dek()), + open(&envelope, &key(8)), Err(CryptoError::AuthenticationFailed) ); + assert_eq!(open(b"garbage", &key(7)), Err(CryptoError::InvalidEnvelope)); } #[test] - fn wrong_key_is_refused() { - let envelope = encrypt_bytes(b"secret", &test_dek()).unwrap(); + fn plaintext_and_legacy_encrypted_forms_are_differentiated() { assert_eq!( - decrypt_bytes(&envelope, &[9u8; 32]), - Err(CryptoError::AuthenticationFailed) - ); - } - - #[test] - fn plaintext_is_never_accepted() { - let envelope = plaintext_envelope(b"{}"); - assert_eq!( - decrypt_bytes(&envelope, &test_dek()), + parse_header(b"{}"), Err(CryptoError::PlaintextMigrationRequired) ); assert_eq!( - decrypt_bytes(b"{}", &test_dek()), + parse_header(b"NIGIG1\x00{}"), Err(CryptoError::PlaintextMigrationRequired) ); + assert_eq!( + parse_header(b"\xef\xbb\xbf \r\n\t{}"), + Err(CryptoError::PlaintextMigrationRequired) + ); + assert_eq!( + expose_legacy_plaintext(b"\xef\xbb\xbf \n{}") + .unwrap() + .as_slice(), + b"{}" + ); + let old = legacy_encrypted(b"{}", &key(7)); + assert_eq!( + parse_header(&old), + Err(CryptoError::LegacyEncryptedMigrationRequired) + ); + assert_eq!(open_legacy_nigig1(&old, &key(7)).unwrap().as_slice(), b"{}"); + } + + #[test] + fn explicit_plaintext_extractor_rejects_encrypted_legacy_input() { + assert_eq!(expose_legacy_plaintext(b"{}").unwrap().as_slice(), b"{}"); + assert_eq!( + expose_legacy_plaintext(b"NIGIG1\x00{}").unwrap().as_slice(), + b"{}" + ); + assert_eq!( + expose_legacy_plaintext(&legacy_encrypted(b"{}", &key(7))), + Err(CryptoError::LegacyEncryptedMigrationRequired) + ); } #[test] - fn garbage_and_unknown_alg_rejected() { + fn unsupported_version_algorithm_and_zero_ids_are_rejected() { + let envelope = seal(b"{}", header(), &key(7), &FixedRandom(3)).unwrap(); + let mut version = envelope.clone(); + version[6] = 2; + assert_eq!(parse_header(&version), Err(CryptoError::UnsupportedVersion)); + let mut algorithm = envelope.clone(); + algorithm[7] = 99; assert_eq!( - decrypt_bytes(b"", &test_dek()), - Err(CryptoError::InvalidEnvelope) - ); - assert_eq!( - decrypt_bytes(b"NOTMAGIC{}", &test_dek()), - Err(CryptoError::InvalidEnvelope) - ); - let mut envelope = plaintext_envelope(b"{}"); - envelope[6] = 0x7f; - assert_eq!( - decrypt_bytes(&envelope, &test_dek()), + parse_header(&algorithm), Err(CryptoError::UnsupportedAlgorithm) ); + let mut zero_id = envelope; + zero_id[8..24].fill(0); + assert_eq!(parse_header(&zero_id), Err(CryptoError::InvalidEnvelope)); + } + + #[test] + fn declared_size_is_bounded_before_decryption_or_allocation() { + let mut envelope = vec![0_u8; HEADER_LEN + TAG_LEN]; + envelope[..6].copy_from_slice(MAGIC); + envelope[6] = FORMAT_VERSION; + envelope[7] = ALG_AES256_GCM; + envelope[8..24].fill(1); + envelope[24..40].fill(2); + envelope[40..48].copy_from_slice(&1_u64.to_be_bytes()); + envelope[60..68].copy_from_slice( + &u64::try_from(MAX_PLAINTEXT_BYTES + 1) + .unwrap() + .to_be_bytes(), + ); + assert_eq!(parse_header(&envelope), Err(CryptoError::InputTooLarge)); + } + + #[test] + fn random_failure_never_emits_an_envelope() { + assert_eq!(random_id(&FixedRandom(5)).unwrap(), [5; 16]); + assert_eq!( + random_id(&FixedRandom(0)), + Err(CryptoError::RandomUnavailable) + ); + assert_eq!( + seal(b"{}", header(), &key(7), &FailedRandom), + Err(CryptoError::RandomUnavailable) + ); + } + + #[test] + fn all_zero_key_material_is_rejected_before_use() { + assert_eq!( + seal(b"{}", header(), &key(0), &FailedRandom), + Err(CryptoError::InvalidKey) + ); + let envelope = seal(b"{}", header(), &key(7), &FixedRandom(3)).unwrap(); + assert_eq!(open(&envelope, &key(0)), Err(CryptoError::InvalidKey)); + } + + #[test] + fn invalid_header_cannot_be_sealed() { + let mut invalid = header(); + invalid.revision = 0; + assert_eq!( + seal(b"{}", invalid, &key(7), &FixedRandom(3)), + Err(CryptoError::InvalidEnvelope) + ); } } diff --git a/crates/apps/nigig-site/src/lib.rs b/crates/apps/nigig-site/src/lib.rs index 3d04306..8f7eea7 100644 --- a/crates/apps/nigig-site/src/lib.rs +++ b/crates/apps/nigig-site/src/lib.rs @@ -10,19 +10,20 @@ use makepad_widgets::ScriptVm; mod ai_refine; pub mod containment; mod crypto; -#[cfg(test)] +#[cfg(all(test, target_os = "linux"))] pub mod doc_export; pub mod domain; -#[cfg(test)] +#[cfg(all(test, target_os = "linux"))] pub mod gif; -#[cfg(test)] +#[cfg(all(test, target_os = "linux"))] pub mod ocr; -#[cfg(test)] +#[cfg(all(test, target_os = "linux"))] pub mod report_pdf; +pub(crate) mod repository; pub mod scheduler; pub mod site_frame; pub mod store; -#[cfg(test)] +#[cfg(all(test, target_os = "linux"))] pub mod video; pub fn script_mod(vm: &mut ScriptVm) { diff --git a/crates/apps/nigig-site/src/main.rs b/crates/apps/nigig-site/src/main.rs index 3c88a86..0f8ff9a 100644 --- a/crates/apps/nigig-site/src/main.rs +++ b/crates/apps/nigig-site/src/main.rs @@ -32,6 +32,11 @@ script_mod! { meetings_page := mod.widgets.MeetingsPage { visible: false } more_page := mod.widgets.MoreHubPage { visible: false } } + persistence_banner := Label { + width: Fill, text: "Encrypted repository health unavailable" + margin: Inset{left: 12, right: 12, top: 4, bottom: 4} + draw_text +: { color: #x92400E text_style: theme.font_bold { font_size: 9.0 } } + } bottom_nav := View { width: Fill, height: Fit root_nav := mod.widgets.SiteActionBar {} @@ -56,6 +61,7 @@ script_mod! { draw_bg +: { color: #xFFFFFF border_radius: 14.0 border_size: 1.0 border_color: #xFCA5A5 } recovery_message := Label { width: Fill, text: "The original store is preserved and all writes are blocked." draw_text +: { color: #x334155 text_style: theme.font_regular { font_size: 12.0 } } } support_code := Label { width: Fill, text: "" draw_text +: { color: #x991B1B text_style: theme.font_bold { font_size: 11.0 } } } + persistence_health := Label { width: Fill, text: "Accepted revision 0; durable revision 0; no unsaved changes." draw_text +: { color: #x92400E text_style: theme.font_bold { font_size: 10.0 } } } recovery_detail := Label { width: Fill, text: "Do not delete, rename, or replace the store file." draw_text +: { color: #x64748B text_style: theme.font_regular { font_size: 11.0 } } } } guide_btn := Button { width: Fill, height: 44, text: "Show recovery guidance" draw_bg +: { color: #x0F172A border_radius: 12.0 } draw_text +: { color: #xFFFFFF text_style: theme.font_bold { font_size: 11.0 } } } @@ -78,9 +84,25 @@ pub struct SiteStandaloneApp { current: usize, #[rust] store_access: Option, + #[rust] + store_health: Option, } impl ScriptHook for SiteStandaloneApp {} + +impl Drop for SiteStandaloneApp { + fn drop(&mut self) { + if !nigig_site::store::SiteStore::flush_and_shutdown(5_000) { + // Content-free operational signal only; never print paths, keys, + // serialized records, or decrypted error details. + eprintln!( + "graceful persistence drain failed: {}", + nigig_site::store::SiteStore::access_state().support_code() + ); + } + } +} + impl MatchEvent for SiteStandaloneApp { fn handle_startup(&mut self, cx: &mut Cx) { // Opening the app is read-only. Existing encrypted bytes remain @@ -171,10 +193,19 @@ pub fn nav_index(nav: &nigig_site::site_frame::action_bar::SiteNavAction) -> Opt impl SiteStandaloneApp { fn apply_store_access(&mut self, cx: &mut Cx, access: nigig_site::store::StoreAccess) { - if self.store_access == Some(access) { + let health = nigig_site::store::SiteStore::persistence_health(); + if self.store_access == Some(access) && self.store_health == Some(health) { return; } self.store_access = Some(access); + self.store_health = Some(health); + let health_line = health.status_line(); + self.ui + .label(cx, ids!(root.app_shell.persistence_banner)) + .set_text(cx, &health_line); + self.ui + .label(cx, ids!(root.recovery_page.persistence_health)) + .set_text(cx, &health_line); let ready = access.permits_confidential_writes(); self.ui .view(cx, ids!(root.app_shell)) diff --git a/crates/apps/nigig-site/src/repository.rs b/crates/apps/nigig-site/src/repository.rs new file mode 100644 index 0000000..deee10e --- /dev/null +++ b/crates/apps/nigig-site/src/repository.rs @@ -0,0 +1,2805 @@ +//! Recoverable, authenticated and atomic repository primitives for SITE-02. +//! +//! Normal open is lookup-only: it parses the envelope identity before asking +//! the native credential store for that exact key. It never creates a key, +//! migrates data, repairs files, or seeds records. Publication uses encrypted +//! temporary/rollback files and verifies the canonical ciphertext before the +//! prior revision is removed. + +use crate::crypto::{self, EnvelopeHeader, KeyId, OsRandom, SecretKey, SecureRandom, StoreId}; +use serde::de::DeserializeOwned; +use serde::Serialize; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use zeroize::Zeroizing; + +const KEYRING_SERVICE: &str = "nigig-site.repository.v2"; +#[cfg(not(test))] +const COMMIT_LOCK_TIMEOUT: Duration = Duration::from_secs(5); +#[cfg(test)] +const COMMIT_LOCK_TIMEOUT: Duration = Duration::from_millis(500); +static FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RepositoryFailure { + PathUnavailable, + EmptyFile, + PermissionDenied, + IoFailure, + SymlinkRejected, + InterruptedPublication, + KeyStoreUnavailable, + KeyMissing, + KeyInvalid, + RotatedKeyUnavailable, + AuthenticationFailed, + PlaintextMigrationRequired, + LegacyEncryptedMigrationRequired, + InvalidEnvelope, + UnsupportedVersion, + UnsupportedAlgorithm, + InputTooLarge, + MalformedJson, + OlderSchemaVersion, + FutureSchemaVersion, + SerializationFailed, + RandomUnavailable, + NonceInvocationLimit, + EncryptionFailed, + RevisionConflict, + RepositoryBusy, + TempCreateFailed, + #[cfg_attr(not(unix), allow(dead_code))] + PermissionSetFailed, + WriteFailed, + FlushFailed, + FileSyncFailed, + RenameFailed, + #[cfg_attr(not(unix), allow(dead_code))] + DirectorySyncFailed, + CanonicalReadbackFailed, + RollbackFailed, + WriterClosed, + ShutdownTimeout, + #[cfg(test)] + MigrationConsentRequired, + SecurityReviewRequired, +} + +impl RepositoryFailure { + pub const fn support_code(self) -> &'static str { + match self { + Self::PathUnavailable => "SITE-REPOSITORY-PATH", + Self::EmptyFile => "SITE-STORE-EMPTY", + Self::PermissionDenied => "SITE-STORE-PERMISSION", + Self::IoFailure => "SITE-STORE-IO", + Self::SymlinkRejected => "SITE-REPOSITORY-SYMLINK", + Self::InterruptedPublication => "SITE-REPOSITORY-INTERRUPTED", + Self::KeyStoreUnavailable => "SITE-KEYSTORE-UNAVAILABLE", + Self::KeyMissing => "SITE-KEY-MISSING", + Self::KeyInvalid => "SITE-KEY-INVALID", + Self::RotatedKeyUnavailable => "SITE-ROTATED-KEY-UNAVAILABLE", + Self::AuthenticationFailed => "SITE-STORE-AUTHENTICATION", + Self::PlaintextMigrationRequired => "SITE-STORE-PLAINTEXT-MIGRATION", + Self::LegacyEncryptedMigrationRequired => "SITE-STORE-LEGACY-MIGRATION", + Self::InvalidEnvelope => "SITE-STORE-ENVELOPE", + Self::UnsupportedVersion => "SITE-STORE-FUTURE-ENVELOPE", + Self::UnsupportedAlgorithm => "SITE-STORE-ALGORITHM", + Self::InputTooLarge => "SITE-STORE-LIMIT", + Self::MalformedJson => "SITE-STORE-JSON", + Self::OlderSchemaVersion => "SITE-STORE-OLDER-VERSION-MIGRATION", + Self::FutureSchemaVersion => "SITE-STORE-FUTURE-VERSION", + Self::SerializationFailed => "SITE-STORE-SERIALIZATION", + Self::RandomUnavailable => "SITE-RANDOM-UNAVAILABLE", + Self::NonceInvocationLimit => "SITE-NONCE-LIMIT", + Self::EncryptionFailed => "SITE-STORE-ENCRYPTION", + Self::RevisionConflict => "SITE-STORE-REVISION-CONFLICT", + Self::RepositoryBusy => "SITE-STORE-BUSY", + Self::TempCreateFailed => "SITE-STORE-CREATE", + Self::PermissionSetFailed => "SITE-STORE-PERMISSIONS", + Self::WriteFailed => "SITE-STORE-WRITE", + Self::FlushFailed => "SITE-STORE-FLUSH", + Self::FileSyncFailed => "SITE-STORE-FILE-SYNC", + Self::RenameFailed => "SITE-STORE-RENAME", + Self::DirectorySyncFailed => "SITE-STORE-DIRECTORY-SYNC", + Self::CanonicalReadbackFailed => "SITE-STORE-READBACK", + Self::RollbackFailed => "SITE-STORE-ROLLBACK", + Self::WriterClosed => "SITE-WRITER-CLOSED", + Self::ShutdownTimeout => "SITE-WRITER-SHUTDOWN-TIMEOUT", + #[cfg(test)] + Self::MigrationConsentRequired => "SITE-MIGRATION-CONSENT-REQUIRED", + Self::SecurityReviewRequired => "SITE-02-SECURITY-REVIEW-REQUIRED", + } + } +} + +impl std::fmt::Display for RepositoryFailure { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.support_code()) + } +} + +impl std::error::Error for RepositoryFailure {} + +fn map_crypto(error: crypto::CryptoError) -> RepositoryFailure { + match error { + crypto::CryptoError::RandomUnavailable => RepositoryFailure::RandomUnavailable, + crypto::CryptoError::NonceInvocationLimit => RepositoryFailure::NonceInvocationLimit, + crypto::CryptoError::InvalidKey => RepositoryFailure::KeyInvalid, + crypto::CryptoError::InputTooLarge => RepositoryFailure::InputTooLarge, + crypto::CryptoError::InvalidEnvelope => RepositoryFailure::InvalidEnvelope, + crypto::CryptoError::PlaintextMigrationRequired => { + RepositoryFailure::PlaintextMigrationRequired + } + crypto::CryptoError::LegacyEncryptedMigrationRequired => { + RepositoryFailure::LegacyEncryptedMigrationRequired + } + crypto::CryptoError::UnsupportedVersion => RepositoryFailure::UnsupportedVersion, + crypto::CryptoError::UnsupportedAlgorithm => RepositoryFailure::UnsupportedAlgorithm, + crypto::CryptoError::AuthenticationFailed => RepositoryFailure::AuthenticationFailed, + crypto::CryptoError::EncryptionFailed => RepositoryFailure::EncryptionFailed, + } +} + +fn map_read_io(error: &std::io::Error) -> RepositoryFailure { + if error.kind() == std::io::ErrorKind::PermissionDenied { + RepositoryFailure::PermissionDenied + } else { + RepositoryFailure::IoFailure + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum KeyLookupFailure { + Unavailable, + Missing, + Invalid, + RotatedKeyUnavailable, +} + +pub(crate) trait KeyProvider: Send + Sync { + fn load(&self, store_id: StoreId, key_id: KeyId) -> Result; +} + +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct NativeKeyProvider; + +impl NativeKeyProvider { + fn account(store_id: StoreId, key_id: KeyId) -> String { + fn append_hex(output: &mut String, bytes: &[u8]) { + const HEX: &[u8; 16] = b"0123456789abcdef"; + for byte in bytes { + output.push(char::from(HEX[usize::from(byte >> 4)])); + output.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + } + let mut account = String::with_capacity(16 * 4 + 1); + append_hex(&mut account, &store_id.0); + account.push(':'); + append_hex(&mut account, &key_id.0); + account + } + + fn entry(store_id: StoreId, key_id: KeyId) -> Result { + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] + use keyring_core::api::CredentialStoreApi; + + let account = Self::account(store_id, key_id); + #[cfg(target_os = "linux")] + let store = zbus_secret_service_keyring_store::Store::new() + .map_err(|_| KeyLookupFailure::Unavailable)?; + #[cfg(target_os = "macos")] + let store = apple_native_keyring_store::keychain::Store::new() + .map_err(|_| KeyLookupFailure::Unavailable)?; + #[cfg(target_os = "windows")] + let store = windows_native_keyring_store::Store::new() + .map_err(|_| KeyLookupFailure::Unavailable)?; + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + drop(account); + return Err(KeyLookupFailure::Unavailable); + } + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] + { + if !matches!( + store.persistence(), + keyring_core::api::CredentialPersistence::UntilDelete + ) { + return Err(KeyLookupFailure::Unavailable); + } + store + .build(KEYRING_SERVICE, &account, None) + .map_err(|_| KeyLookupFailure::Unavailable) + } + } + + fn map_keyring(error: keyring_core::Error) -> KeyLookupFailure { + match error { + keyring_core::Error::NoEntry => KeyLookupFailure::Missing, + keyring_core::Error::BadEncoding(_) + | keyring_core::Error::BadDataFormat(_, _) + | keyring_core::Error::BadStoreFormat(_) + | keyring_core::Error::Ambiguous(_) => KeyLookupFailure::Invalid, + _ => KeyLookupFailure::Unavailable, + } + } + + fn decode_record(bytes: &[u8]) -> Result { + // Native key records are versioned: 0x01 + 32-byte active DEK, or + // 0x02 + 32-byte retired DEK marker. A retired record is deliberately + // distinguishable from deletion and vault unavailability. + let (state, material) = bytes.split_first().ok_or(KeyLookupFailure::Invalid)?; + if *state == 0x02 && material.len() == 32 { + return Err(KeyLookupFailure::RotatedKeyUnavailable); + } + if *state != 0x01 { + return Err(KeyLookupFailure::Invalid); + } + let key: [u8; 32] = material.try_into().map_err(|_| KeyLookupFailure::Invalid)?; + if key == [0; 32] { + return Err(KeyLookupFailure::Invalid); + } + Ok(Zeroizing::new(key)) + } +} + +impl KeyProvider for NativeKeyProvider { + fn load(&self, store_id: StoreId, key_id: KeyId) -> Result { + let entry = Self::entry(store_id, key_id)?; + #[cfg(target_os = "windows")] + { + // The adapter defaults new records to Enterprise persistence, which + // can roam a DEK to another computer. Reject anything except an + // explicitly local record before retrieving its secret. + let attributes = entry.get_attributes().map_err(Self::map_keyring)?; + if !matches!(attributes.get("persistence"), Some(value) if value == "Local") { + return Err(KeyLookupFailure::Invalid); + } + } + let bytes = Zeroizing::new(entry.get_secret().map_err(Self::map_keyring)?); + Self::decode_record(&bytes) + } +} + +#[cfg(test)] +#[derive(Clone, Copy, Debug, Default)] +struct EnvironmentTestKeyProvider; + +#[cfg(test)] +impl KeyProvider for EnvironmentTestKeyProvider { + fn load(&self, _store_id: StoreId, _key_id: KeyId) -> Result { + if let Ok(failure) = std::env::var("NIGIG_SITE_TEST_KEY_FAILURE") { + return Err(match failure.as_str() { + "missing" => KeyLookupFailure::Missing, + "invalid" => KeyLookupFailure::Invalid, + "rotated" => KeyLookupFailure::RotatedKeyUnavailable, + _ => KeyLookupFailure::Unavailable, + }); + } + let encoded = + std::env::var("NIGIG_SITE_TEST_DEK").map_err(|_| KeyLookupFailure::Unavailable)?; + if encoded.len() != 64 { + return Err(KeyLookupFailure::Invalid); + } + let mut key = [0_u8; 32]; + for (index, byte) in key.iter_mut().enumerate() { + let start = index * 2; + *byte = u8::from_str_radix(&encoded[start..start + 2], 16) + .map_err(|_| KeyLookupFailure::Invalid)?; + } + Ok(Zeroizing::new(key)) + } +} + +fn map_key_lookup(error: KeyLookupFailure) -> RepositoryFailure { + match error { + KeyLookupFailure::Unavailable => RepositoryFailure::KeyStoreUnavailable, + KeyLookupFailure::Missing => RepositoryFailure::KeyMissing, + KeyLookupFailure::Invalid => RepositoryFailure::KeyInvalid, + KeyLookupFailure::RotatedKeyUnavailable => RepositoryFailure::RotatedKeyUnavailable, + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FaultStage { + KeyLookup, + RandomGeneration, + Serialization, + Encryption, + TempCreate, + Permissions, + Write, + Flush, + FileSync, + Rename, + DirectorySync, + Readback, + Shutdown, +} + +pub(crate) trait FaultInjector: Send + Sync { + fn check(&self, stage: FaultStage) -> Result<(), RepositoryFailure>; +} + +#[derive(Clone, Copy, Debug, Default)] +struct NoFaults; + +impl FaultInjector for NoFaults { + fn check(&self, _stage: FaultStage) -> Result<(), RepositoryFailure> { + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct RepositoryMetadata { + pub(crate) store_id: StoreId, + pub(crate) key_id: KeyId, + pub(crate) revision: u64, +} + +impl From for RepositoryMetadata { + fn from(header: EnvelopeHeader) -> Self { + Self { + store_id: header.store_id, + key_id: header.key_id, + revision: header.revision, + } + } +} + +impl RepositoryMetadata { + fn header_at(self, revision: u64) -> EnvelopeHeader { + EnvelopeHeader { + store_id: self.store_id, + key_id: self.key_id, + revision, + } + } +} + +#[derive(Debug)] +pub(crate) struct RepositoryDocument { + pub(crate) value: T, + pub(crate) metadata: RepositoryMetadata, +} + +#[derive(Debug)] +pub(crate) enum RepositoryOpen { + Absent, + Open(RepositoryDocument), +} + +#[derive(serde::Deserialize)] +struct SchemaVersionProbe { + version: u64, +} + +#[cfg(test)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum MigrationConsent { + Denied, + Explicit, +} + +#[cfg(test)] +trait LegacyKeyProvider: Send + Sync { + fn load_legacy_key(&self) -> Result; +} + +#[cfg(test)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct MigrationOutcome { + metadata: RepositoryMetadata, + original_preserved: bool, +} + +#[derive(Clone)] +pub(crate) struct SiteRepository { + path: PathBuf, + keys: Arc, + random: Arc, + faults: Arc, +} + +struct CommitLock(std::fs::File); + +impl Drop for CommitLock { + fn drop(&mut self) { + drop(self.0.unlock()); + } +} + +impl SiteRepository { + pub(crate) fn native(path: PathBuf) -> Self { + Self { + path, + keys: Arc::new(NativeKeyProvider), + random: Arc::new(OsRandom), + faults: Arc::new(NoFaults), + } + } + + pub(crate) fn runtime(path: PathBuf) -> Self { + #[cfg(test)] + if std::env::var_os("NIGIG_SITE_TEST_DEK").is_some() + || std::env::var_os("NIGIG_SITE_TEST_KEY_FAILURE").is_some() + { + return Self { + path, + keys: Arc::new(EnvironmentTestKeyProvider), + random: Arc::new(OsRandom), + faults: Arc::new(NoFaults), + }; + } + Self::native(path) + } + + #[cfg(test)] + fn injected( + path: PathBuf, + keys: Arc, + random: Arc, + faults: Arc, + ) -> Self { + Self { + path, + keys, + random, + faults, + } + } + + #[cfg(test)] + pub(crate) fn path(&self) -> &Path { + &self.path + } + + #[cfg(test)] + pub(crate) fn open_json( + &self, + ) -> Result, RepositoryFailure> { + self.open_json_with(|plaintext| { + serde_json::from_slice(plaintext).map_err(|_| RepositoryFailure::MalformedJson) + }) + } + + pub(crate) fn open_versioned_json( + &self, + expected_version: u32, + ) -> Result, RepositoryFailure> { + self.open_json_with(|plaintext| { + // Probe only the non-confidential scalar schema version first. The + // full parser is intentionally deferred so a historical or future + // shape is classified without accepting or discarding its fields. + let probe: SchemaVersionProbe = + serde_json::from_slice(plaintext).map_err(|_| RepositoryFailure::MalformedJson)?; + match probe.version.cmp(&u64::from(expected_version)) { + std::cmp::Ordering::Less => Err(RepositoryFailure::OlderSchemaVersion), + std::cmp::Ordering::Greater => Err(RepositoryFailure::FutureSchemaVersion), + std::cmp::Ordering::Equal => { + serde_json::from_slice(plaintext).map_err(|_| RepositoryFailure::MalformedJson) + } + } + }) + } + + fn open_json_with( + &self, + decode: impl FnOnce(&[u8]) -> Result, + ) -> Result, RepositoryFailure> { + self.reject_publication_artifacts()?; + match self.read_canonical()? { + None => Ok(RepositoryOpen::Absent), + Some(envelope) => { + let header = crypto::parse_header(&envelope).map_err(map_crypto)?; + self.faults.check(FaultStage::KeyLookup)?; + let key = self + .keys + .load(header.store_id, header.key_id) + .map_err(map_key_lookup)?; + let opened = crypto::open(&envelope, &key).map_err(map_crypto)?; + let value = decode(&opened.plaintext)?; + Ok(RepositoryOpen::Open(RepositoryDocument { + value, + metadata: opened.header.into(), + })) + } + } + } + + fn read_canonical(&self) -> Result, RepositoryFailure> { + reject_symlink(&self.path)?; + let mut file = match open_read_no_follow(&self.path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(map_read_io(&error)), + }; + let metadata = file.metadata().map_err(|error| map_read_io(&error))?; + if metadata_is_link_like(&metadata) { + return Err(RepositoryFailure::SymlinkRejected); + } + if !metadata.file_type().is_file() { + return Err(RepositoryFailure::IoFailure); + } + self.validate_canonical_permissions(&file, &metadata)?; + if metadata.len() == 0 { + return Err(RepositoryFailure::EmptyFile); + } + if metadata.len() > u64::try_from(crypto::MAX_ENVELOPE_BYTES).unwrap_or(u64::MAX) { + return Err(RepositoryFailure::InputTooLarge); + } + let mut envelope = Vec::with_capacity( + usize::try_from(metadata.len()) + .unwrap_or(0) + .min(crypto::MAX_ENVELOPE_BYTES), + ); + Read::by_ref(&mut file) + .take(u64::try_from(crypto::MAX_ENVELOPE_BYTES + 1).unwrap_or(u64::MAX)) + .read_to_end(&mut envelope) + .map_err(|error| map_read_io(&error))?; + if envelope.len() > crypto::MAX_ENVELOPE_BYTES { + return Err(RepositoryFailure::InputTooLarge); + } + Ok(Some(Zeroizing::new(envelope))) + } + + /// Commit a newer encrypted revision. Success means canonical readback was + /// authenticated and the containing directory was synchronized where the + /// platform exposes directory handles. + pub(crate) fn commit_json( + &self, + metadata: &mut RepositoryMetadata, + revision: u64, + value: &T, + ) -> Result { + if revision <= metadata.revision { + return Err(RepositoryFailure::RevisionConflict); + } + self.validate_managed_path(true)?; + self.reject_publication_artifacts()?; + let _commit_lock = self.acquire_commit_lock()?; + // A prior process may have died while this writer waited for the lock. + // Never publish through unresolved temp/rollback evidence. + self.reject_publication_artifacts()?; + let current = self.read_expected_current(*metadata)?; + self.faults.check(FaultStage::KeyLookup)?; + let key = self + .keys + .load(metadata.store_id, metadata.key_id) + .map_err(map_key_lookup)?; + if let Some(envelope) = current { + // A matching unauthenticated header is not permission to overwrite + // corrupted ciphertext. Authenticate the exact current revision + // under the process lock before creating any publication artifact. + drop(crypto::open(&envelope, &key).map_err(map_crypto)?); + } + self.faults.check(FaultStage::Serialization)?; + let plaintext = Zeroizing::new( + serde_json::to_vec(value).map_err(|_| RepositoryFailure::SerializationFailed)?, + ); + self.faults.check(FaultStage::RandomGeneration)?; + self.faults.check(FaultStage::Encryption)?; + let envelope = crypto::seal( + &plaintext, + metadata.header_at(revision), + &key, + self.random.as_ref(), + ) + .map_err(map_crypto)?; + self.publish_envelope(&envelope, metadata.header_at(revision), &key)?; + metadata.revision = revision; + Ok(revision) + } + + fn validate_canonical_permissions( + &self, + file: &std::fs::File, + metadata: &std::fs::Metadata, + ) -> Result<(), RepositoryFailure> { + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let parent = self + .path + .parent() + .ok_or(RepositoryFailure::PathUnavailable)?; + let parent_metadata = std::fs::metadata(parent).map_err(|error| map_read_io(&error))?; + let file_is_private = metadata.permissions().mode() & 0o077 == 0; + let parent_is_not_shared_writable = parent_metadata.permissions().mode() & 0o022 == 0; + if !file_is_private + || !parent_is_not_shared_writable + || metadata.nlink() != 1 + || metadata.uid() != parent_metadata.uid() + { + return Err(RepositoryFailure::PermissionDenied); + } + } + #[cfg(windows)] + if windows_link_count(file)? != 1 { + return Err(RepositoryFailure::PermissionDenied); + } + #[cfg(not(windows))] + let _ = file; + #[cfg(not(unix))] + let _ = metadata; + Ok(()) + } + + fn acquire_commit_lock(&self) -> Result { + let parent = self + .path + .parent() + .ok_or(RepositoryFailure::PathUnavailable)?; + let stem = managed_stem(&self.path)?; + let lock_path = parent.join(format!(".{stem}.lock")); + reject_symlink(&lock_path)?; + let mut options = std::fs::OpenOptions::new(); + options.read(true).write(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options + .mode(0o600) + .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(0x0020_0000); // FILE_FLAG_OPEN_REPARSE_POINT + } + let file = options + .open(&lock_path) + .map_err(|error| map_read_io(&error))?; + let lock_metadata = file.metadata().map_err(|error| map_read_io(&error))?; + if metadata_is_link_like(&lock_metadata) || !lock_metadata.file_type().is_file() { + return Err(RepositoryFailure::SymlinkRejected); + } + #[cfg(windows)] + if windows_link_count(&file)? != 1 { + return Err(RepositoryFailure::SymlinkRejected); + } + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let parent_metadata = std::fs::metadata(parent).map_err(|error| map_read_io(&error))?; + if lock_metadata.nlink() != 1 || lock_metadata.uid() != parent_metadata.uid() { + return Err(RepositoryFailure::SymlinkRejected); + } + file.set_permissions(std::fs::Permissions::from_mode(0o600)) + .map_err(|_| RepositoryFailure::PermissionSetFailed)?; + } + let deadline = Instant::now() + COMMIT_LOCK_TIMEOUT; + loop { + match file.try_lock() { + Ok(()) => return Ok(CommitLock(file)), + Err(std::fs::TryLockError::WouldBlock) => { + if Instant::now() >= deadline { + return Err(RepositoryFailure::RepositoryBusy); + } + std::thread::sleep(Duration::from_millis(10)); + } + Err(std::fs::TryLockError::Error(_)) => { + return Err(RepositoryFailure::RepositoryBusy); + } + } + } + } + + fn read_expected_current( + &self, + expected: RepositoryMetadata, + ) -> Result, RepositoryFailure> { + match self.read_canonical()? { + None if expected.revision == 0 => Ok(None), + None => Err(RepositoryFailure::RevisionConflict), + Some(envelope) => { + let actual = + RepositoryMetadata::from(crypto::parse_header(&envelope).map_err(map_crypto)?); + if actual == expected { + Ok(Some(envelope)) + } else { + Err(RepositoryFailure::RevisionConflict) + } + } + } + } + + fn validate_managed_path( + &self, + require_existing_parent: bool, + ) -> Result<(), RepositoryFailure> { + let parent = self + .path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .ok_or(RepositoryFailure::PathUnavailable)?; + reject_symlink_chain(parent)?; + reject_symlink(&self.path)?; + if require_existing_parent && !parent.is_dir() { + return Err(RepositoryFailure::PathUnavailable); + } + Ok(()) + } + + fn reject_publication_artifacts(&self) -> Result<(), RepositoryFailure> { + self.validate_managed_path(false)?; + let Some(parent) = self.path.parent() else { + return Err(RepositoryFailure::PathUnavailable); + }; + if !parent.exists() { + return Ok(()); + } + let stem = managed_stem(&self.path)?; + let prefix = format!(".{stem}.site02-"); + let entries = std::fs::read_dir(parent).map_err(|error| map_read_io(&error))?; + for entry in entries { + let entry = entry.map_err(|error| map_read_io(&error))?; + let name = entry.file_name(); + if name.to_string_lossy().starts_with(&prefix) { + return Err(RepositoryFailure::InterruptedPublication); + } + } + Ok(()) + } + + fn publish_envelope( + &self, + envelope: &[u8], + expected: EnvelopeHeader, + key: &SecretKey, + ) -> Result<(), RepositoryFailure> { + let parent = self + .path + .parent() + .ok_or(RepositoryFailure::PathUnavailable)?; + let sequence = FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let stem = managed_stem(&self.path)?; + let tmp = parent.join(format!( + ".{stem}.site02-tmp-{}-{sequence}", + std::process::id() + )); + let backup = parent.join(format!( + ".{stem}.site02-backup-{}-{sequence}", + std::process::id() + )); + reject_symlink(&tmp)?; + reject_symlink(&backup)?; + + let result = self.publish_inner(envelope, expected, key, &tmp, &backup); + if result.is_err() { + let _ = std::fs::remove_file(&tmp); + } + result + } + + fn publish_inner( + &self, + envelope: &[u8], + expected: EnvelopeHeader, + key: &SecretKey, + tmp: &Path, + backup: &Path, + ) -> Result<(), RepositoryFailure> { + let parent = self + .path + .parent() + .ok_or(RepositoryFailure::PathUnavailable)?; + self.faults.check(FaultStage::TempCreate)?; + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options + .mode(0o600) + .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW); + } + let mut file = options + .open(tmp) + .map_err(|_| RepositoryFailure::TempCreateFailed)?; + + self.faults.check(FaultStage::Permissions)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(std::fs::Permissions::from_mode(0o600)) + .map_err(|_| RepositoryFailure::PermissionSetFailed)?; + } + + self.faults.check(FaultStage::Write)?; + file.write_all(envelope) + .map_err(|_| RepositoryFailure::WriteFailed)?; + self.faults.check(FaultStage::Flush)?; + file.flush().map_err(|_| RepositoryFailure::FlushFailed)?; + self.faults.check(FaultStage::FileSync)?; + file.sync_all() + .map_err(|_| RepositoryFailure::FileSyncFailed)?; + drop(file); + + let had_original = self.path.exists(); + self.faults.check(FaultStage::Rename)?; + if had_original { + std::fs::rename(&self.path, backup).map_err(|_| RepositoryFailure::RenameFailed)?; + } + if let Err(_error) = std::fs::rename(tmp, &self.path) { + if had_original && std::fs::rename(backup, &self.path).is_err() { + return Err(RepositoryFailure::RollbackFailed); + } + return Err(RepositoryFailure::RenameFailed); + } + + let after_publish = (|| { + self.faults.check(FaultStage::DirectorySync)?; + sync_directory(parent)?; + self.faults.check(FaultStage::Readback)?; + let readback = self + .read_canonical()? + .ok_or(RepositoryFailure::CanonicalReadbackFailed)?; + let opened = crypto::open(&readback, key) + .map_err(|_| RepositoryFailure::CanonicalReadbackFailed)?; + if opened.header != expected || readback.as_slice() != envelope { + return Err(RepositoryFailure::CanonicalReadbackFailed); + } + Ok(()) + })(); + + if let Err(failure) = after_publish { + if rollback_publication(&self.path, backup, had_original, parent).is_err() { + return Err(RepositoryFailure::RollbackFailed); + } + return Err(failure); + } + + if had_original { + if std::fs::remove_file(backup).is_err() { + if rollback_publication(&self.path, backup, true, parent).is_err() { + return Err(RepositoryFailure::RollbackFailed); + } + return Err(RepositoryFailure::IoFailure); + } + sync_directory(parent)?; + } + Ok(()) + } + + /// Setup and migration remain intentionally unreachable in production + /// until the independent security review signs the key/recovery design. + pub(crate) fn setup_review_locked(&self) -> Result<(), RepositoryFailure> { + let _ = self; + Err(RepositoryFailure::SecurityReviewRequired) + } + + pub(crate) fn migration_review_locked(&self) -> Result<(), RepositoryFailure> { + let _ = self; + Err(RepositoryFailure::SecurityReviewRequired) + } + + /// Test-only execution harness for the otherwise hard-locked migration + /// design. It requires explicit consent and an already-provisioned target + /// key, publishes to a distinct path, authenticates readback, and leaves + /// the historical source untouched for a later reviewed disposition step. + #[cfg(test)] + fn migrate_legacy_json( + &self, + source: &Path, + consent: MigrationConsent, + legacy_keys: &dyn LegacyKeyProvider, + mut target_metadata: RepositoryMetadata, + ) -> Result + where + T: DeserializeOwned + Serialize, + { + if consent != MigrationConsent::Explicit { + return Err(RepositoryFailure::MigrationConsentRequired); + } + if source == self.path || target_metadata.revision != 0 { + return Err(RepositoryFailure::RevisionConflict); + } + reject_symlink_chain(source.parent().ok_or(RepositoryFailure::PathUnavailable)?)?; + reject_symlink(source)?; + self.reject_publication_artifacts()?; + if self.read_canonical()?.is_some() { + return Err(RepositoryFailure::RevisionConflict); + } + + // Exactly one bounded source snapshot is held in memory; it is never + // copied to a temporary or journal file. + let original = read_required_bounded(source)?; + let plaintext = match crypto::expose_legacy_plaintext(&original) { + Ok(plaintext) => plaintext, + Err(crypto::CryptoError::LegacyEncryptedMigrationRequired) => { + let legacy_key = legacy_keys.load_legacy_key().map_err(map_key_lookup)?; + crypto::open_legacy_nigig1(&original, &legacy_key).map_err(map_crypto)? + } + Err(failure) => return Err(map_crypto(failure)), + }; + let value: T = + serde_json::from_slice(&plaintext).map_err(|_| RepositoryFailure::MalformedJson)?; + self.commit_json(&mut target_metadata, 1, &value)?; + let RepositoryOpen::Open(verified) = self.open_json::()? else { + return Err(RepositoryFailure::CanonicalReadbackFailed); + }; + let expected_value = Zeroizing::new( + serde_json::to_vec(&value).map_err(|_| RepositoryFailure::SerializationFailed)?, + ); + let actual_value = Zeroizing::new( + serde_json::to_vec(&verified.value) + .map_err(|_| RepositoryFailure::SerializationFailed)?, + ); + if expected_value != actual_value || verified.metadata != target_metadata { + return Err(RepositoryFailure::CanonicalReadbackFailed); + } + if read_required_bounded(source)? != original { + return Err(RepositoryFailure::CanonicalReadbackFailed); + } + Ok(MigrationOutcome { + metadata: target_metadata, + original_preserved: true, + }) + } +} + +fn open_read_no_follow(path: &Path) -> std::io::Result { + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + // Open the reparse point itself rather than following it; the handle + // metadata is rejected below before any bytes are consumed. + options.custom_flags(0x0020_0000); // FILE_FLAG_OPEN_REPARSE_POINT + } + options.open(path) +} + +fn reject_symlink_chain(path: &Path) -> Result<(), RepositoryFailure> { + let mut current = PathBuf::new(); + for component in path.components() { + current.push(component.as_os_str()); + reject_symlink(¤t)?; + } + Ok(()) +} + +#[cfg(windows)] +fn windows_link_count(file: &std::fs::File) -> Result { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, + }; + + let mut information = std::mem::MaybeUninit::::zeroed(); + // SAFETY: `file` owns a valid handle for this call and `information` points + // to writable storage of exactly the structure size required by Win32. + let succeeded = + unsafe { GetFileInformationByHandle(file.as_raw_handle(), information.as_mut_ptr()) }; + if succeeded == 0 { + return Err(map_read_io(&std::io::Error::last_os_error())); + } + // SAFETY: a nonzero return guarantees that Win32 initialized the output. + Ok(unsafe { information.assume_init() }.nNumberOfLinks) +} + +fn metadata_is_link_like(metadata: &std::fs::Metadata) -> bool { + if metadata.file_type().is_symlink() { + return true; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return true; + } + } + false +} + +fn reject_symlink(path: &Path) -> Result<(), RepositoryFailure> { + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata_is_link_like(&metadata) => Err(RepositoryFailure::SymlinkRejected), + Ok(_) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(map_read_io(&error)), + } +} + +#[cfg(test)] +fn read_required_bounded(path: &Path) -> Result { + let mut file = open_read_no_follow(path).map_err(|error| map_read_io(&error))?; + let metadata = file.metadata().map_err(|error| map_read_io(&error))?; + if metadata_is_link_like(&metadata) { + return Err(RepositoryFailure::SymlinkRejected); + } + if !metadata.file_type().is_file() { + return Err(RepositoryFailure::IoFailure); + } + if metadata.len() == 0 { + return Err(RepositoryFailure::EmptyFile); + } + if metadata.len() > u64::try_from(crypto::MAX_ENVELOPE_BYTES).unwrap_or(u64::MAX) { + return Err(RepositoryFailure::InputTooLarge); + } + let mut bytes = Vec::with_capacity( + usize::try_from(metadata.len()) + .unwrap_or(0) + .min(crypto::MAX_ENVELOPE_BYTES), + ); + Read::by_ref(&mut file) + .take(u64::try_from(crypto::MAX_ENVELOPE_BYTES + 1).unwrap_or(u64::MAX)) + .read_to_end(&mut bytes) + .map_err(|error| map_read_io(&error))?; + if bytes.len() > crypto::MAX_ENVELOPE_BYTES { + return Err(RepositoryFailure::InputTooLarge); + } + Ok(Zeroizing::new(bytes)) +} + +fn managed_stem(path: &Path) -> Result { + path.file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .map(str::to_owned) + .ok_or(RepositoryFailure::PathUnavailable) +} + +fn sync_directory(parent: &Path) -> Result<(), RepositoryFailure> { + #[cfg(unix)] + { + let directory = + std::fs::File::open(parent).map_err(|_| RepositoryFailure::DirectorySyncFailed)?; + directory + .sync_all() + .map_err(|_| RepositoryFailure::DirectorySyncFailed)?; + } + #[cfg(not(unix))] + let _ = parent; + Ok(()) +} + +fn rollback_publication( + canonical: &Path, + backup: &Path, + had_original: bool, + parent: &Path, +) -> Result<(), ()> { + if canonical.exists() { + std::fs::remove_file(canonical).map_err(|_| ())?; + } + if had_original { + std::fs::rename(backup, canonical).map_err(|_| ())?; + } + sync_directory(parent).map_err(|_| ()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct WriterHealth { + pub(crate) accepted_revision: u64, + pub(crate) durable_revision: u64, + pub(crate) pending_depth: usize, + pub(crate) active: bool, + pub(crate) accepting: bool, + pub(crate) last_failure: Option, +} + +impl WriterHealth { + #[cfg(test)] + pub(crate) const fn has_unsaved_changes(self) -> bool { + self.accepted_revision > self.durable_revision + } +} + +struct Pending { + revision: u64, + value: T, +} + +struct WriterState { + metadata: RepositoryMetadata, + accepted_revision: u64, + durable_revision: u64, + pending: Option>, + active: bool, + accepting: bool, + shutdown_requested: bool, + thread_done: bool, + last_failure: Option, +} + +struct WriterShared { + state: std::sync::Mutex>, + wake: std::sync::Condvar, +} + +pub(crate) struct RepositoryWriter { + shared: Arc>, + faults: Arc, + thread: std::sync::Mutex>>, +} + +impl RepositoryWriter +where + T: Clone + Serialize + Send + 'static, +{ + pub(crate) fn start( + repository: SiteRepository, + metadata: RepositoryMetadata, + ) -> Result { + let shared = Arc::new(WriterShared { + state: std::sync::Mutex::new(WriterState { + metadata, + accepted_revision: metadata.revision, + durable_revision: metadata.revision, + pending: None, + active: false, + accepting: true, + shutdown_requested: false, + thread_done: false, + last_failure: None, + }), + wake: std::sync::Condvar::new(), + }); + let worker_shared = Arc::clone(&shared); + let faults = Arc::clone(&repository.faults); + let thread = std::thread::Builder::new() + .name("nigig-site-repository-writer".to_owned()) + .spawn(move || writer_loop(repository, worker_shared)) + .map_err(|_| RepositoryFailure::WriterClosed)?; + Ok(Self { + shared, + faults, + thread: std::sync::Mutex::new(Some(thread)), + }) + } + + /// Accept the newest complete snapshot. The single pending slot is replaced + /// (coalesced); because each snapshot contains all prior accepted changes, + /// no accepted mutation is dropped from the newest value. + pub(crate) fn submit(&self, value: T) -> Result { + let mut state = self + .shared + .state + .lock() + .unwrap_or_else(|error| error.into_inner()); + if !state.accepting || state.last_failure.is_some() { + return Err(state + .last_failure + .unwrap_or(RepositoryFailure::WriterClosed)); + } + state.accepted_revision = state + .accepted_revision + .checked_add(1) + .ok_or(RepositoryFailure::RevisionConflict)?; + let revision = state.accepted_revision; + state.pending = Some(Pending { revision, value }); + self.shared.wake.notify_all(); + Ok(revision) + } + + pub(crate) fn health(&self) -> WriterHealth { + let state = self + .shared + .state + .lock() + .unwrap_or_else(|error| error.into_inner()); + WriterHealth { + accepted_revision: state.accepted_revision, + durable_revision: state.durable_revision, + pending_depth: usize::from(state.pending.is_some()), + active: state.active, + accepting: state.accepting, + last_failure: state.last_failure, + } + } + + pub(crate) fn flush(&self, timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + let mut state = self + .shared + .state + .lock() + .unwrap_or_else(|error| error.into_inner()); + while state.pending.is_some() || state.active { + if let Some(failure) = state.last_failure { + return Err(failure); + } + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return Err(RepositoryFailure::ShutdownTimeout); + }; + let (next, wait) = self + .shared + .wake + .wait_timeout(state, remaining) + .unwrap_or_else(|error| error.into_inner()); + state = next; + if wait.timed_out() && (state.pending.is_some() || state.active) { + return Err(RepositoryFailure::ShutdownTimeout); + } + } + if let Some(failure) = state.last_failure { + Err(failure) + } else { + Ok(state.durable_revision) + } + } + + pub(crate) fn flush_and_shutdown(&self, timeout: Duration) -> Result { + self.faults.check(FaultStage::Shutdown)?; + { + let mut state = self + .shared + .state + .lock() + .unwrap_or_else(|error| error.into_inner()); + state.accepting = false; + state.shutdown_requested = true; + self.shared.wake.notify_all(); + } + let deadline = Instant::now() + timeout; + let mut state = self + .shared + .state + .lock() + .unwrap_or_else(|error| error.into_inner()); + while !state.thread_done { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return Err(RepositoryFailure::ShutdownTimeout); + }; + let (next, wait) = self + .shared + .wake + .wait_timeout(state, remaining) + .unwrap_or_else(|error| error.into_inner()); + state = next; + if wait.timed_out() && !state.thread_done { + return Err(RepositoryFailure::ShutdownTimeout); + } + } + let result = state.last_failure.map_or(Ok(state.durable_revision), Err); + drop(state); + if let Some(thread) = self + .thread + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take() + { + if thread.join().is_err() { + return Err(RepositoryFailure::ShutdownTimeout); + } + } + result + } +} + +impl Drop for RepositoryWriter { + fn drop(&mut self) { + // Normal application teardown calls `flush_and_shutdown` explicitly. + // This fallback is intentionally non-blocking, but it always wakes an + // idle worker so dropping the handle cannot strand a detached thread. + let mut state = self + .shared + .state + .lock() + .unwrap_or_else(|error| error.into_inner()); + state.accepting = false; + state.shutdown_requested = true; + self.shared.wake.notify_all(); + } +} + +fn writer_loop(repository: SiteRepository, shared: Arc>) +where + T: Clone + Serialize + Send + 'static, +{ + loop { + let pending = { + let mut state = shared + .state + .lock() + .unwrap_or_else(|error| error.into_inner()); + while state.pending.is_none() + && !state.shutdown_requested + && state.last_failure.is_none() + { + state = shared + .wake + .wait(state) + .unwrap_or_else(|error| error.into_inner()); + } + if state.last_failure.is_some() || (state.shutdown_requested && state.pending.is_none()) + { + state.thread_done = true; + shared.wake.notify_all(); + return; + } + let pending = state.pending.take().expect("pending checked above"); + state.active = true; + pending + }; + + let metadata = { + shared + .state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .metadata + }; + let mut next_metadata = metadata; + let result = repository.commit_json(&mut next_metadata, pending.revision, &pending.value); + + let mut state = shared + .state + .lock() + .unwrap_or_else(|error| error.into_inner()); + state.active = false; + match result { + Ok(revision) => { + state.metadata = next_metadata; + state.durable_revision = revision; + } + Err(failure) => { + state.last_failure = Some(failure); + state.accepting = false; + state.pending = None; + } + } + shared.wake.notify_all(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::sync::Mutex; + + #[derive(Default)] + struct MemoryKeys { + keys: Mutex>, + failure: Mutex>, + } + + impl MemoryKeys { + fn insert(&self, metadata: RepositoryMetadata, key: [u8; 32]) { + self.keys + .lock() + .unwrap() + .insert((metadata.store_id, metadata.key_id), key); + } + } + + impl KeyProvider for MemoryKeys { + fn load(&self, store_id: StoreId, key_id: KeyId) -> Result { + if let Some(failure) = *self.failure.lock().unwrap() { + return Err(failure); + } + self.keys + .lock() + .unwrap() + .get(&(store_id, key_id)) + .copied() + .map(Zeroizing::new) + .ok_or(KeyLookupFailure::Missing) + } + } + + struct LegacyKeys { + key: [u8; 32], + failure: Option, + calls: std::sync::atomic::AtomicUsize, + } + + impl LegacyKeys { + fn available(key: [u8; 32]) -> Self { + Self { + key, + failure: None, + calls: std::sync::atomic::AtomicUsize::new(0), + } + } + + fn failing(failure: KeyLookupFailure) -> Self { + Self { + key: [0; 32], + failure: Some(failure), + calls: std::sync::atomic::AtomicUsize::new(0), + } + } + + fn calls(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } + } + + impl LegacyKeyProvider for LegacyKeys { + fn load_legacy_key(&self) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + if let Some(failure) = self.failure { + Err(failure) + } else { + Ok(Zeroizing::new(self.key)) + } + } + } + + struct FixedRandom(u8); + impl SecureRandom for FixedRandom { + fn fill(&self, destination: &mut [u8]) -> Result<(), crypto::CryptoError> { + destination.fill(self.0); + Ok(()) + } + } + + struct OneFault(Mutex>); + + struct ExitFault(FaultStage); + + impl FaultInjector for ExitFault { + fn check(&self, stage: FaultStage) -> Result<(), RepositoryFailure> { + if stage == self.0 { + // `process::exit` does not unwind or run destructors, so the + // parent test observes real unclean publication artifacts. + std::process::exit(91); + } + Ok(()) + } + } + + impl OneFault { + fn at(stage: FaultStage) -> Self { + Self(Mutex::new(Some(stage))) + } + } + impl FaultInjector for OneFault { + fn check(&self, stage: FaultStage) -> Result<(), RepositoryFailure> { + let mut selected = self.0.lock().unwrap(); + if *selected == Some(stage) { + *selected = None; + return Err(match stage { + FaultStage::KeyLookup => RepositoryFailure::KeyStoreUnavailable, + FaultStage::RandomGeneration => RepositoryFailure::RandomUnavailable, + FaultStage::Serialization => RepositoryFailure::SerializationFailed, + FaultStage::Encryption => RepositoryFailure::EncryptionFailed, + FaultStage::TempCreate => RepositoryFailure::TempCreateFailed, + FaultStage::Permissions => RepositoryFailure::PermissionSetFailed, + FaultStage::Write => RepositoryFailure::WriteFailed, + FaultStage::Flush => RepositoryFailure::FlushFailed, + FaultStage::FileSync => RepositoryFailure::FileSyncFailed, + FaultStage::Rename => RepositoryFailure::RenameFailed, + FaultStage::DirectorySync => RepositoryFailure::DirectorySyncFailed, + FaultStage::Readback => RepositoryFailure::CanonicalReadbackFailed, + FaultStage::Shutdown => RepositoryFailure::ShutdownTimeout, + }); + } + Ok(()) + } + } + + #[derive(Clone, Debug, PartialEq, Eq, Serialize, serde::Deserialize)] + struct Record { + version: u32, + secret: String, + count: u32, + } + + fn metadata() -> RepositoryMetadata { + RepositoryMetadata { + store_id: StoreId([1; 16]), + key_id: KeyId([2; 16]), + revision: 1, + } + } + + fn temp_path(tag: &str) -> PathBuf { + let directory = std::env::temp_dir().join(format!( + "nigig-site-repository-{tag}-{}-{}", + std::process::id(), + FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + let _ = std::fs::remove_dir_all(&directory); + std::fs::create_dir_all(&directory).unwrap(); + directory.join("store.json") + } + + fn fixture( + tag: &str, + ) -> ( + SiteRepository, + Arc, + RepositoryMetadata, + SecretKey, + ) { + let path = temp_path(tag); + let keys = Arc::new(MemoryKeys::default()); + let metadata = metadata(); + keys.insert(metadata, [7; 32]); + let repository = SiteRepository::injected( + path, + keys.clone(), + Arc::new(FixedRandom(4)), + Arc::new(NoFaults), + ); + (repository, keys, metadata, Zeroizing::new([7; 32])) + } + + fn write_private(path: &Path, bytes: &[u8]) { + std::fs::write(path, bytes).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap(); + } + } + + fn seed( + repository: &SiteRepository, + metadata: RepositoryMetadata, + key: &SecretKey, + record: &Record, + ) -> Vec { + let bytes = serde_json::to_vec(record).unwrap(); + let envelope = crypto::seal( + &bytes, + metadata.header_at(metadata.revision), + key, + &FixedRandom(3), + ) + .unwrap(); + write_private(repository.path(), &envelope); + envelope + } + + fn record(count: u32) -> Record { + Record { + version: 2, + secret: "Worker ID 12345678 — Confidential Sentinel".to_owned(), + count, + } + } + + fn legacy_encrypted(plaintext: &[u8], key: &[u8; 32]) -> Vec { + use aead::Aead; + use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; + + let nonce = [3_u8; 12]; + let cipher = Aes256Gcm::new_from_slice(key).unwrap(); + let ciphertext = cipher + .encrypt(Nonce::from_slice(&nonce), plaintext) + .unwrap(); + [b"NIGIG1\x01".as_slice(), &nonce, &ciphertext].concat() + } + + fn migration_target( + tag: &str, + ) -> (SiteRepository, Arc, RepositoryMetadata, PathBuf) { + let target = temp_path(tag); + let source = target.with_file_name("legacy-store.json"); + let keys = Arc::new(MemoryKeys::default()); + let metadata = RepositoryMetadata { + revision: 0, + ..metadata() + }; + keys.insert(metadata, [7; 32]); + let repository = SiteRepository::injected( + target, + keys.clone(), + Arc::new(FixedRandom(4)), + Arc::new(NoFaults), + ); + (repository, keys, metadata, source) + } + + #[cfg(target_os = "linux")] + #[test] + #[ignore = "requires an explicitly disposable unlocked Secret Service session"] + fn linux_native_provider_real_vault_lifecycle() { + assert_eq!( + std::env::var("NIGIG_SITE_LIVE_KEYRING_TEST").as_deref(), + Ok("disposable-secret-service-v1"), + "refusing to touch a native vault without the disposable-session opt-in" + ); + let home = std::env::var_os("HOME").expect("disposable HOME"); + assert_eq!( + std::env::var_os("NIGIG_SITE_KEYRING_TEST_HOME").as_deref(), + Some(home.as_os_str()), + "HOME must exactly match the declared disposable test home" + ); + assert!(std::env::var_os("DBUS_SESSION_BUS_ADDRESS").is_some()); + + struct DeleteOnDrop(keyring_core::Entry); + impl Drop for DeleteOnDrop { + fn drop(&mut self) { + drop(self.0.delete_credential()); + } + } + + let store_id = StoreId(crypto::random_id(&OsRandom).unwrap()); + let key_id = KeyId(crypto::random_id(&OsRandom).unwrap()); + let entry = DeleteOnDrop(NativeKeyProvider::entry(store_id, key_id).unwrap()); + drop(entry.0.delete_credential()); + + entry.0.set_secret(&[0x01, 0x02]).unwrap(); + assert_eq!( + NativeKeyProvider.load(store_id, key_id).unwrap_err(), + KeyLookupFailure::Invalid + ); + + let mut active = Zeroizing::new(vec![0x01]); + active.extend_from_slice(&[0x5a; 32]); + entry.0.set_secret(&active).unwrap(); + assert_eq!( + &*NativeKeyProvider.load(store_id, key_id).unwrap(), + &[0x5a; 32] + ); + + let repository_root = PathBuf::from(&home).join("native-repository-smoke"); + std::fs::create_dir(&repository_root).unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&repository_root, std::fs::Permissions::from_mode(0o700)).unwrap(); + let repository = SiteRepository::native(repository_root.join("store.json")); + let mut repository_metadata = RepositoryMetadata { + store_id, + key_id, + revision: 0, + }; + assert_eq!( + repository.commit_json(&mut repository_metadata, 1, &record(1)), + Ok(1) + ); + let RepositoryOpen::Open(opened) = repository.open_json::().unwrap() else { + panic!("native repository did not reopen") + }; + assert_eq!(opened.value, record(1)); + assert_eq!( + repository.commit_json(&mut repository_metadata, 2, &record(2)), + Ok(2) + ); + let RepositoryOpen::Open(opened) = repository.open_json::().unwrap() else { + panic!("updated native repository did not reopen") + }; + assert_eq!(opened.value, record(2)); + let ciphertext = std::fs::read(repository.path()).unwrap(); + assert!(ciphertext.starts_with(b"NIGIG2")); + assert!(!String::from_utf8_lossy(&ciphertext).contains("Confidential Sentinel")); + let canonical_metadata = std::fs::metadata(repository.path()).unwrap(); + use std::os::unix::fs::MetadataExt; + assert_eq!(canonical_metadata.permissions().mode() & 0o077, 0); + assert_eq!(canonical_metadata.nlink(), 1); + + let mut wrong = active.clone(); + wrong[1..].fill(0x6b); + entry.0.set_secret(&wrong).unwrap(); + assert_eq!( + repository.open_json::().unwrap_err(), + RepositoryFailure::AuthenticationFailed + ); + entry.0.set_secret(&active).unwrap(); + entry.0.delete_credential().unwrap(); + assert_eq!( + repository.open_json::().unwrap_err(), + RepositoryFailure::KeyMissing + ); + assert_eq!(std::fs::read(repository.path()).unwrap(), ciphertext); + std::fs::remove_dir_all(repository_root).unwrap(); + + let mut retired = active; + retired[0] = 0x02; + entry.0.set_secret(&retired).unwrap(); + assert_eq!( + NativeKeyProvider.load(store_id, key_id).unwrap_err(), + KeyLookupFailure::RotatedKeyUnavailable + ); + + retired[0] = 0x01; + entry.0.set_secret(&retired).unwrap(); + entry.0.delete_credential().unwrap(); + assert_eq!( + NativeKeyProvider.load(store_id, key_id).unwrap_err(), + KeyLookupFailure::Missing + ); + + // Finish with a locked collection. Cleanup removes the entire + // disposable HOME because a locked item cannot be deleted headlessly. + entry.0.set_secret(&retired).unwrap(); + assert!(std::process::Command::new("secret-tool") + .args(["lock", "--collection=login"]) + .status() + .unwrap() + .success()); + assert_eq!( + NativeKeyProvider.load(store_id, key_id).unwrap_err(), + KeyLookupFailure::Unavailable + ); + } + + #[cfg(any(target_os = "macos", target_os = "windows"))] + #[test] + #[ignore = "requires an explicitly authorized isolated CI native vault"] + fn apple_windows_native_provider_real_vault_lifecycle() { + assert_eq!( + std::env::var("NIGIG_SITE_LIVE_KEYRING_TEST").as_deref(), + Ok("isolated-ci-native-v1"), + "refusing to touch a native vault without explicit isolated-CI opt-in" + ); + + use keyring_core::api::CredentialStoreApi; + + struct DeleteOnDrop(keyring_core::Entry); + impl Drop for DeleteOnDrop { + fn drop(&mut self) { + drop(self.0.delete_credential()); + } + } + + let store_id = StoreId(crypto::random_id(&OsRandom).unwrap()); + let key_id = KeyId(crypto::random_id(&OsRandom).unwrap()); + let account = NativeKeyProvider::account(store_id, key_id); + #[cfg(target_os = "macos")] + let raw_entry = apple_native_keyring_store::keychain::Store::new() + .unwrap() + .build(KEYRING_SERVICE, &account, None) + .unwrap(); + #[cfg(target_os = "windows")] + let raw_entry = { + let store = windows_native_keyring_store::Store::new().unwrap(); + let modifiers = std::collections::HashMap::from([("persistence", "local")]); + store + .build(KEYRING_SERVICE, &account, Some(&modifiers)) + .unwrap() + }; + let entry = DeleteOnDrop(raw_entry); + drop(entry.0.delete_credential()); + + entry.0.set_secret(&[0x01, 0x02]).unwrap(); + assert_eq!( + NativeKeyProvider.load(store_id, key_id).unwrap_err(), + KeyLookupFailure::Invalid + ); + let mut zero_record = Zeroizing::new([0_u8; 33]); + zero_record[0] = 0x01; + entry.0.set_secret(zero_record.as_slice()).unwrap(); + assert_eq!( + NativeKeyProvider.load(store_id, key_id).unwrap_err(), + KeyLookupFailure::Invalid + ); + + let mut active = Zeroizing::new(vec![0x01]); + active.extend_from_slice(&[0x5a; 32]); + entry.0.set_secret(&active).unwrap(); + #[cfg(target_os = "windows")] + assert!(matches!( + entry.0.get_attributes().unwrap().get("persistence"), + Some(value) if value == "Local" + )); + assert_eq!( + &*NativeKeyProvider.load(store_id, key_id).unwrap(), + &[0x5a; 32] + ); + + #[cfg(target_os = "windows")] + { + let enterprise_key_id = KeyId(crypto::random_id(&OsRandom).unwrap()); + let enterprise_account = NativeKeyProvider::account(store_id, enterprise_key_id); + let enterprise_store = windows_native_keyring_store::Store::new().unwrap(); + let enterprise_modifiers = + std::collections::HashMap::from([("persistence", "enterprise")]); + let enterprise_entry = DeleteOnDrop( + enterprise_store + .build( + KEYRING_SERVICE, + &enterprise_account, + Some(&enterprise_modifiers), + ) + .unwrap(), + ); + drop(enterprise_entry.0.delete_credential()); + enterprise_entry.0.set_secret(&active).unwrap(); + assert!(matches!( + enterprise_entry + .0 + .get_attributes() + .unwrap() + .get("persistence"), + Some(value) if value == "Enterprise" + )); + assert_eq!( + NativeKeyProvider + .load(store_id, enterprise_key_id) + .unwrap_err(), + KeyLookupFailure::Invalid + ); + } + + let repository_path = temp_path("apple-windows-native-vault"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + repository_path.parent().unwrap(), + std::fs::Permissions::from_mode(0o700), + ) + .unwrap(); + } + let repository = SiteRepository::native(repository_path); + let mut repository_metadata = RepositoryMetadata { + store_id, + key_id, + revision: 0, + }; + assert_eq!( + repository.commit_json(&mut repository_metadata, 1, &record(1)), + Ok(1) + ); + assert_eq!( + repository.commit_json(&mut repository_metadata, 2, &record(2)), + Ok(2) + ); + let RepositoryOpen::Open(opened) = repository.open_json::().unwrap() else { + panic!("updated native repository did not reopen") + }; + assert_eq!(opened.value, record(2)); + let ciphertext = std::fs::read(repository.path()).unwrap(); + assert!(ciphertext.starts_with(b"NIGIG2")); + assert!(!String::from_utf8_lossy(&ciphertext).contains("Confidential Sentinel")); + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let canonical_metadata = std::fs::metadata(repository.path()).unwrap(); + assert_eq!(canonical_metadata.permissions().mode() & 0o077, 0); + assert_eq!(canonical_metadata.nlink(), 1); + } + let mut wrong = active.clone(); + wrong[1..].fill(0x6b); + entry.0.set_secret(&wrong).unwrap(); + assert_eq!( + repository.open_json::().unwrap_err(), + RepositoryFailure::AuthenticationFailed + ); + entry.0.set_secret(&active).unwrap(); + entry.0.delete_credential().unwrap(); + assert_eq!( + repository.open_json::().unwrap_err(), + RepositoryFailure::KeyMissing + ); + assert_eq!(std::fs::read(repository.path()).unwrap(), ciphertext); + + let mut retired = active; + retired[0] = 0x02; + entry.0.set_secret(&retired).unwrap(); + assert_eq!( + NativeKeyProvider.load(store_id, key_id).unwrap_err(), + KeyLookupFailure::RotatedKeyUnavailable + ); + entry.0.delete_credential().unwrap(); + assert_eq!( + NativeKeyProvider.load(store_id, key_id).unwrap_err(), + KeyLookupFailure::Missing + ); + std::fs::remove_dir_all(repository.path().parent().unwrap()).unwrap(); + } + + #[test] + fn native_key_record_states_are_strict_and_distinct() { + let mut active = [0u8; 33]; + active[0] = 0x01; + active[1..].copy_from_slice(&[7; 32]); + assert_eq!( + &*NativeKeyProvider::decode_record(&active).unwrap(), + &[7; 32] + ); + + let mut retired = active; + retired[0] = 0x02; + assert_eq!( + NativeKeyProvider::decode_record(&retired).unwrap_err(), + KeyLookupFailure::RotatedKeyUnavailable + ); + let mut zero_key = [0_u8; 33]; + zero_key[0] = 0x01; + for invalid in [ + &[][..], + &[0x01][..], + &[0x03; 33][..], + &[0x01; 34][..], + &zero_key, + ] { + assert_eq!( + NativeKeyProvider::decode_record(invalid).unwrap_err(), + KeyLookupFailure::Invalid + ); + } + } + + #[test] + fn absent_open_is_read_only_and_setup_is_review_locked() { + let (repository, _, _, _) = fixture("absent"); + assert!(matches!( + repository.open_json::().unwrap(), + RepositoryOpen::Absent + )); + assert!(!repository.path().exists()); + assert_eq!( + repository.setup_review_locked(), + Err(RepositoryFailure::SecurityReviewRequired) + ); + assert_eq!( + repository.migration_review_locked(), + Err(RepositoryFailure::SecurityReviewRequired) + ); + std::fs::remove_dir_all(repository.path().parent().unwrap()).unwrap(); + } + + #[test] + fn open_parses_identity_before_exact_key_lookup_and_preserves_bytes() { + let (repository, _, metadata, key) = fixture("open"); + let original = seed(&repository, metadata, &key, &record(1)); + let opened = repository.open_json::().unwrap(); + let RepositoryOpen::Open(opened) = opened else { + panic!("expected open document") + }; + assert_eq!(opened.metadata, metadata); + assert_eq!(opened.value, record(1)); + assert_eq!(std::fs::read(repository.path()).unwrap(), original); + std::fs::remove_dir_all(repository.path().parent().unwrap()).unwrap(); + } + + #[test] + fn durable_commit_is_encrypted_authenticated_and_readable() { + let (repository, _, mut metadata, key) = fixture("commit"); + seed(&repository, metadata, &key, &record(1)); + assert_eq!(repository.commit_json(&mut metadata, 2, &record(2)), Ok(2)); + assert_eq!(metadata.revision, 2); + let bytes = std::fs::read(repository.path()).unwrap(); + assert!(bytes.starts_with(b"NIGIG2")); + assert!(!String::from_utf8_lossy(&bytes).contains("Confidential Sentinel")); + let RepositoryOpen::Open(opened) = repository.open_json::().unwrap() else { + panic!("expected document") + }; + assert_eq!(opened.value.count, 2); + assert_eq!(opened.metadata.revision, 2); + let entries: Vec<_> = std::fs::read_dir(repository.path().parent().unwrap()) + .unwrap() + .collect(); + assert_eq!( + entries.len(), + 2, + "only canonical ciphertext and the empty process lock may remain" + ); + std::fs::remove_dir_all(repository.path().parent().unwrap()).unwrap(); + } + + #[test] + fn missing_invalid_rotated_and_wrong_keys_are_distinct_and_non_destructive() { + let (repository, keys, metadata, key) = fixture("keys"); + let original = seed(&repository, metadata, &key, &record(1)); + for (lookup, expected) in [ + (KeyLookupFailure::Missing, RepositoryFailure::KeyMissing), + (KeyLookupFailure::Invalid, RepositoryFailure::KeyInvalid), + ( + KeyLookupFailure::RotatedKeyUnavailable, + RepositoryFailure::RotatedKeyUnavailable, + ), + ] { + *keys.failure.lock().unwrap() = Some(lookup); + assert_eq!(repository.open_json::().unwrap_err(), expected); + assert_eq!(std::fs::read(repository.path()).unwrap(), original); + } + *keys.failure.lock().unwrap() = None; + keys.insert(metadata, [9; 32]); + assert_eq!( + repository.open_json::().unwrap_err(), + RepositoryFailure::AuthenticationFailed + ); + assert_eq!(std::fs::read(repository.path()).unwrap(), original); + std::fs::remove_dir_all(repository.path().parent().unwrap()).unwrap(); + } + + #[test] + fn tampered_header_and_ciphertext_preserve_original() { + let (repository, _, metadata, key) = fixture("tamper"); + let original = seed(&repository, metadata, &key, &record(1)); + // A changed routing identity cannot locate a key; a changed bound + // revision or ciphertext reaches AEAD and fails authentication. + for (index, expected) in [ + (16, RepositoryFailure::KeyMissing), + (46, RepositoryFailure::AuthenticationFailed), + (original.len() - 1, RepositoryFailure::AuthenticationFailed), + ] { + let mut tampered = original.clone(); + tampered[index] ^= 1; + write_private(repository.path(), &tampered); + assert_eq!(repository.open_json::().unwrap_err(), expected); + assert_eq!(std::fs::read(repository.path()).unwrap(), tampered); + } + std::fs::remove_dir_all(repository.path().parent().unwrap()).unwrap(); + } + + #[test] + fn unauthenticated_current_revision_cannot_be_overwritten() { + let (repository, _, mut metadata, key) = fixture("tampered-commit"); + let mut tampered = seed(&repository, metadata, &key, &record(1)); + *tampered.last_mut().unwrap() ^= 1; + write_private(repository.path(), &tampered); + + assert_eq!( + repository.commit_json(&mut metadata, 2, &record(2)), + Err(RepositoryFailure::AuthenticationFailed) + ); + assert_eq!(metadata.revision, 1); + assert_eq!(std::fs::read(repository.path()).unwrap(), tampered); + let names: Vec<_> = std::fs::read_dir(repository.path().parent().unwrap()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert!(names + .iter() + .all(|name| !name.to_string_lossy().starts_with(".store.json.site02-"))); + std::fs::remove_dir_all(repository.path().parent().unwrap()).unwrap(); + } + + #[test] + fn plaintext_legacy_and_malformed_inputs_are_classified_without_change() { + let (repository, _, metadata, key) = fixture("formats"); + let cases = [ + ( + b"{}".to_vec(), + RepositoryFailure::PlaintextMigrationRequired, + ), + ( + b"NIGIG1\x00{}".to_vec(), + RepositoryFailure::PlaintextMigrationRequired, + ), + ( + b"NIGIG1\x01not-valid".to_vec(), + RepositoryFailure::LegacyEncryptedMigrationRequired, + ), + ]; + for (bytes, failure) in cases { + write_private(repository.path(), &bytes); + assert_eq!(repository.open_json::().unwrap_err(), failure); + assert_eq!(std::fs::read(repository.path()).unwrap(), bytes); + } + let malformed = + crypto::seal(b"not-json", metadata.header_at(1), &key, &FixedRandom(3)).unwrap(); + write_private(repository.path(), &malformed); + assert_eq!( + repository.open_json::().unwrap_err(), + RepositoryFailure::MalformedJson + ); + assert_eq!(std::fs::read(repository.path()).unwrap(), malformed); + std::fs::remove_dir_all(repository.path().parent().unwrap()).unwrap(); + } + + #[test] + fn oversized_sparse_canonical_is_rejected_before_key_lookup_or_allocation() { + let (repository, keys, _, _) = fixture("oversized-sparse"); + let file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(repository.path()) + .unwrap(); + file.set_len(u64::try_from(crypto::MAX_ENVELOPE_BYTES + 1).unwrap()) + .unwrap(); + drop(file); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(repository.path(), std::fs::Permissions::from_mode(0o600)) + .unwrap(); + } + *keys.failure.lock().unwrap() = Some(KeyLookupFailure::Unavailable); + assert_eq!( + repository.open_json::().unwrap_err(), + RepositoryFailure::InputTooLarge + ); + assert_eq!( + std::fs::metadata(repository.path()).unwrap().len(), + u64::try_from(crypto::MAX_ENVELOPE_BYTES + 1).unwrap() + ); + std::fs::remove_dir_all(repository.path().parent().unwrap()).unwrap(); + } + + #[test] + fn stale_or_repeated_revision_is_rejected_before_writing() { + let (repository, _, metadata, key) = fixture("revision"); + let original = seed(&repository, metadata, &key, &record(1)); + let mut repeated = metadata; + assert_eq!( + repository.commit_json(&mut repeated, 1, &record(2)), + Err(RepositoryFailure::RevisionConflict) + ); + assert_eq!(std::fs::read(repository.path()).unwrap(), original); + assert_eq!(repeated.revision, 1); + + // Simulate another process publishing while this process retains stale + // metadata. The process lock serializes the check/publication window; + // canonical revision revalidation rejects the stale writer. + let mut leader = metadata; + let mut stale = metadata; + assert_eq!(repository.commit_json(&mut leader, 2, &record(2)), Ok(2)); + let leader_bytes = std::fs::read(repository.path()).unwrap(); + assert_eq!( + repository.commit_json(&mut stale, 2, &record(99)), + Err(RepositoryFailure::RevisionConflict) + ); + assert_eq!(stale.revision, 1); + assert_eq!(std::fs::read(repository.path()).unwrap(), leader_bytes); + std::fs::remove_dir_all(repository.path().parent().unwrap()).unwrap(); + } + + #[test] + fn concurrent_writers_serialize_and_exactly_one_stale_commit_fails() { + let (repository, _, metadata, key) = fixture("concurrent-writers"); + seed(&repository, metadata, &key, &record(1)); + let barrier = Arc::new(std::sync::Barrier::new(3)); + let mut handles = Vec::new(); + for count in [2, 3] { + let repository = repository.clone(); + let barrier = Arc::clone(&barrier); + handles.push(std::thread::spawn(move || { + let mut local_metadata = metadata; + barrier.wait(); + let result = repository.commit_json(&mut local_metadata, 2, &record(count)); + (result, local_metadata) + })); + } + barrier.wait(); + let results: Vec<_> = handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .collect(); + assert_eq!( + results + .iter() + .filter(|(result, _)| *result == Ok(2)) + .count(), + 1 + ); + assert_eq!( + results + .iter() + .filter(|(result, _)| *result == Err(RepositoryFailure::RevisionConflict)) + .count(), + 1 + ); + assert!(results + .iter() + .any(|(result, metadata)| { *result == Ok(2) && metadata.revision == 2 })); + assert!(results.iter().any(|(result, metadata)| { + *result == Err(RepositoryFailure::RevisionConflict) && metadata.revision == 1 + })); + let RepositoryOpen::Open(opened) = repository.open_json::().unwrap() else { + panic!("concurrent winner was not readable") + }; + assert_eq!(opened.metadata.revision, 2); + assert!([2, 3].contains(&opened.value.count)); + std::fs::remove_dir_all(repository.path().parent().unwrap()).unwrap(); + } + + #[test] + fn process_lock_contention_times_out_without_touching_canonical() { + let (repository, _, mut metadata, key) = fixture("lock-contention"); + let original = seed(&repository, metadata, &key, &record(1)); + let lock_path = repository.path().parent().unwrap().join(".store.json.lock"); + let lock = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .unwrap(); + lock.lock().unwrap(); + assert_eq!( + repository.commit_json(&mut metadata, 2, &record(2)), + Err(RepositoryFailure::RepositoryBusy) + ); + assert_eq!(metadata.revision, 1); + assert_eq!(std::fs::read(repository.path()).unwrap(), original); + lock.unlock().unwrap(); + std::fs::remove_dir_all(repository.path().parent().unwrap()).unwrap(); + } + + #[test] + fn abrupt_exit_publication_helper() { + if std::env::var("NIGIG_SITE_INTERNAL_EXIT_CHILD").as_deref() != Ok("site02-publication-v1") + { + return; + } + let stage_name = + std::env::var("NIGIG_SITE_TEST_EXIT_STAGE").expect("child publication stage"); + let path = PathBuf::from( + std::env::var_os("NIGIG_SITE_TEST_EXIT_PATH").expect("child repository path"), + ); + let stage = match stage_name.as_str() { + "key" => FaultStage::KeyLookup, + "random" => FaultStage::RandomGeneration, + "serialization" => FaultStage::Serialization, + "encryption" => FaultStage::Encryption, + "create" => FaultStage::TempCreate, + "permissions" => FaultStage::Permissions, + "write" => FaultStage::Write, + "flush" => FaultStage::Flush, + "file-sync" => FaultStage::FileSync, + "rename" => FaultStage::Rename, + "directory-sync" => FaultStage::DirectorySync, + "readback" => FaultStage::Readback, + other => panic!("unknown child stage: {other}"), + }; + let keys = Arc::new(MemoryKeys::default()); + let mut metadata = metadata(); + keys.insert(metadata, [7; 32]); + let repository = SiteRepository::injected( + path, + keys, + Arc::new(FixedRandom(4)), + Arc::new(ExitFault(stage)), + ); + let _ = repository.commit_json(&mut metadata, 2, &record(2)); + panic!("exit fault did not terminate at {stage_name}"); + } + + #[test] + fn abrupt_process_termination_is_fail_closed_at_every_commit_stage() { + const STAGES: &[&str] = &[ + "key", + "random", + "serialization", + "encryption", + "create", + "permissions", + "write", + "flush", + "file-sync", + "rename", + "directory-sync", + "readback", + ]; + let executable = std::env::current_exe().unwrap(); + for stage in STAGES { + let (repository, _, metadata, key) = fixture(&format!("abrupt-{stage}")); + let original = seed(&repository, metadata, &key, &record(1)); + let status = std::process::Command::new(&executable) + .args([ + "--exact", + "repository::tests::abrupt_exit_publication_helper", + "--test-threads=1", + ]) + .env("NIGIG_SITE_INTERNAL_EXIT_CHILD", "site02-publication-v1") + .env("NIGIG_SITE_TEST_EXIT_STAGE", stage) + .env("NIGIG_SITE_TEST_EXIT_PATH", repository.path()) + .status() + .unwrap(); + assert_eq!(status.code(), Some(91), "child did not exit at {stage}"); + + let parent = repository.path().parent().unwrap(); + let mut preserved_original = false; + let mut interrupted_artifact = false; + for entry in std::fs::read_dir(parent).unwrap() { + let path = entry.unwrap().path(); + if !path.is_file() { + continue; + } + let bytes = std::fs::read(&path).unwrap(); + preserved_original |= bytes == original; + interrupted_artifact |= path + .file_name() + .unwrap() + .to_string_lossy() + .contains(".site02-"); + let display = String::from_utf8_lossy(&bytes); + assert!(!display.contains("Worker ID 12345678"), "{stage}: {path:?}"); + assert!( + !display.contains("Confidential Sentinel"), + "{stage}: {path:?}" + ); + } + assert!(preserved_original, "{stage}: original ciphertext was lost"); + if interrupted_artifact { + assert_eq!( + repository.open_json::().unwrap_err(), + RepositoryFailure::InterruptedPublication, + "{stage}" + ); + } else { + let RepositoryOpen::Open(opened) = repository.open_json::().unwrap() else { + panic!("{stage}: expected preserved canonical") + }; + assert_eq!(opened.value, record(1), "{stage}"); + } + std::fs::remove_dir_all(parent).unwrap(); + } + } + + #[test] + fn every_prepublication_fault_preserves_canonical_and_cleans_temp() { + for (stage, expected) in [ + ( + FaultStage::KeyLookup, + RepositoryFailure::KeyStoreUnavailable, + ), + ( + FaultStage::RandomGeneration, + RepositoryFailure::RandomUnavailable, + ), + ( + FaultStage::Serialization, + RepositoryFailure::SerializationFailed, + ), + (FaultStage::Encryption, RepositoryFailure::EncryptionFailed), + (FaultStage::TempCreate, RepositoryFailure::TempCreateFailed), + ( + FaultStage::Permissions, + RepositoryFailure::PermissionSetFailed, + ), + (FaultStage::Write, RepositoryFailure::WriteFailed), + (FaultStage::Flush, RepositoryFailure::FlushFailed), + (FaultStage::FileSync, RepositoryFailure::FileSyncFailed), + (FaultStage::Rename, RepositoryFailure::RenameFailed), + ] { + let (base, keys, mut metadata, key) = fixture(&format!("fault-{stage:?}")); + let original = seed(&base, metadata, &key, &record(1)); + let repository = SiteRepository::injected( + base.path.clone(), + keys, + Arc::new(FixedRandom(4)), + Arc::new(OneFault::at(stage)), + ); + assert_eq!( + repository.commit_json(&mut metadata, 2, &record(2)), + Err(expected), + "{stage:?}" + ); + assert_eq!( + std::fs::read(repository.path()).unwrap(), + original, + "{stage:?}" + ); + assert_eq!(metadata.revision, 1); + assert_eq!( + std::fs::read_dir(repository.path().parent().unwrap()) + .unwrap() + .count(), + 2, + "{stage:?} left more than canonical ciphertext and the process lock" + ); + std::fs::remove_dir_all(repository.path().parent().unwrap()).unwrap(); + } + } + + #[test] + fn postpublication_sync_and_readback_faults_roll_back_original() { + for (stage, expected) in [ + ( + FaultStage::DirectorySync, + RepositoryFailure::DirectorySyncFailed, + ), + ( + FaultStage::Readback, + RepositoryFailure::CanonicalReadbackFailed, + ), + ] { + let (base, keys, mut metadata, key) = fixture(&format!("rollback-{stage:?}")); + let original = seed(&base, metadata, &key, &record(1)); + let repository = SiteRepository::injected( + base.path.clone(), + keys, + Arc::new(FixedRandom(4)), + Arc::new(OneFault::at(stage)), + ); + assert_eq!( + repository.commit_json(&mut metadata, 2, &record(2)), + Err(expected), + "{stage:?}" + ); + assert_eq!(std::fs::read(repository.path()).unwrap(), original); + assert_eq!(metadata.revision, 1); + assert_eq!( + std::fs::read_dir(repository.path().parent().unwrap()) + .unwrap() + .count(), + 2 + ); + std::fs::remove_dir_all(repository.path().parent().unwrap()).unwrap(); + } + } + + #[cfg(unix)] + #[test] + fn unsafe_canonical_permissions_hardlinks_and_parent_mode_are_rejected() { + use std::os::unix::fs::PermissionsExt; + + let (repository, _, metadata, key) = fixture("unsafe-permissions"); + let original = seed(&repository, metadata, &key, &record(1)); + std::fs::set_permissions(repository.path(), std::fs::Permissions::from_mode(0o640)) + .unwrap(); + assert_eq!( + repository.open_json::().unwrap_err(), + RepositoryFailure::PermissionDenied + ); + assert_eq!(std::fs::read(repository.path()).unwrap(), original); + + std::fs::set_permissions(repository.path(), std::fs::Permissions::from_mode(0o600)) + .unwrap(); + let hardlink = repository.path().with_extension("hardlink"); + std::fs::hard_link(repository.path(), &hardlink).unwrap(); + assert_eq!( + repository.open_json::().unwrap_err(), + RepositoryFailure::PermissionDenied + ); + assert_eq!(std::fs::read(&hardlink).unwrap(), original); + std::fs::remove_file(hardlink).unwrap(); + + let parent = repository.path().parent().unwrap(); + let original_parent_mode = std::fs::metadata(parent).unwrap().permissions().mode(); + std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o770)).unwrap(); + assert_eq!( + repository.open_json::().unwrap_err(), + RepositoryFailure::PermissionDenied + ); + assert_eq!(std::fs::read(repository.path()).unwrap(), original); + std::fs::set_permissions( + parent, + std::fs::Permissions::from_mode(original_parent_mode), + ) + .unwrap(); + std::fs::remove_dir_all(parent).unwrap(); + } + + #[cfg(unix)] + #[test] + fn canonical_parent_and_artifact_symlinks_are_rejected() { + use std::os::unix::fs::symlink; + + let (repository, _, metadata, key) = fixture("symlink-file"); + let target = repository.path().with_extension("target"); + let original = crypto::seal( + &serde_json::to_vec(&record(1)).unwrap(), + metadata.header_at(1), + &key, + &FixedRandom(3), + ) + .unwrap(); + std::fs::write(&target, &original).unwrap(); + symlink(&target, repository.path()).unwrap(); + assert_eq!( + repository.open_json::().unwrap_err(), + RepositoryFailure::SymlinkRejected + ); + assert_eq!(std::fs::read(&target).unwrap(), original); + std::fs::remove_dir_all(repository.path().parent().unwrap()).unwrap(); + + let path = temp_path("symlink-parent-real"); + let real_parent = path.parent().unwrap().to_path_buf(); + let link_parent = real_parent.with_extension("link"); + symlink(&real_parent, &link_parent).unwrap(); + let linked = SiteRepository::injected( + link_parent.join("store.json"), + Arc::new(MemoryKeys::default()), + Arc::new(FixedRandom(4)), + Arc::new(NoFaults), + ); + assert_eq!( + linked.open_json::().unwrap_err(), + RepositoryFailure::SymlinkRejected + ); + std::fs::remove_file(&link_parent).unwrap(); + std::fs::remove_dir_all(&real_parent).unwrap(); + + let (repository, _, mut metadata, key) = fixture("symlink-lock"); + let original = seed(&repository, metadata, &key, &record(1)); + let lock_target = repository.path().with_extension("lock-target"); + std::fs::write(&lock_target, b"do not touch").unwrap(); + let lock_path = repository.path().parent().unwrap().join(".store.json.lock"); + symlink(&lock_target, &lock_path).unwrap(); + assert_eq!( + repository.commit_json(&mut metadata, 2, &record(2)), + Err(RepositoryFailure::SymlinkRejected) + ); + assert_eq!(std::fs::read(repository.path()).unwrap(), original); + assert_eq!(std::fs::read(lock_target).unwrap(), b"do not touch"); + std::fs::remove_dir_all(repository.path().parent().unwrap()).unwrap(); + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_reparse_points_and_hardlinks_are_rejected() { + let (repository, _, mut metadata, key) = fixture("windows-link-count"); + seed(&repository, metadata, &key, &record(1)); + + let canonical_hardlink = repository.path().with_extension("hardlink"); + std::fs::hard_link(repository.path(), &canonical_hardlink).unwrap(); + assert_eq!( + repository.open_json::().unwrap_err(), + RepositoryFailure::PermissionDenied + ); + std::fs::remove_file(canonical_hardlink).unwrap(); + + assert_eq!(repository.commit_json(&mut metadata, 2, &record(2)), Ok(2)); + let lock_path = repository.path().parent().unwrap().join(".store.json.lock"); + let lock_hardlink = repository.path().parent().unwrap().join("lock-hardlink"); + std::fs::hard_link(&lock_path, &lock_hardlink).unwrap(); + assert_eq!( + repository.commit_json(&mut metadata, 3, &record(3)), + Err(RepositoryFailure::SymlinkRejected) + ); + assert_eq!(metadata.revision, 2); + std::fs::remove_file(lock_hardlink).unwrap(); + let RepositoryOpen::Open(opened) = repository.open_json::().unwrap() else { + panic!("hardlink rejection changed the canonical repository") + }; + assert_eq!(opened.value, record(2)); + std::fs::remove_dir_all(repository.path().parent().unwrap()).unwrap(); + + let real_path = temp_path("windows-reparse-real"); + let real_parent = real_path.parent().unwrap().to_path_buf(); + let junction_parent = real_parent.with_extension("junction"); + let status = std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(&junction_parent) + .arg(&real_parent) + .status() + .unwrap(); + assert!(status.success(), "failed to create disposable junction"); + let linked = SiteRepository::injected( + junction_parent.join("store.json"), + Arc::new(MemoryKeys::default()), + Arc::new(FixedRandom(4)), + Arc::new(NoFaults), + ); + assert_eq!( + linked.open_json::().unwrap_err(), + RepositoryFailure::SymlinkRejected + ); + std::fs::remove_dir(&junction_parent).unwrap(); + std::fs::remove_dir_all(&real_parent).unwrap(); + } + + #[test] + fn interrupted_temp_or_backup_artifact_forces_recovery_without_mutation() { + let (repository, _, mut metadata, key) = fixture("artifact"); + let original = seed(&repository, metadata, &key, &record(1)); + let artifact = repository + .path() + .parent() + .unwrap() + .join(".store.json.site02-tmp-crash"); + std::fs::write(&artifact, b"ciphertext fragment").unwrap(); + assert_eq!( + repository.open_json::().unwrap_err(), + RepositoryFailure::InterruptedPublication + ); + assert_eq!( + repository.commit_json(&mut metadata, 2, &record(2)), + Err(RepositoryFailure::InterruptedPublication) + ); + assert_eq!(metadata.revision, 1); + assert_eq!(std::fs::read(repository.path()).unwrap(), original); + assert_eq!(std::fs::read(&artifact).unwrap(), b"ciphertext fragment"); + assert_eq!( + std::fs::read_dir(repository.path().parent().unwrap()) + .unwrap() + .count(), + 2 + ); + std::fs::remove_dir_all(repository.path().parent().unwrap()).unwrap(); + } + + #[test] + fn migration_requires_explicit_consent_before_reading_keys_or_writing() { + let (repository, _, metadata, source) = migration_target("migration-consent"); + let original = serde_json::to_vec(&record(1)).unwrap(); + std::fs::write(&source, &original).unwrap(); + let legacy = LegacyKeys::available([9; 32]); + assert_eq!( + repository.migrate_legacy_json::( + &source, + MigrationConsent::Denied, + &legacy, + metadata, + ), + Err(RepositoryFailure::MigrationConsentRequired) + ); + assert_eq!(legacy.calls(), 0); + assert!(!repository.path().exists()); + assert_eq!(std::fs::read(&source).unwrap(), original); + std::fs::remove_dir_all(repository.path().parent().unwrap()).unwrap(); + } + + #[test] + fn consented_plaintext_migration_encrypts_new_path_and_preserves_original() { + let (repository, _, metadata, source) = migration_target("migration-plaintext"); + let original = serde_json::to_vec(&record(1)).unwrap(); + std::fs::write(&source, &original).unwrap(); + let legacy = LegacyKeys::available([9; 32]); + let outcome = repository + .migrate_legacy_json::(&source, MigrationConsent::Explicit, &legacy, metadata) + .unwrap(); + assert!(outcome.original_preserved); + assert_eq!(outcome.metadata.revision, 1); + assert_eq!( + legacy.calls(), + 0, + "plaintext input needs no historical key lookup" + ); + assert_eq!(std::fs::read(&source).unwrap(), original); + let target = std::fs::read(repository.path()).unwrap(); + assert!(target.starts_with(b"NIGIG2")); + assert!(!String::from_utf8_lossy(&target).contains("Worker ID 12345678")); + std::fs::remove_dir_all(repository.path().parent().unwrap()).unwrap(); + } + + #[test] + fn historical_encrypted_migration_key_failure_never_creates_a_replacement() { + let (repository, _, metadata, source) = migration_target("migration-key-missing"); + let original = legacy_encrypted(&serde_json::to_vec(&record(1)).unwrap(), &[9; 32]); + std::fs::write(&source, &original).unwrap(); + let legacy = LegacyKeys::failing(KeyLookupFailure::Missing); + assert_eq!( + repository.migrate_legacy_json::( + &source, + MigrationConsent::Explicit, + &legacy, + metadata, + ), + Err(RepositoryFailure::KeyMissing) + ); + assert_eq!(legacy.calls(), 1); + assert!(!repository.path().exists()); + assert_eq!(std::fs::read(&source).unwrap(), original); + assert_eq!( + std::fs::read_dir(repository.path().parent().unwrap()) + .unwrap() + .count(), + 1, + "only the preserved source may exist" + ); + std::fs::remove_dir_all(repository.path().parent().unwrap()).unwrap(); + } + + #[test] + fn historical_encrypted_migration_uses_existing_key_once_and_verifies_target() { + let (repository, _, metadata, source) = migration_target("migration-encrypted"); + let original = legacy_encrypted(&serde_json::to_vec(&record(7)).unwrap(), &[9; 32]); + std::fs::write(&source, &original).unwrap(); + let legacy = LegacyKeys::available([9; 32]); + let outcome = repository + .migrate_legacy_json::(&source, MigrationConsent::Explicit, &legacy, metadata) + .unwrap(); + assert_eq!(legacy.calls(), 1); + assert!(outcome.original_preserved); + assert_eq!(std::fs::read(&source).unwrap(), original); + let RepositoryOpen::Open(opened) = repository.open_json::().unwrap() else { + panic!("expected verified migration target") + }; + assert_eq!(opened.value, record(7)); + assert_eq!(opened.metadata.revision, 1); + std::fs::remove_dir_all(repository.path().parent().unwrap()).unwrap(); + } + + #[test] + fn writer_coalesces_to_one_pending_snapshot_and_flushes_latest_revision() { + let (repository, _, metadata, key) = fixture("writer"); + seed(&repository, metadata, &key, &record(1)); + let writer = RepositoryWriter::start(repository.clone(), metadata).unwrap(); + let mut latest = 0; + for count in 2..=100 { + latest = writer.submit(record(count)).unwrap(); + assert!(writer.health().pending_depth <= 1); + } + assert_eq!(writer.flush(Duration::from_secs(10)).unwrap(), latest); + let health = writer.health(); + assert_eq!(health.accepted_revision, health.durable_revision); + assert!(!health.has_unsaved_changes()); + let RepositoryOpen::Open(opened) = repository.open_json::().unwrap() else { + panic!("expected document") + }; + assert_eq!(opened.value.count, 100); + assert_eq!( + writer.flush_and_shutdown(Duration::from_secs(10)), + Ok(latest) + ); + std::fs::remove_dir_all(repository.path().parent().unwrap()).unwrap(); + } + + #[test] + fn writer_failure_is_sticky_visible_and_leaves_unsaved_revision() { + let (base, keys, metadata, key) = fixture("writer-failure"); + let original = seed(&base, metadata, &key, &record(1)); + let repository = SiteRepository::injected( + base.path.clone(), + keys, + Arc::new(FixedRandom(4)), + Arc::new(OneFault::at(FaultStage::Write)), + ); + let writer = RepositoryWriter::start(repository.clone(), metadata).unwrap(); + assert_eq!(writer.submit(record(2)), Ok(2)); + assert_eq!( + writer.flush(Duration::from_secs(10)), + Err(RepositoryFailure::WriteFailed) + ); + let health = writer.health(); + assert_eq!(health.accepted_revision, 2); + assert_eq!(health.durable_revision, 1); + assert!(health.has_unsaved_changes()); + assert_eq!(health.last_failure, Some(RepositoryFailure::WriteFailed)); + assert_eq!( + writer.submit(record(3)), + Err(RepositoryFailure::WriteFailed) + ); + assert_eq!(std::fs::read(repository.path()).unwrap(), original); + assert_eq!( + writer.flush_and_shutdown(Duration::from_secs(10)), + Err(RepositoryFailure::WriteFailed) + ); + std::fs::remove_dir_all(repository.path().parent().unwrap()).unwrap(); + } + + #[test] + fn dropping_writer_wakes_and_stops_idle_worker() { + let (repository, _, metadata, key) = fixture("writer-drop"); + seed(&repository, metadata, &key, &record(1)); + let writer = RepositoryWriter::::start(repository.clone(), metadata).unwrap(); + let shared = Arc::clone(&writer.shared); + drop(writer); + let deadline = Instant::now() + Duration::from_secs(2); + let mut state = shared.state.lock().unwrap(); + while !state.thread_done { + let remaining = deadline + .checked_duration_since(Instant::now()) + .expect("dropped writer did not stop its worker"); + let (next, wait) = shared.wake.wait_timeout(state, remaining).unwrap(); + state = next; + assert!(!wait.timed_out() || state.thread_done); + } + drop(state); + std::fs::remove_dir_all(repository.path().parent().unwrap()).unwrap(); + } + + #[test] + fn shutdown_fault_is_explicit_and_never_claims_a_drain() { + let (base, keys, metadata, key) = fixture("shutdown-fault"); + seed(&base, metadata, &key, &record(1)); + let repository = SiteRepository::injected( + base.path.clone(), + keys, + Arc::new(FixedRandom(4)), + Arc::new(OneFault::at(FaultStage::Shutdown)), + ); + let writer = RepositoryWriter::start(repository.clone(), metadata).unwrap(); + assert_eq!(writer.submit(record(2)), Ok(2)); + assert_eq!( + writer.flush_and_shutdown(Duration::from_secs(10)), + Err(RepositoryFailure::ShutdownTimeout) + ); + // The failed shutdown injection occurs before closure; explicitly close + // on the second invocation so the test never strands a worker thread. + assert_eq!(writer.flush_and_shutdown(Duration::from_secs(10)), Ok(2)); + std::fs::remove_dir_all(repository.path().parent().unwrap()).unwrap(); + } + + #[test] + fn no_plaintext_appears_in_canonical_temp_or_backup_files() { + let (repository, _, mut metadata, key) = fixture("plaintext-scan"); + seed(&repository, metadata, &key, &record(1)); + repository + .commit_json(&mut metadata, 2, &record(2)) + .unwrap(); + for entry in std::fs::read_dir(repository.path().parent().unwrap()).unwrap() { + let path = entry.unwrap().path(); + if path.is_file() { + let bytes = std::fs::read(path).unwrap(); + assert!(!String::from_utf8_lossy(&bytes).contains("Worker ID 12345678")); + assert!(!String::from_utf8_lossy(&bytes).contains("Confidential Sentinel")); + } + } + std::fs::remove_dir_all(repository.path().parent().unwrap()).unwrap(); + } +} diff --git a/crates/apps/nigig-site/src/scheduler.rs b/crates/apps/nigig-site/src/scheduler.rs index 057b369..450a6cb 100644 --- a/crates/apps/nigig-site/src/scheduler.rs +++ b/crates/apps/nigig-site/src/scheduler.rs @@ -18,10 +18,12 @@ pub fn daily_eod_fire_at( eod_local_hour: u32, now: chrono::DateTime, ) -> chrono::DateTime { - let firing_hour = eod_local_hour.saturating_sub(1); + // Clamp external configuration before constructing a wall-clock time; an + // invalid hour must never panic an event handler. + let firing_hour = eod_local_hour.clamp(1, 24) - 1; let firing_naive = chrono::NaiveDateTime::new( date, - chrono::NaiveTime::from_hms_opt(firing_hour, 0, 0).unwrap(), + chrono::NaiveTime::from_hms_opt(firing_hour, 0, 0).expect("clamped hour is in 0..=23"), ); let convert = |n: chrono::NaiveDateTime| { eat() @@ -40,7 +42,7 @@ pub fn daily_eod_fire_at( /// Record an encrypted local reminder. SITE-01 does not register it with an /// operating-system scheduler or post a notification. -pub fn schedule_daily_eod(site_id: &str, date: chrono::NaiveDate, eod_local_hour: u32) { +pub fn schedule_daily_eod(site_id: &str, date: chrono::NaiveDate, eod_local_hour: u32) -> bool { // Reminder time is one hour before end of work in fixed EAT wall time. // 17:00 EAT EOD → 16:00 EAT = 13:00 UTC. let fire_at = daily_eod_fire_at(date, eod_local_hour, Utc::now()); @@ -50,15 +52,15 @@ pub fn schedule_daily_eod(site_id: &str, date: chrono::NaiveDate, eod_local_hour fire_at, ); r.site_id = Some(site_id.to_string()); - SiteStore::mutate(|s| s.push_reminder(r)); + SiteStore::mutate_scoped(site_id, |s| s.push_reminder(r)) } /// Record an encrypted local reminder for a scheduled meeting without /// registering or firing an operating-system notification. -pub fn schedule_monthly_before_meeting(site_id: &str, meeting_at: chrono::DateTime) { +pub fn schedule_monthly_before_meeting(site_id: &str, meeting_at: chrono::DateTime) -> bool { let mut r = crate::domain::reminders::Reminder::monthly_report_before(meeting_at); r.site_id = Some(site_id.to_string()); - SiteStore::mutate(|s| s.push_reminder(r)); + SiteStore::mutate_scoped(site_id, |s| s.push_reminder(r)) } #[cfg(test)] @@ -95,11 +97,8 @@ mod tests { } #[test] fn schedules_and_fires() { - // Hermetic + serialized against other env-touching tests via the - // shared guard (which also drains the writer before restoring env). - let dir = std::env::temp_dir().join(format!("nigig-site-sched-{}", std::process::id())); - let _guard = - crate::store::TempStorePath::set("NIGIG_SITE_STORE_PATH", &dir.join("store.json")); + // Pure state transition: persistence integration is covered by the + // repository/store suites rather than process-global environment state. let mut s = SiteStore { ..Default::default() }; @@ -114,6 +113,5 @@ mod tests { assert_eq!(s.due_reminders(now).len(), 1); s.mark_fired(&id); assert!(s.due_reminders(now).is_empty()); - let _ = std::fs::remove_dir_all(&dir); } } diff --git a/crates/apps/nigig-site/src/site_frame/screens/approvals.rs b/crates/apps/nigig-site/src/site_frame/screens/approvals.rs index 3fbf250..8dcbeb1 100644 --- a/crates/apps/nigig-site/src/site_frame/screens/approvals.rs +++ b/crates/apps/nigig-site/src/site_frame/screens/approvals.rs @@ -295,8 +295,7 @@ impl Widget for ApprovalsPage { .text() .trim() .to_string(); - let Some(site_id) = crate::store::SiteStore::read().selected_site_id_or_first() - else { + let Some(site_id) = crate::store::SiteStore::read().selected_site_id() else { self.view .label(cx, ids!(body.new_task_form.date_error)) .set_text(cx, "Create or select a real site first"); @@ -353,7 +352,16 @@ impl Widget for ApprovalsPage { notes: None, }); } - crate::store::SiteStore::mutate(|s| s.push_task(task)); + let accepted = + crate::store::SiteStore::mutate_scoped(&site_id, |store| store.push_task(task)); + if !accepted { + let message = crate::store::SiteStore::mutation_status(false); + self.view + .label(cx, ids!(body.new_task_form.date_error)) + .set_text(cx, &message); + self.view.redraw(cx); + return; + } self.editing_id = None; self.view .view(cx, ids!(body.new_task_form.edit_row)) @@ -382,9 +390,24 @@ impl Widget for ApprovalsPage { .clicked(actions) { if let Some(id) = self.editing_id.clone() { - crate::store::SiteStore::mutate(|s| { - s.tasks.retain(|t| t.id != id); + let Some(site_id) = crate::store::SiteStore::read().selected_site_id() else { + self.view + .label(cx, ids!(body.new_task_form.date_error)) + .set_text(cx, "Select the task's site before deleting it"); + self.view.redraw(cx); + return; + }; + let accepted = crate::store::SiteStore::mutate_scoped(&site_id, |store| { + store.tasks.retain(|task| task.id != id); }); + if !accepted { + let message = crate::store::SiteStore::mutation_status(false); + self.view + .label(cx, ids!(body.new_task_form.date_error)) + .set_text(cx, &message); + self.view.redraw(cx); + return; + } self.editing_id = None; self.view .view(cx, ids!(body.new_task_form.edit_row)) @@ -439,17 +462,24 @@ impl Widget for ApprovalsPage { .button(cx, ids!(body.template_card.template_btn)) .clicked(actions) { - let Some(site_id) = crate::store::SiteStore::read().selected_site_id_or_first() - else { + let Some(site_id) = crate::store::SiteStore::read().selected_site_id() else { self.view .label(cx, ids!(body.new_task_form.date_error)) .set_text(cx, "Create or select a real site first"); self.view.redraw(cx); return; }; - crate::store::SiteStore::mutate(|store| { + let accepted = crate::store::SiteStore::mutate_scoped(&site_id, |store| { store.generate_template_for_site(&site_id); }); + if !accepted { + let message = crate::store::SiteStore::mutation_status(false); + self.view + .label(cx, ids!(body.new_task_form.date_error)) + .set_text(cx, &message); + self.view.redraw(cx); + return; + } self.update_task_list(cx); self.view.redraw(cx); } @@ -531,11 +561,22 @@ impl ApprovalsPage { let Some(id) = self.editing_id.clone() else { return; }; - crate::store::SiteStore::mutate(|s| { - if let Some(task) = s.tasks.iter_mut().find(|t| t.id == id) { + let Some(site_id) = crate::store::SiteStore::read().selected_site_id() else { + return; + }; + let accepted = crate::store::SiteStore::mutate_scoped(&site_id, |store| { + if let Some(task) = store.tasks.iter_mut().find(|task| task.id == id) { task.status = status.clone(); } }); + if !accepted { + let message = crate::store::SiteStore::mutation_status(false); + self.view + .label(cx, ids!(body.new_task_form.date_error)) + .set_text(cx, &message); + self.view.redraw(cx); + return; + } self.update_task_list(cx); self.view.redraw(cx); } @@ -544,22 +585,35 @@ impl ApprovalsPage { let Some(id) = self.editing_id.clone() else { return; }; + let Some(site_id) = crate::store::SiteStore::read().selected_site_id() else { + return; + }; let mut found = false; let mut pending = false; - crate::store::SiteStore::mutate(|s| { - if let Some(task) = s.tasks.iter_mut().find(|t| t.id == id) { + let accepted = crate::store::SiteStore::mutate_scoped(&site_id, |store| { + if let Some(task) = store.tasks.iter_mut().find(|task| task.id == id) { found = true; - if let Some(insp) = task.inspections.iter_mut().find(|i| i.passed.is_none()) { + if let Some(inspection) = task + .inspections + .iter_mut() + .find(|inspection| inspection.passed.is_none()) + { pending = true; - insp.passed = Some(passed); - insp.inspected_at = Some(chrono::Utc::now()); + inspection.passed = Some(passed); + inspection.inspected_at = Some(chrono::Utc::now()); if passed { task.status = crate::domain::approvals::TaskStatus::Inspected; } } } }); - if found && pending { + if !accepted { + let message = crate::store::SiteStore::mutation_status(false); + self.view + .label(cx, ids!(body.new_task_form.date_error)) + .set_text(cx, &message); + self.view.redraw(cx); + } else if found && pending { self.update_task_list(cx); self.view.redraw(cx); } else if found { @@ -572,7 +626,7 @@ impl ApprovalsPage { fn reload_rows(&mut self) { let store = crate::store::SiteStore::read(); - let Some(site_id) = store.selected_site_id_or_first() else { + let Some(site_id) = store.selected_site_id() else { self.rows.clear(); return; }; diff --git a/crates/apps/nigig-site/src/site_frame/screens/chat.rs b/crates/apps/nigig-site/src/site_frame/screens/chat.rs index 6c82684..68f66b3 100644 --- a/crates/apps/nigig-site/src/site_frame/screens/chat.rs +++ b/crates/apps/nigig-site/src/site_frame/screens/chat.rs @@ -142,7 +142,7 @@ impl SiteChatPage { .set_text(cx, &rooms.join("\n")); let history = store - .selected_or_first() + .selected_site() .and_then(|site| site.chat_room_id) .map(|room| store.thread_lines(&room)) .unwrap_or_default(); diff --git a/crates/apps/nigig-site/src/site_frame/screens/meetings.rs b/crates/apps/nigig-site/src/site_frame/screens/meetings.rs index 6a6771e..cd842db 100644 --- a/crates/apps/nigig-site/src/site_frame/screens/meetings.rs +++ b/crates/apps/nigig-site/src/site_frame/screens/meetings.rs @@ -264,8 +264,7 @@ impl Widget for MeetingsPage { self.view.redraw(cx); return; }; - let Some(site_id) = crate::store::SiteStore::read().selected_site_id_or_first() - else { + let Some(site_id) = crate::store::SiteStore::read().selected_site_id() else { self.view .label(cx, ids!(body.resched_form.resched_error)) .set_text(cx, "Create or select a real site first"); @@ -273,20 +272,32 @@ impl Widget for MeetingsPage { return; }; let mut found: Option<(String, chrono::DateTime)> = None; - crate::store::SiteStore::mutate(|s| { - if let Some(m) = s - .meetings - .iter_mut() - .rev() - .find(|m| m.site_id == site_id && m.title.eq_ignore_ascii_case(&title)) - { - m.reschedule(new_at); - found = Some((m.site_id.clone(), m.scheduled_at)); + let accepted = crate::store::SiteStore::mutate_scoped(&site_id, |store| { + if let Some(meeting) = store.meetings.iter_mut().rev().find(|meeting| { + meeting.site_id == site_id && meeting.title.eq_ignore_ascii_case(&title) + }) { + meeting.reschedule(new_at); + found = Some((meeting.site_id.clone(), meeting.scheduled_at)); } }); + if !accepted { + let message = crate::store::SiteStore::mutation_status(false); + self.view + .label(cx, ids!(body.resched_form.resched_error)) + .set_text(cx, &message); + self.view.redraw(cx); + return; + } match found { Some((mid, mat)) => { - crate::scheduler::schedule_monthly_before_meeting(&mid, mat); + if !crate::scheduler::schedule_monthly_before_meeting(&mid, mat) { + let message = crate::store::SiteStore::mutation_status(false); + self.view + .label(cx, ids!(body.resched_form.resched_error)) + .set_text(cx, &message); + self.view.redraw(cx); + return; + } self.view .label(cx, ids!(body.resched_form.resched_error)) .set_text(cx, ""); @@ -318,16 +329,15 @@ impl Widget for MeetingsPage { if name.is_empty() { return; } - let Some(site_id) = crate::store::SiteStore::read().selected_site_id_or_first() - else { + let Some(site_id) = crate::store::SiteStore::read().selected_site_id() else { self.view .label(cx, ids!(body.live_card.stt_status)) .set_text(cx, "Create or select a real site first"); self.view.redraw(cx); return; }; - crate::store::SiteStore::mutate(|s| { - s.push_contact( + let accepted = crate::store::SiteStore::mutate_scoped(&site_id, |store| { + store.push_contact( &site_id, crate::domain::meetings::MeetingAttendee { user_id: uuid::Uuid::new_v4().to_string(), @@ -336,6 +346,14 @@ impl Widget for MeetingsPage { }, ); }); + if !accepted { + let message = crate::store::SiteStore::mutation_status(false); + self.view + .label(cx, ids!(body.live_card.stt_status)) + .set_text(cx, &message); + self.view.redraw(cx); + return; + } self.view .text_input(cx, ids!(body.project_dir_card.dir_row.dir_name_input)) .set_text(cx, ""); @@ -369,8 +387,7 @@ impl Widget for MeetingsPage { self.view.redraw(cx); return; }; - let Some(site_id) = crate::store::SiteStore::read().selected_site_id_or_first() - else { + let Some(site_id) = crate::store::SiteStore::read().selected_site_id() else { self.view .label(cx, ids!(body.schedule_form.schedule_error)) .set_text(cx, "Create or select a real site first"); @@ -404,9 +421,30 @@ impl Widget for MeetingsPage { email: None, }); } - crate::store::SiteStore::mutate(|s| s.push_meeting(m.clone())); - // 72h reminder for monthly report review (item 8) + meeting reminder - crate::scheduler::schedule_monthly_before_meeting(&m.site_id, m.scheduled_at); + let meeting_site_id = m.site_id.clone(); + let accepted = crate::store::SiteStore::mutate_scoped(&meeting_site_id, |store| { + store.push_meeting(m.clone()) + }); + if !accepted { + let message = crate::store::SiteStore::mutation_status(false); + self.view + .label(cx, ids!(body.schedule_form.schedule_error)) + .set_text(cx, &message); + self.view.redraw(cx); + return; + } + // 72h reminder for monthly report review (item 8) + meeting reminder. + if !crate::scheduler::schedule_monthly_before_meeting( + &meeting_site_id, + m.scheduled_at, + ) { + let message = crate::store::SiteStore::mutation_status(false); + self.view + .label(cx, ids!(body.schedule_form.schedule_error)) + .set_text(cx, &message); + self.view.redraw(cx); + return; + } self.view .view(cx, ids!(body.schedule_form)) .set_visible(cx, false); @@ -500,7 +538,7 @@ impl Widget for MeetingsPage { impl MeetingsPage { fn update_meetings_list(&mut self, cx: &mut Cx) { let store = crate::store::SiteStore::read(); - let Some(site_id) = store.selected_site_id_or_first() else { + let Some(site_id) = store.selected_site_id() else { self.rows.clear(); self.update_directory(cx); return; @@ -528,7 +566,7 @@ impl MeetingsPage { fn update_directory(&mut self, cx: &mut Cx) { let store = crate::store::SiteStore::read(); let names = store - .selected_site_id_or_first() + .selected_site_id() .map(|site_id| store.directory_names(&site_id)) .unwrap_or_default(); let dir_text = if names.is_empty() { diff --git a/crates/apps/nigig-site/src/site_frame/screens/more_hub.rs b/crates/apps/nigig-site/src/site_frame/screens/more_hub.rs index 7efb608..8ac0ea9 100644 --- a/crates/apps/nigig-site/src/site_frame/screens/more_hub.rs +++ b/crates/apps/nigig-site/src/site_frame/screens/more_hub.rs @@ -102,10 +102,10 @@ impl Widget for MoreHubPage { let access = crate::store::SiteStore::access_state(); let text = match access { crate::store::StoreAccess::ReadyEncrypted => { - "🔒 Existing store is encrypted; confidential writes are enabled." + "🔒 Existing NIGIG2 repository opened with its bound native key." } crate::store::StoreAccess::SetupRequired => { - "🔒 Confidential writes locked: secure first-run setup is not available yet." + "🔒 Setup and migration hard-locked: SITE-02 independent security review required." } crate::store::StoreAccess::RecoveryRequired(_) => { "⚠ Recovery required: original store preserved; all writes are locked." @@ -114,9 +114,14 @@ impl Widget for MoreHubPage { "⚠ Storage failure: last durable revision preserved; all writes are locked." } }; + let text = format!( + "{} {}", + text, + crate::store::SiteStore::persistence_health().status_line() + ); self.view .label(cx.cx, ids!(crypto_text)) - .set_text(cx.cx, text); + .set_text(cx.cx, &text); self.view.draw_walk(cx, scope, walk) } } diff --git a/crates/apps/nigig-site/src/site_frame/screens/procurement.rs b/crates/apps/nigig-site/src/site_frame/screens/procurement.rs index 2fa8c4d..0c452ee 100644 --- a/crates/apps/nigig-site/src/site_frame/screens/procurement.rs +++ b/crates/apps/nigig-site/src/site_frame/screens/procurement.rs @@ -249,7 +249,7 @@ impl Widget for ProcurementPage { self.view.redraw(cx); return; }; - let Some(site_id) = store.selected_site_id_or_first() else { + let Some(site_id) = store.selected_site_id() else { self.view .label(cx, ids!(body.material_form.mat_error)) .set_text(cx, "Create or select a real site first"); @@ -267,7 +267,17 @@ impl Widget for ProcurementPage { ); line.supplier_id = supplier_id; sched.lines.push(line); - crate::store::SiteStore::mutate(|s| s.push_procurement(sched)); + let accepted = crate::store::SiteStore::mutate_scoped(&site_id, |store| { + store.push_procurement(sched) + }); + if !accepted { + let message = crate::store::SiteStore::mutation_status(false); + self.view + .label(cx, ids!(body.material_form.mat_error)) + .set_text(cx, &message); + self.view.redraw(cx); + return; + } self.view .label(cx, ids!(body.material_form.mat_error)) .set_text(cx, ""); @@ -370,8 +380,15 @@ impl Widget for ProcurementPage { "factory" => crate::domain::procurement::SupplierKind::Factory, _ => crate::domain::procurement::SupplierKind::Other(kind_raw), }; - crate::store::SiteStore::mutate(|s| { - s.push_supplier(crate::domain::procurement::Supplier { + let Some(site_id) = crate::store::SiteStore::read().selected_site_id() else { + self.view + .label(cx, ids!(body.material_form.mat_error)) + .set_text(cx, "Select an existing site before changing suppliers"); + self.view.redraw(cx); + return; + }; + let accepted = crate::store::SiteStore::mutate_scoped(&site_id, |store| { + store.push_supplier(crate::domain::procurement::Supplier { id: uuid::Uuid::new_v4().to_string(), name, kind, @@ -380,6 +397,14 @@ impl Widget for ProcurementPage { address: None, }); }); + if !accepted { + let message = crate::store::SiteStore::mutation_status(false); + self.view + .label(cx, ids!(body.material_form.mat_error)) + .set_text(cx, &message); + self.view.redraw(cx); + return; + } self.view .view(cx, ids!(body.directory_card.supplier_form)) .set_visible(cx, false); @@ -444,7 +469,7 @@ impl ProcurementPage { }) .collect(); let schedule = store - .selected_site_id_or_first() + .selected_site_id() .and_then(|site_id| store.procurement_for(&site_id)) .map(|sched| { sched diff --git a/crates/apps/nigig-site/src/site_frame/screens/report_editor.rs b/crates/apps/nigig-site/src/site_frame/screens/report_editor.rs index 64e69e9..2e04ebf 100644 --- a/crates/apps/nigig-site/src/site_frame/screens/report_editor.rs +++ b/crates/apps/nigig-site/src/site_frame/screens/report_editor.rs @@ -270,8 +270,7 @@ impl Widget for ReportEditorPage { self.list_kind, ); let ws = self.view.text_input(cx, ids!(workstation_input)).text(); - let Some(site_id) = crate::store::SiteStore::read().selected_site_id_or_first() - else { + let Some(site_id) = crate::store::SiteStore::read().selected_site_id() else { self.view .label(cx, ids!(refined_preview.refined_text)) .set_text(cx, "Select an existing site before saving."); @@ -284,17 +283,13 @@ impl Widget for ReportEditorPage { let date = chrono::Local::now().date_naive(); let mut entry = crate::domain::daily_report::DailyTaskEntry::new(title, rich); entry.work_station = if ws.trim().is_empty() { None } else { Some(ws) }; - let saved = crate::store::SiteStore::mutate(|store| { + let accepted = crate::store::SiteStore::mutate_scoped(&site_id, |store| { store.push_task_entry(&site_id, date, entry) }); - let message = if saved { - "Draft saved to encrypted local storage." - } else { - crate::store::SiteStore::access_state().message() - }; + let message = crate::store::SiteStore::mutation_status(accepted); self.view .label(cx, ids!(refined_preview.refined_text)) - .set_text(cx, message); + .set_text(cx, &message); self.view .view(cx, ids!(refined_preview)) .set_visible(cx, true); diff --git a/crates/apps/nigig-site/src/site_frame/screens/reports.rs b/crates/apps/nigig-site/src/site_frame/screens/reports.rs index 6c9defe..a6c9409 100644 --- a/crates/apps/nigig-site/src/site_frame/screens/reports.rs +++ b/crates/apps/nigig-site/src/site_frame/screens/reports.rs @@ -229,7 +229,7 @@ impl SiteReportsPage { let store = crate::store::SiteStore::read(); let today = chrono::Local::now().date_naive(); - let selected = store.selected_or_first(); + let selected = store.selected_site(); let (daily, monthly) = if let Some(site) = selected { let site_reports = store.reports_for_site(&site.id); let today_report = store.report_for_day(&site.id, today); diff --git a/crates/apps/nigig-site/src/site_frame/screens/sites.rs b/crates/apps/nigig-site/src/site_frame/screens/sites.rs index ce56658..95474dc 100644 --- a/crates/apps/nigig-site/src/site_frame/screens/sites.rs +++ b/crates/apps/nigig-site/src/site_frame/screens/sites.rs @@ -54,7 +54,8 @@ script_mod! { show_bg: true draw_bg +: { color: #xFFFFFF border_radius: 14.0 border_size: 1.0 border_color: #xE2E8F0 } Label { text: "New construction site" draw_text +: { color: #x1C274C text_style: theme.font_bold { font_size: 12.0 } } } - site_name_input := TextInput { width: Fill, height: 36, empty_text: "Site name — e.g. Muthaiga Villas Phase 2" draw_bg +: { color: #xF8FAFC border_radius: 10.0 border_size: 1.0 border_color: #xE2E8F0 } } + site_status := Label { width: Fill, text: "" draw_text +: { color: #xB91C1C text_style: theme.font_bold { font_size: 10.0 } } } + site_name_input := TextInput { width: Fill, height: 36, empty_text: "Site name" draw_bg +: { color: #xF8FAFC border_radius: 10.0 border_size: 1.0 border_color: #xE2E8F0 } } site_address_input := TextInput { width: Fill, height: 36, empty_text: "Address / area — e.g. Nairobi, KE" draw_bg +: { color: #xF8FAFC border_radius: 10.0 border_size: 1.0 border_color: #xE2E8F0 } } nature_row := View { width: Fill, height: Fit @@ -111,9 +112,18 @@ impl Widget for SiteListPage { .is_some_and(|fe| fe.was_tap()) { if let Some(row) = self.rows.get(item_id).cloned() { - crate::store::SiteStore::mutate(|s| { - s.apply_selected(&row.id); + let accepted = crate::store::SiteStore::mutate_profile(|store| { + store.apply_selected(&row.id); }); + if !accepted { + let message = crate::store::SiteStore::mutation_status(false); + self.view + .label(cx, ids!(new_site_form.site_status)) + .set_text(cx, &message); + self.view + .view(cx, ids!(new_site_form)) + .set_visible(cx, true); + } self.refresh_rows(cx, None); self.view.redraw(cx); } @@ -181,10 +191,18 @@ impl Widget for SiteListPage { }; let site = crate::domain::site::Site::new(name, nature, addr); let new_id = site.id.clone(); - crate::store::SiteStore::mutate(|s| { - s.push_site(site); - s.apply_selected(&new_id); + let accepted = crate::store::SiteStore::mutate_profile(|store| { + store.push_site(site); + store.apply_selected(&new_id); }); + if !accepted { + let message = crate::store::SiteStore::mutation_status(false); + self.view + .label(cx, ids!(new_site_form.site_status)) + .set_text(cx, &message); + self.view.redraw(cx); + return; + } self.refresh_rows(cx, None); self.view .view(cx, ids!(new_site_form)) @@ -245,7 +263,7 @@ impl SiteListPage { .unwrap_or_else(|| self.last_search.clone()) .to_lowercase(); let store = crate::store::SiteStore::read(); - let selected = store.selected_site_id_or_first(); + let selected = store.selected_site_id(); self.rows = store .sites .iter() diff --git a/crates/apps/nigig-site/src/site_frame/screens/workers.rs b/crates/apps/nigig-site/src/site_frame/screens/workers.rs index 3c46ac5..47208ca 100644 --- a/crates/apps/nigig-site/src/site_frame/screens/workers.rs +++ b/crates/apps/nigig-site/src/site_frame/screens/workers.rs @@ -123,7 +123,7 @@ impl Widget for WorkersPage { if self.rows.is_empty() { let store = crate::store::SiteStore::read(); let date = chrono::Local::now().date_naive(); - if let Some(site) = store.selected_or_first() { + if let Some(site) = store.selected_site() { if let Some(table) = store .workers .iter() diff --git a/crates/apps/nigig-site/src/store.rs b/crates/apps/nigig-site/src/store.rs index ca15884..ce4746a 100644 --- a/crates/apps/nigig-site/src/store.rs +++ b/crates/apps/nigig-site/src/store.rs @@ -1,15 +1,16 @@ -//! SITE-01 fail-closed encrypted store containment. +//! SITE-02 fail-closed runtime store over the recoverable encrypted repository. //! -//! The process opens one existing authenticated envelope into a canonical -//! in-memory revision. `SiteStore::mutate` is the only production write path: -//! it persists a cloned candidate synchronously and publishes it only after the -//! encrypted replacement is durable. Any open/write/key failure preserves the -//! prior bytes and disables confidential writes. +//! Mutations are applied to one canonical in-memory snapshot and accepted only +//! into a single-slot coalescing writer. Persistence health reports accepted, +//! durable, pending and explicitly-unsaved revisions. Setup/migration remain +//! hard-locked pending independent security approval. -use std::path::{Path, PathBuf}; +use std::path::PathBuf; +use std::time::Duration; use chrono::NaiveDate; use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; use crate::domain::approvals::ConstructionTask; use crate::domain::daily_report::DailyReport; @@ -18,25 +19,49 @@ use crate::domain::procurement::{ProcurementSchedule, SupplierDirectory}; use crate::domain::reminders::Reminder; use crate::domain::site::Site; use crate::domain::workers::WorkersDailyTable; +use crate::repository::{ + RepositoryFailure, RepositoryOpen, RepositoryWriter, SiteRepository, WriterHealth, +}; const STORE_VERSION: u32 = 2; +#[cfg(test)] +const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum StoreFailure { EmptyFile, PermissionDenied, IoFailure, - KeyUnavailable, + SymlinkRejected, + InterruptedPublication, + KeyStoreUnavailable, + KeyMissing, InvalidKey, + RotatedKeyUnavailable, AuthenticationFailed, PlaintextMigrationRequired, + LegacyEncryptedMigrationRequired, InvalidEnvelope, + UnsupportedEnvelopeVersion, UnsupportedAlgorithm, + InputTooLarge, MalformedJson, + OlderVersionMigrationRequired, FutureVersion, SerializationFailed, + RandomUnavailable, + NonceInvocationLimit, EncryptionFailed, + RevisionConflict, + RepositoryBusy, DurableWriteFailed, + CanonicalReadbackFailed, + RollbackFailed, + InvalidScope, + WriterClosed, + ShutdownTimeout, + MigrationConsentRequired, + SecurityReviewRequired, } impl StoreFailure { @@ -45,21 +70,96 @@ impl StoreFailure { Self::EmptyFile => "SITE-STORE-EMPTY", Self::PermissionDenied => "SITE-STORE-PERMISSION", Self::IoFailure => "SITE-STORE-IO", - Self::KeyUnavailable => "SITE-STORE-KEY-UNAVAILABLE", - Self::InvalidKey => "SITE-STORE-KEY-INVALID", + Self::SymlinkRejected => "SITE-REPOSITORY-SYMLINK", + Self::InterruptedPublication => "SITE-REPOSITORY-INTERRUPTED", + Self::KeyStoreUnavailable => "SITE-KEYSTORE-UNAVAILABLE", + Self::KeyMissing => "SITE-KEY-MISSING", + Self::InvalidKey => "SITE-KEY-INVALID", + Self::RotatedKeyUnavailable => "SITE-ROTATED-KEY-UNAVAILABLE", Self::AuthenticationFailed => "SITE-STORE-AUTHENTICATION", - Self::PlaintextMigrationRequired => "SITE-STORE-MIGRATION-REQUIRED", + Self::PlaintextMigrationRequired => "SITE-STORE-PLAINTEXT-MIGRATION", + Self::LegacyEncryptedMigrationRequired => "SITE-STORE-LEGACY-MIGRATION", Self::InvalidEnvelope => "SITE-STORE-ENVELOPE", + Self::UnsupportedEnvelopeVersion => "SITE-STORE-FUTURE-ENVELOPE", Self::UnsupportedAlgorithm => "SITE-STORE-ALGORITHM", + Self::InputTooLarge => "SITE-STORE-LIMIT", Self::MalformedJson => "SITE-STORE-JSON", + Self::OlderVersionMigrationRequired => "SITE-STORE-OLDER-VERSION-MIGRATION", Self::FutureVersion => "SITE-STORE-FUTURE-VERSION", Self::SerializationFailed => "SITE-STORE-SERIALIZATION", + Self::RandomUnavailable => "SITE-RANDOM-UNAVAILABLE", + Self::NonceInvocationLimit => "SITE-NONCE-LIMIT", Self::EncryptionFailed => "SITE-STORE-ENCRYPTION", + Self::RevisionConflict => "SITE-STORE-REVISION-CONFLICT", + Self::RepositoryBusy => "SITE-STORE-BUSY", Self::DurableWriteFailed => "SITE-STORE-DURABILITY", + Self::CanonicalReadbackFailed => "SITE-STORE-READBACK", + Self::RollbackFailed => "SITE-STORE-ROLLBACK", + Self::InvalidScope => "SITE-STORE-SCOPE", + Self::WriterClosed => "SITE-WRITER-CLOSED", + Self::ShutdownTimeout => "SITE-WRITER-SHUTDOWN-TIMEOUT", + Self::MigrationConsentRequired => "SITE-MIGRATION-CONSENT-REQUIRED", + Self::SecurityReviewRequired => "SITE-02-SECURITY-REVIEW-REQUIRED", } } } +impl std::fmt::Display for StoreFailure { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.support_code()) + } +} + +impl std::error::Error for StoreFailure {} + +fn map_repository(failure: RepositoryFailure) -> StoreFailure { + match failure { + RepositoryFailure::PathUnavailable | RepositoryFailure::IoFailure => { + StoreFailure::IoFailure + } + RepositoryFailure::EmptyFile => StoreFailure::EmptyFile, + RepositoryFailure::PermissionDenied => StoreFailure::PermissionDenied, + RepositoryFailure::SymlinkRejected => StoreFailure::SymlinkRejected, + RepositoryFailure::InterruptedPublication => StoreFailure::InterruptedPublication, + RepositoryFailure::KeyStoreUnavailable => StoreFailure::KeyStoreUnavailable, + RepositoryFailure::KeyMissing => StoreFailure::KeyMissing, + RepositoryFailure::KeyInvalid => StoreFailure::InvalidKey, + RepositoryFailure::RotatedKeyUnavailable => StoreFailure::RotatedKeyUnavailable, + RepositoryFailure::AuthenticationFailed => StoreFailure::AuthenticationFailed, + RepositoryFailure::PlaintextMigrationRequired => StoreFailure::PlaintextMigrationRequired, + RepositoryFailure::LegacyEncryptedMigrationRequired => { + StoreFailure::LegacyEncryptedMigrationRequired + } + RepositoryFailure::InvalidEnvelope => StoreFailure::InvalidEnvelope, + RepositoryFailure::UnsupportedVersion => StoreFailure::UnsupportedEnvelopeVersion, + RepositoryFailure::UnsupportedAlgorithm => StoreFailure::UnsupportedAlgorithm, + RepositoryFailure::InputTooLarge => StoreFailure::InputTooLarge, + RepositoryFailure::MalformedJson => StoreFailure::MalformedJson, + RepositoryFailure::OlderSchemaVersion => StoreFailure::OlderVersionMigrationRequired, + RepositoryFailure::FutureSchemaVersion => StoreFailure::FutureVersion, + RepositoryFailure::SerializationFailed => StoreFailure::SerializationFailed, + RepositoryFailure::RandomUnavailable => StoreFailure::RandomUnavailable, + RepositoryFailure::NonceInvocationLimit => StoreFailure::NonceInvocationLimit, + RepositoryFailure::EncryptionFailed => StoreFailure::EncryptionFailed, + RepositoryFailure::RevisionConflict => StoreFailure::RevisionConflict, + RepositoryFailure::RepositoryBusy => StoreFailure::RepositoryBusy, + RepositoryFailure::CanonicalReadbackFailed => StoreFailure::CanonicalReadbackFailed, + RepositoryFailure::RollbackFailed => StoreFailure::RollbackFailed, + RepositoryFailure::WriterClosed => StoreFailure::WriterClosed, + RepositoryFailure::ShutdownTimeout => StoreFailure::ShutdownTimeout, + #[cfg(test)] + RepositoryFailure::MigrationConsentRequired => StoreFailure::MigrationConsentRequired, + RepositoryFailure::SecurityReviewRequired => StoreFailure::SecurityReviewRequired, + RepositoryFailure::TempCreateFailed + | RepositoryFailure::PermissionSetFailed + | RepositoryFailure::WriteFailed + | RepositoryFailure::FlushFailed + | RepositoryFailure::FileSyncFailed + | RepositoryFailure::RenameFailed + | RepositoryFailure::DirectorySyncFailed => StoreFailure::DurableWriteFailed, + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum StoreAccess { ReadyEncrypted, @@ -76,7 +176,7 @@ impl StoreAccess { pub const fn support_code(self) -> &'static str { match self { Self::ReadyEncrypted => "SITE-STORE-READY", - Self::SetupRequired => "SITE-STORE-SETUP-REQUIRED", + Self::SetupRequired => "SITE-02-SECURITY-REVIEW-REQUIRED", Self::RecoveryRequired(failure) | Self::ConfidentialWritesDisabled(failure) => { failure.support_code() } @@ -86,7 +186,7 @@ impl StoreAccess { pub const fn title(self) -> &'static str { match self { Self::ReadyEncrypted => "Encrypted storage ready", - Self::SetupRequired => "Secure storage setup required", + Self::SetupRequired => "Secure storage setup review required", Self::RecoveryRequired(_) => "Recovery required", Self::ConfidentialWritesDisabled(_) => "Confidential writes disabled", } @@ -94,76 +194,57 @@ impl StoreAccess { pub const fn message(self) -> &'static str { match self { - Self::ReadyEncrypted => "The existing encrypted store opened successfully.", + Self::ReadyEncrypted => { + "The authenticated encrypted repository opened with its exact existing key." + } Self::SetupRequired => { - "No store exists. Data entry stays locked until reviewed key setup lands in SITE-02." + "No repository exists. Setup and key creation remain locked until SITE-02 receives independent security approval." } Self::RecoveryRequired(_) => { - "The original store was preserved byte-for-byte. Nothing was seeded, replaced, or decrypted for support." + "The original repository and recovery artifacts were preserved byte-for-byte. No setup, migration, repair, or demo seeding ran." } Self::ConfidentialWritesDisabled(_) => { - "The last durable revision is preserved. Further confidential changes are blocked." + "The last durable revision is preserved. Accepted but unsaved changes are identified below; further confidential changes are blocked." } } } } -fn failure_from_crypto(error: crate::crypto::CryptoError) -> StoreFailure { - use crate::crypto::CryptoError; - match error { - CryptoError::KeyUnavailable => StoreFailure::KeyUnavailable, - CryptoError::InvalidKey => StoreFailure::InvalidKey, - CryptoError::AuthenticationFailed => StoreFailure::AuthenticationFailed, - CryptoError::PlaintextMigrationRequired => StoreFailure::PlaintextMigrationRequired, - CryptoError::InvalidEnvelope => StoreFailure::InvalidEnvelope, - CryptoError::UnsupportedAlgorithm => StoreFailure::UnsupportedAlgorithm, - CryptoError::RandomUnavailable | CryptoError::EncryptionFailed => { - StoreFailure::EncryptionFailed - } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PersistenceHealth { + pub accepted_revision: u64, + pub durable_revision: u64, + pub pending_depth: usize, + pub write_active: bool, + pub accepting: bool, + pub last_failure: Option, +} + +impl PersistenceHealth { + pub const fn has_unsaved_changes(self) -> bool { + self.accepted_revision > self.durable_revision } -} -fn access_cell() -> &'static std::sync::Mutex { - static ACCESS: std::sync::OnceLock> = std::sync::OnceLock::new(); - ACCESS.get_or_init(|| std::sync::Mutex::new(StoreAccess::SetupRequired)) -} - -fn current_access() -> StoreAccess { - *access_cell() - .lock() - .unwrap_or_else(|error| error.into_inner()) -} - -fn set_access(access: StoreAccess) { - *access_cell() - .lock() - .unwrap_or_else(|error| error.into_inner()) = access; -} - -#[cfg(test)] -fn mutate_pre_lock_hook_cell( -) -> &'static std::sync::Mutex>> { - static HOOK: std::sync::OnceLock>>> = - std::sync::OnceLock::new(); - HOOK.get_or_init(|| std::sync::Mutex::new(None)) -} - -#[cfg(test)] -fn run_mutate_pre_lock_hook() { - let hook = mutate_pre_lock_hook_cell() - .lock() - .unwrap_or_else(|error| error.into_inner()) - .take(); - if let Some(barrier) = hook { - barrier.wait(); + pub fn status_line(self) -> String { + if self.has_unsaved_changes() { + format!( + "Accepted revision {}; durable revision {}; UNSAVED changes remain (pending {}).", + self.accepted_revision, self.durable_revision, self.pending_depth + ) + } else { + format!( + "Accepted revision {}; durable revision {}; no unsaved changes.", + self.accepted_revision, self.durable_revision + ) + } } } #[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct SiteStore { pub version: u32, pub sites: Vec, - /// key: (site_id, date) pub reports: Vec, pub workers: Vec, pub reminders: Vec, @@ -175,14 +256,12 @@ pub struct SiteStore { pub suppliers: SupplierDirectory, #[serde(default)] pub meetings: Vec, - /// Currently selected site for all screens. Falls back to first site. + /// Device-local selection retained temporarily in the legacy aggregate. #[serde(default)] pub selected_site_id: Option, - /// Legacy locally stored chat lines per room (room_id → lines, capped). - /// SITE-01 exposes no production message composer, sender, or sync path. + /// Legacy local chat cache. Production has no composer or sync route. #[serde(default)] pub chat_threads: std::collections::BTreeMap>, - /// Project directory (item 19) — attendees and interested parties per site. #[serde(default)] pub directories: Vec, } @@ -200,22 +279,104 @@ impl Default for SiteStore { suppliers: SupplierDirectory::default(), meetings: Vec::new(), selected_site_id: None, - directories: Vec::new(), chat_threads: std::collections::BTreeMap::new(), + directories: Vec::new(), } } } +struct RuntimeStore { + value: SiteStore, + access: StoreAccess, + writer: Option>, +} + +impl RuntimeStore { + fn load() -> Self { + let path = match SiteStore::file_path() { + Ok(path) => path, + Err(failure) => { + return Self { + value: SiteStore::default(), + access: StoreAccess::RecoveryRequired(failure), + writer: None, + }; + } + }; + let repository = SiteRepository::runtime(path); + match repository.open_versioned_json::(STORE_VERSION) { + Ok(RepositoryOpen::Absent) => Self { + value: SiteStore::default(), + access: StoreAccess::SetupRequired, + writer: None, + }, + Ok(RepositoryOpen::Open(document)) => { + match RepositoryWriter::start(repository, document.metadata) { + Ok(writer) => Self { + value: document.value, + access: StoreAccess::ReadyEncrypted, + writer: Some(writer), + }, + Err(failure) => Self { + value: document.value, + access: StoreAccess::ConfidentialWritesDisabled(map_repository(failure)), + writer: None, + }, + } + } + Err(failure) => Self { + value: SiteStore::default(), + access: StoreAccess::RecoveryRequired(map_repository(failure)), + writer: None, + }, + } + } + + fn refresh_writer_failure(&mut self) { + let health = self.writer.as_ref().map(RepositoryWriter::health); + if let Some(failure) = health.and_then(|health| health.last_failure) { + self.access = StoreAccess::ConfidentialWritesDisabled(map_repository(failure)); + } else if health.is_some_and(|health| !health.accepting) { + self.access = StoreAccess::ConfidentialWritesDisabled(StoreFailure::WriterClosed); + } + } + + fn health(&self) -> PersistenceHealth { + self.writer + .as_ref() + .map_or_else(PersistenceHealth::default, |writer| { + let WriterHealth { + accepted_revision, + durable_revision, + pending_depth, + active, + accepting, + last_failure, + } = writer.health(); + PersistenceHealth { + accepted_revision, + durable_revision, + pending_depth, + write_active: active, + accepting, + last_failure: last_failure.map(map_repository), + } + }) + } +} + +fn runtime_cell() -> &'static std::sync::Mutex { + static RUNTIME: std::sync::OnceLock> = + std::sync::OnceLock::new(); + RUNTIME.get_or_init(|| std::sync::Mutex::new(RuntimeStore::load())) +} + impl SiteStore { fn file_path() -> Result { #[cfg(test)] - { - // Unit tests use an isolated path and can never redirect a - // production build through process environment configuration. - if let Ok(path) = std::env::var("NIGIG_SITE_STORE_PATH") { - if !path.is_empty() { - return Ok(PathBuf::from(path)); - } + if let Ok(path) = std::env::var("NIGIG_SITE_STORE_PATH") { + if !path.is_empty() { + return Ok(PathBuf::from(path)); } } let base = robius_directories::BaseDirs::new() @@ -225,189 +386,248 @@ impl SiteStore { Ok(base.join("nigig-site").join("store.json")) } - fn empty_read_only() -> Self { - Self { - version: STORE_VERSION, - sites: Vec::new(), - reports: Vec::new(), - workers: Vec::new(), - reminders: Vec::new(), - tasks: Vec::new(), - procurement: Vec::new(), - suppliers: SupplierDirectory::default(), - meetings: Vec::new(), - selected_site_id: None, - chat_threads: std::collections::BTreeMap::new(), - directories: Vec::new(), - } - } - - fn map_io(error: &std::io::Error) -> StoreFailure { - if error.kind() == std::io::ErrorKind::PermissionDenied { - StoreFailure::PermissionDenied - } else { - StoreFailure::IoFailure - } - } - - /// Open a store without mutating it. `Ok(None)` means first-run absence; - /// every other failure preserves the original bytes for recovery. - fn open_path(path: &Path) -> Result, StoreFailure> { - let envelope = match std::fs::read(path) { - Ok(bytes) => bytes, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(Self::map_io(&error)), - }; - if envelope.is_empty() { - return Err(StoreFailure::EmptyFile); - } - let json = crate::crypto::open_envelope(&envelope).map_err(failure_from_crypto)?; - let store: Self = serde_json::from_slice(&json).map_err(|_| StoreFailure::MalformedJson)?; - if store.version > STORE_VERSION { - return Err(StoreFailure::FutureVersion); - } - Ok(Some(store)) - } - - fn load() -> Self { - let path = match Self::file_path() { - Ok(path) => path, - Err(failure) => { - set_access(StoreAccess::RecoveryRequired(failure)); - return Self::empty_read_only(); - } - }; - match Self::open_path(&path) { - Ok(Some(store)) => { - set_access(StoreAccess::ReadyEncrypted); - store - } - Ok(None) => { - set_access(StoreAccess::SetupRequired); - Self::empty_read_only() - } - Err(failure) => { - set_access(StoreAccess::RecoveryRequired(failure)); - Self::empty_read_only() - } - } - } - - fn persist_to(&self, path: &Path) -> Result<(), StoreFailure> { - use std::io::Write; - use std::sync::atomic::{AtomicU64, Ordering}; - - static TEMP_ID: AtomicU64 = AtomicU64::new(0); - let parent = path.parent().ok_or(StoreFailure::DurableWriteFailed)?; - if !parent.is_dir() { - return Err(StoreFailure::DurableWriteFailed); - } - let json = - serde_json::to_vec_pretty(self).map_err(|_| StoreFailure::SerializationFailed)?; - let sealed = crate::crypto::seal(&json).map_err(failure_from_crypto)?; - let id = TEMP_ID.fetch_add(1, Ordering::Relaxed); - let tmp = path.with_extension(format!("tmp.{}.{id}", std::process::id())); - - let write_result = (|| -> Result<(), StoreFailure> { - let mut options = std::fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - let mut file = options.open(&tmp).map_err(|error| Self::map_io(&error))?; - file.write_all(&sealed) - .map_err(|error| Self::map_io(&error))?; - file.sync_all().map_err(|error| Self::map_io(&error))?; - std::fs::rename(&tmp, path).map_err(|error| Self::map_io(&error))?; - #[cfg(unix)] - { - let directory = - std::fs::File::open(parent).map_err(|error| Self::map_io(&error))?; - directory.sync_all().map_err(|error| Self::map_io(&error))?; - } - Ok(()) - })(); - if write_result.is_err() { - let _ = std::fs::remove_file(&tmp); - } - write_result - } - - fn global() -> &'static std::sync::Mutex { - static GLOBAL: std::sync::OnceLock> = - std::sync::OnceLock::new(); - GLOBAL.get_or_init(|| std::sync::Mutex::new(Self::load())) - } - pub fn access_state() -> StoreAccess { - let _ = Self::global(); - current_access() - } - - /// Apply a change to a clone, durably encrypt it, and publish it in memory - /// only after the write succeeds. Recovery/safe mode never invokes `f`. - pub fn mutate(f: impl FnOnce(&mut SiteStore)) -> bool { - if !Self::access_state().permits_confidential_writes() { - return false; - } - #[cfg(test)] - run_mutate_pre_lock_hook(); - let mut guard = Self::global() + let mut runtime = runtime_cell() .lock() .unwrap_or_else(|error| error.into_inner()); - // A concurrent writer may have disabled writes while this caller was - // waiting for the canonical revision lock. Re-check before touching - // the key store or invoking the closure so safe mode is process-wide - // and sticky. Key validation is serialized under this same lock so it - // cannot disable writes while another candidate is being persisted. - if !current_access().permits_confidential_writes() { - return false; + runtime.refresh_writer_failure(); + runtime.access + } + + pub fn persistence_health() -> PersistenceHealth { + let mut runtime = runtime_cell() + .lock() + .unwrap_or_else(|error| error.into_inner()); + runtime.refresh_writer_failure(); + runtime.health() + } + + pub fn mutation_status(accepted: bool) -> String { + if accepted { + format!( + "Encrypted change accepted. {}", + Self::persistence_health().status_line() + ) + } else { + let access = Self::access_state(); + format!( + "{} Support code: {}", + access.message(), + access.support_code() + ) } - if let Err(error) = crate::crypto::load_existing_dek() { - set_access(StoreAccess::ConfidentialWritesDisabled( - failure_from_crypto(error), - )); - return false; + } + + /// Profile-level mutation permits only site-profile and selection changes; + /// a postcondition fence rejects/discards changes to confidential records. + /// It is crate-private. `true` means accepted, not necessarily durable. + pub(crate) fn mutate_profile(change: impl FnOnce(&mut SiteStore)) -> bool { + Self::mutate_result(None, change).is_ok() + } + + /// Site-scoped mutation rejects absent, stale or implicit context before + /// invoking the closure, then rejects/discards any out-of-scope change. + /// It is crate-private so every call site is auditable inside this package. + pub(crate) fn mutate_scoped(site_id: &str, change: impl FnOnce(&mut SiteStore)) -> bool { + Self::mutate_result(Some(site_id), change).is_ok() + } + + fn profile_mutation_fence(&self) -> Result>, StoreFailure> { + serde_json::to_vec(&( + self.version, + &self.reports, + &self.workers, + &self.reminders, + &self.tasks, + &self.procurement, + &self.suppliers, + &self.meetings, + &self.chat_threads, + &self.directories, + )) + .map(Zeroizing::new) + .map_err(|_| StoreFailure::SerializationFailed) + } + + fn site_mutation_fence(&self, site_id: &str) -> Result>, StoreFailure> { + let reports: Vec<_> = self + .reports + .iter() + .filter(|record| record.site_id != site_id) + .collect(); + let workers: Vec<_> = self + .workers + .iter() + .filter(|record| record.site_id != site_id) + .collect(); + let reminders: Vec<_> = self + .reminders + .iter() + .filter(|record| record.site_id.as_deref() != Some(site_id)) + .collect(); + let tasks: Vec<_> = self + .tasks + .iter() + .filter(|record| record.site_id != site_id) + .collect(); + let procurement: Vec<_> = self + .procurement + .iter() + .filter(|record| record.site_id != site_id) + .collect(); + let meetings: Vec<_> = self + .meetings + .iter() + .filter(|record| record.site_id != site_id) + .collect(); + let directories: Vec<_> = self + .directories + .iter() + .filter(|record| record.site_id != site_id) + .collect(); + // The legacy supplier directory has no site_id and is therefore the + // one intentionally shared field omitted from this fence. SITE-03 must + // assign or quarantine those records before per-site persistence. + serde_json::to_vec(&( + self.version, + &self.sites, + reports, + workers, + reminders, + tasks, + procurement, + meetings, + &self.selected_site_id, + &self.chat_threads, + directories, + )) + .map(Zeroizing::new) + .map_err(|_| StoreFailure::SerializationFailed) + } + + fn mutate_result( + scope: Option<&str>, + change: impl FnOnce(&mut SiteStore), + ) -> Result { + let mut runtime = runtime_cell() + .lock() + .unwrap_or_else(|error| error.into_inner()); + runtime.refresh_writer_failure(); + if !runtime.access.permits_confidential_writes() { + return Err(match runtime.access { + StoreAccess::SetupRequired => StoreFailure::SecurityReviewRequired, + StoreAccess::RecoveryRequired(failure) + | StoreAccess::ConfidentialWritesDisabled(failure) => failure, + StoreAccess::ReadyEncrypted => unreachable!(), + }); } - let path = match Self::file_path() { - Ok(path) => path, - Err(failure) => { - set_access(StoreAccess::ConfidentialWritesDisabled(failure)); - return false; + if let Some(site_id) = scope { + if runtime.value.selected_site_id.as_deref() != Some(site_id) + || runtime.value.site(site_id).is_none() + { + return Err(StoreFailure::InvalidScope); } + } + let fence_before = match scope { + Some(site_id) => runtime.value.site_mutation_fence(site_id)?, + None => runtime.value.profile_mutation_fence()?, }; - let mut candidate = guard.clone(); - f(&mut candidate); - match candidate.persist_to(&path) { - Ok(()) => { - *guard = candidate; - true + let mut candidate = runtime.value.clone(); + change(&mut candidate); + let fence_after = match scope { + Some(site_id) => candidate.site_mutation_fence(site_id)?, + None => candidate.profile_mutation_fence()?, + }; + if fence_before != fence_after { + return Err(StoreFailure::InvalidScope); + } + let submit = runtime + .writer + .as_ref() + .ok_or(StoreFailure::WriterClosed)? + .submit(candidate.clone()) + .map_err(map_repository); + match submit { + Ok(revision) => { + runtime.value = candidate; + Ok(revision) } Err(failure) => { - set_access(StoreAccess::ConfidentialWritesDisabled(failure)); - false + runtime.access = StoreAccess::ConfidentialWritesDisabled(failure); + Err(failure) } } } pub fn read() -> Self { - Self::global() + let mut runtime = runtime_cell() .lock() - .map(|store| store.clone()) - .unwrap_or_else(|error| error.into_inner().clone()) + .unwrap_or_else(|error| error.into_inner()); + runtime.refresh_writer_failure(); + runtime.value.clone() + } + + pub fn flush_writer_queue(timeout_ms: u64) -> bool { + let mut runtime = runtime_cell() + .lock() + .unwrap_or_else(|error| error.into_inner()); + let Some(writer) = runtime.writer.as_ref() else { + return true; + }; + match writer.flush(Duration::from_millis(timeout_ms)) { + Ok(_) => true, + Err(failure) => { + runtime.access = StoreAccess::ConfidentialWritesDisabled(map_repository(failure)); + false + } + } + } + + pub fn flush_and_shutdown(timeout_ms: u64) -> bool { + let mut runtime = runtime_cell() + .lock() + .unwrap_or_else(|error| error.into_inner()); + let Some(writer) = runtime.writer.as_ref() else { + return true; + }; + match writer.flush_and_shutdown(Duration::from_millis(timeout_ms)) { + Ok(_) => true, + Err(failure) => { + runtime.access = StoreAccess::ConfidentialWritesDisabled(map_repository(failure)); + false + } + } + } + + /// Both flows are hard-locked until the independent SITE-02 review signs + /// the key lifecycle and recovery design. + pub fn setup_review_locked() -> StoreFailure { + if let Ok(path) = Self::file_path() { + let _ = SiteRepository::runtime(path).setup_review_locked(); + } + StoreFailure::SecurityReviewRequired + } + + pub fn migration_review_locked() -> StoreFailure { + if let Ok(path) = Self::file_path() { + let _ = SiteRepository::runtime(path).migration_review_locked(); + } + StoreFailure::SecurityReviewRequired } #[cfg(test)] pub(crate) fn reset_global() { - let fresh = Self::load(); - let mut guard = Self::global().lock().unwrap_or_else(|e| e.into_inner()); - *guard = fresh; + let mut runtime = runtime_cell() + .lock() + .unwrap_or_else(|error| error.into_inner()); + if let Some(writer) = runtime.writer.as_ref() { + let _ = writer.flush_and_shutdown(SHUTDOWN_TIMEOUT); + } + *runtime = RuntimeStore::load(); } // ---- Sites ---- - /// Merge without persisting — for use inside `mutate()`, which persists once. + /// Merge without persisting — for use inside the scoped mutation boundary, which persists once. pub fn push_site(&mut self, site: Site) { if let Some(pos) = self.sites.iter().position(|s| s.id == site.id) { self.sites[pos] = site; @@ -421,19 +641,18 @@ impl SiteStore { pub fn site_mut(&mut self, id: &str) -> Option<&mut Site> { self.sites.iter_mut().find(|s| s.id == id) } - /// Selected site, falling back to the first site. All screens use this - /// instead of `sites.first()` so multi-site actually works. - pub fn selected_or_first(&self) -> Option { + /// Return only an explicit, still-valid selection. Missing or stale + /// selection never falls back to the first site. + pub fn selected_site(&self) -> Option { self.selected_site_id .as_deref() .and_then(|id| self.site(id)) .cloned() - .or_else(|| self.sites.first().cloned()) } - pub fn selected_site_id_or_first(&self) -> Option { - self.selected_or_first().map(|s| s.id) + pub fn selected_site_id(&self) -> Option { + self.selected_site().map(|site| site.id) } - /// Select without persisting — for use inside `mutate()`. + /// Select without persisting — for use inside the scoped mutation boundary. pub fn apply_selected(&mut self, id: &str) -> bool { if self.sites.iter().any(|s| s.id == id) { self.selected_site_id = Some(id.to_string()); @@ -444,7 +663,7 @@ impl SiteStore { } // ---- Reports ---- - /// Merge without persisting — for use inside `mutate()`. + /// Merge without persisting — for use inside the scoped mutation boundary. pub fn push_report(&mut self, report: DailyReport) { if let Some(pos) = self.reports.iter().position(|r| r.id == report.id) { self.reports[pos] = report; @@ -459,7 +678,7 @@ impl SiteStore { self.reports.push(report); } } - /// Find-or-create today's report and push one entry, all inside `mutate()` + /// Find-or-create today's report and push one entry, all inside the scoped mutation boundary /// so concurrent captures can never clobber each other. pub fn push_task_entry( &mut self, @@ -503,7 +722,7 @@ impl SiteStore { } // ---- Workers ---- - /// Merge without persisting — for use inside `mutate()`. + /// Merge without persisting — for use inside the scoped mutation boundary. pub fn push_workers_table(&mut self, table: WorkersDailyTable) { if let Some(pos) = self .workers @@ -515,7 +734,7 @@ impl SiteStore { self.workers.push(table); } } - /// Append one scan to today's table (creating it), inside `mutate()`. + /// Append one scan to today's table (creating it), inside the scoped mutation boundary. pub fn push_worker_scan(&mut self, scan: crate::domain::workers::WorkerScan) { if let Some(table) = self .workers @@ -531,7 +750,7 @@ impl SiteStore { } // ---- Reminders ---- - /// Append without persisting — for use inside `mutate()`. + /// Append without persisting — for use inside the scoped mutation boundary. pub fn push_reminder(&mut self, r: Reminder) { self.reminders.push(r); } @@ -542,7 +761,7 @@ impl SiteStore { .collect() } /// Mark a candidate revision only; persistence is exclusively owned by - /// `SiteStore::mutate` so helper calls can never perform nested writes. + /// `SiteStore::mutate_scoped` so helper calls can never perform nested writes. pub fn mark_fired(&mut self, id: &str) { if let Some(reminder) = self.reminders.iter_mut().find(|item| item.id == id) { reminder.fired = true; @@ -559,7 +778,7 @@ impl SiteStore { } // ---- Tasks (12-15) ---- - /// Merge without persisting — for use inside `mutate()`. + /// Merge without persisting — for use inside the scoped mutation boundary. pub fn push_task(&mut self, task: ConstructionTask) { if let Some(pos) = self.tasks.iter().position(|t| t.id == task.id) { self.tasks[pos] = task; @@ -571,7 +790,7 @@ impl SiteStore { self.tasks.iter().filter(|t| t.site_id == site_id).collect() } /// Remove from a candidate revision only. The caller must use - /// `SiteStore::mutate` when a durable change is intended. + /// `SiteStore::mutate_scoped` when a durable change is intended. pub fn remove_task(&mut self, id: &str) -> bool { if let Some(pos) = self.tasks.iter().position(|task| task.id == id) { self.tasks.remove(pos); @@ -580,7 +799,7 @@ impl SiteStore { false } } - /// Seed template tasks without persisting — call inside `mutate()`, which + /// Seed template tasks without persisting — call inside the scoped mutation boundary, which /// persists once. (Previously saved internally, double-writing under mutate.) pub fn generate_template_for_site(&mut self, site_id: &str) { if let Some(site) = self.site(site_id).cloned() { @@ -593,11 +812,11 @@ impl SiteStore { } // ---- Procurement (16-17) ---- - /// Append without persisting — for use inside `mutate()`. + /// Append without persisting — for use inside the scoped mutation boundary. pub fn push_supplier(&mut self, s: crate::domain::procurement::Supplier) { self.suppliers.suppliers.push(s); } - /// Merge without persisting — for use inside `mutate()`. + /// Merge without persisting — for use inside the scoped mutation boundary. pub fn push_procurement(&mut self, sched: ProcurementSchedule) { if let Some(pos) = self .procurement @@ -626,7 +845,7 @@ impl SiteStore { } // ---- Meetings (18-22) ---- - /// Merge without persisting — for use inside `mutate()`. + /// Merge without persisting — for use inside the scoped mutation boundary. pub fn push_meeting(&mut self, m: SiteMeeting) { if let Some(pos) = self.meetings.iter().position(|x| x.id == m.id) { self.meetings[pos] = m; @@ -643,7 +862,7 @@ impl SiteStore { pub fn directory_for(&self, site_id: &str) -> Option<&ProjectDirectory> { self.directories.iter().find(|d| d.site_id == site_id) } - /// Append without persisting — for use inside `mutate()`. + /// Append without persisting — for use inside the scoped mutation boundary. pub fn push_contact( &mut self, site_id: &str, @@ -685,584 +904,362 @@ impl SiteStore { } } -/// Process-wide serialization for tests that touch process-global store/key -/// overrides. #[cfg(test)] pub(crate) static TEST_ENV_LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); -/// Points `file_path()` at a temp file and injects a test-only deterministic -/// key. Neither override is compiled into a production library. -#[cfg(test)] -pub(crate) struct TempStorePath { - key: &'static str, - prev: Option, - keystore_prev: Option, - test_dek_prev: Option, - _env: std::sync::MutexGuard<'static, ()>, -} - -#[cfg(test)] -impl TempStorePath { - pub(crate) fn set(key: &'static str, path: &PathBuf) -> Self { - let env = TEST_ENV_LOCK - .get_or_init(|| std::sync::Mutex::new(())) - .lock() - .unwrap_or_else(|error| error.into_inner()); - let prev = std::env::var(key).ok(); - let keystore_prev = std::env::var("NIGIG_SITE_NO_KEYSTORE").ok(); - let test_dek_prev = std::env::var("NIGIG_SITE_TEST_DEK").ok(); - std::env::set_var(key, path); - std::env::remove_var("NIGIG_SITE_NO_KEYSTORE"); - std::env::set_var("NIGIG_SITE_TEST_DEK", "07".repeat(32)); - Self { - key, - prev, - keystore_prev, - test_dek_prev, - _env: env, - } - } -} - -#[cfg(test)] -impl Drop for TempStorePath { - fn drop(&mut self) { - match &self.prev { - Some(value) => std::env::set_var(self.key, value), - None => std::env::remove_var(self.key), - } - match &self.keystore_prev { - Some(value) => std::env::set_var("NIGIG_SITE_NO_KEYSTORE", value), - None => std::env::remove_var("NIGIG_SITE_NO_KEYSTORE"), - } - match &self.test_dek_prev { - Some(value) => std::env::set_var("NIGIG_SITE_TEST_DEK", value), - None => std::env::remove_var("NIGIG_SITE_TEST_DEK"), - } - } -} - #[cfg(test)] mod tests { use super::*; - use chrono::NaiveDate; + use crate::crypto::{EnvelopeHeader, KeyId, OsRandom, StoreId}; - fn write_encrypted(path: &Path, plaintext: &[u8], key: &[u8; 32]) -> Vec { - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - let envelope = crate::crypto::encrypt_bytes(plaintext, key).unwrap(); - std::fs::write(path, &envelope).unwrap(); - envelope + struct TestEnvironment { + path: PathBuf, + store_path_before: Option, + key_before: Option, + key_failure_before: Option, + _guard: std::sync::MutexGuard<'static, ()>, } - fn write_store(path: &Path, store: &SiteStore) -> Vec { - write_encrypted(path, &serde_json::to_vec(store).unwrap(), &[7u8; 32]) + impl TestEnvironment { + fn new(tag: &str) -> Self { + let guard = TEST_ENV_LOCK + .get_or_init(|| std::sync::Mutex::new(())) + .lock() + .unwrap_or_else(|error| error.into_inner()); + let directory = + std::env::temp_dir().join(format!("nigig-site-store-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&directory); + std::fs::create_dir_all(&directory).unwrap(); + let path = directory.join("store.json"); + let store_path_before = std::env::var("NIGIG_SITE_STORE_PATH").ok(); + let key_before = std::env::var("NIGIG_SITE_TEST_DEK").ok(); + let key_failure_before = std::env::var("NIGIG_SITE_TEST_KEY_FAILURE").ok(); + std::env::set_var("NIGIG_SITE_STORE_PATH", &path); + std::env::set_var("NIGIG_SITE_TEST_DEK", "07".repeat(32)); + std::env::remove_var("NIGIG_SITE_TEST_KEY_FAILURE"); + Self { + path, + store_path_before, + key_before, + key_failure_before, + _guard: guard, + } + } + + fn metadata(revision: u64) -> crate::repository::RepositoryMetadata { + crate::repository::RepositoryMetadata { + store_id: StoreId([1; 16]), + key_id: KeyId([2; 16]), + revision, + } + } + + fn write_store(&self, store: &SiteStore, revision: u64) -> Vec { + self.write_plaintext(&serde_json::to_vec(store).unwrap(), revision) + } + + fn write_plaintext(&self, bytes: &[u8], revision: u64) -> Vec { + let metadata = Self::metadata(revision); + let key = zeroize::Zeroizing::new([7_u8; 32]); + let envelope = crate::crypto::seal( + bytes, + EnvelopeHeader { + store_id: metadata.store_id, + key_id: metadata.key_id, + revision, + }, + &key, + &OsRandom, + ) + .unwrap(); + std::fs::write(&self.path, &envelope).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&self.path, std::fs::Permissions::from_mode(0o600)) + .unwrap(); + } + envelope + } + + fn reopen(&self) { + SiteStore::reset_global(); + } } - #[test] - fn malformed_json_enters_recovery_without_reseed_or_overwrite() { - let (_guard, path) = temp_store_path("malformed"); - let original = write_encrypted(&path, b"{ truncated", &[7u8; 32]); - let loaded = SiteStore::load(); - assert!(loaded.sites.is_empty()); - assert_eq!( - SiteStore::access_state(), - StoreAccess::RecoveryRequired(StoreFailure::MalformedJson) - ); - assert_eq!(std::fs::read(&path).unwrap(), original); - assert!(!String::from_utf8_lossy(&original).contains("Muthaiga Villas")); - assert_eq!( - std::fs::read_dir(path.parent().unwrap()).unwrap().count(), - 1 - ); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); + impl Drop for TestEnvironment { + fn drop(&mut self) { + let _ = SiteStore::flush_and_shutdown(10_000); + match &self.store_path_before { + Some(value) => std::env::set_var("NIGIG_SITE_STORE_PATH", value), + None => std::env::remove_var("NIGIG_SITE_STORE_PATH"), + } + match &self.key_before { + Some(value) => std::env::set_var("NIGIG_SITE_TEST_DEK", value), + None => std::env::remove_var("NIGIG_SITE_TEST_DEK"), + } + match &self.key_failure_before { + Some(value) => std::env::set_var("NIGIG_SITE_TEST_KEY_FAILURE", value), + None => std::env::remove_var("NIGIG_SITE_TEST_KEY_FAILURE"), + } + let _ = std::fs::remove_dir_all(self.path.parent().unwrap()); + } } - #[test] - fn month_filter_uses_datelike_not_string() { - let mut s = SiteStore::default(); - let d1 = NaiveDate::from_ymd_opt(2026, 9, 7).unwrap(); - let d2 = NaiveDate::from_ymd_opt(2026, 8, 7).unwrap(); - s.reports.push(DailyReport::new("s1", d1)); - s.reports.push(DailyReport::new("s1", d2)); - assert_eq!(s.reports_for_month("s1", 2026, 9).len(), 1); - assert_eq!(s.reports_for_month("s1", 2026, 8).len(), 1); - } - - #[test] - fn durable_save_writes_only_an_encrypted_valid_store() { - let (_guard, path) = temp_store_path("durable"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + fn selected_store() -> (SiteStore, String) { let mut store = SiteStore::default(); - store.sites.push(Site::new( + let site = Site::new( "Confidential Sentinel", crate::domain::site::SiteNature::Road, "Private Location", - )); - store.persist_to(&path).unwrap(); - let envelope = std::fs::read(&path).unwrap(); - assert!(envelope.starts_with(b"NIGIG1\x01")); - assert!(!String::from_utf8_lossy(&envelope).contains("Confidential Sentinel")); - let reopened = SiteStore::open_path(&path).unwrap().unwrap(); - assert_eq!(reopened.sites[0].name, "Confidential Sentinel"); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - fn temp_store_path(tag: &str) -> (TempStorePath, PathBuf) { - let dir = - std::env::temp_dir().join(format!("nigig-site-test-{tag}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("store.json"); - let guard = TempStorePath::set("NIGIG_SITE_STORE_PATH", &path); - (guard, path) + ); + let id = site.id.clone(); + store.sites.push(site); + store.selected_site_id = Some(id.clone()); + (store, id) } #[test] - fn absent_and_empty_store_never_create_or_seed_data() { - let (_guard, path) = temp_store_path("absent-empty"); - let absent = SiteStore::load(); - assert!(absent.sites.is_empty()); + fn absent_repository_is_setup_locked_and_never_created() { + let environment = TestEnvironment::new("absent"); + environment.reopen(); assert_eq!(SiteStore::access_state(), StoreAccess::SetupRequired); - assert!(!path.exists()); - - std::fs::write(&path, []).unwrap(); - let empty = SiteStore::load(); - assert!(empty.sites.is_empty()); assert_eq!( - SiteStore::access_state(), - StoreAccess::RecoveryRequired(StoreFailure::EmptyFile) + SiteStore::setup_review_locked(), + StoreFailure::SecurityReviewRequired ); - assert_eq!(std::fs::read(&path).unwrap(), Vec::::new()); assert_eq!( - std::fs::read_dir(path.parent().unwrap()).unwrap().count(), - 1 + SiteStore::migration_review_locked(), + StoreFailure::SecurityReviewRequired ); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); + assert!(!environment.path.exists()); + assert!(!SiteStore::mutate_profile(|_| panic!( + "closure must not run" + ))); } #[test] - fn missing_wrong_and_tampered_keys_preserve_original_bytes() { - let (_guard, path) = temp_store_path("key-failures"); - let original = write_store(&path, &SiteStore::default()); - - std::env::remove_var("NIGIG_SITE_TEST_DEK"); - std::env::set_var("NIGIG_SITE_NO_KEYSTORE", "1"); - assert!(matches!( - SiteStore::open_path(&path), - Err(StoreFailure::KeyUnavailable) - )); - assert_eq!(std::fs::read(&path).unwrap(), original); - - std::env::remove_var("NIGIG_SITE_NO_KEYSTORE"); - std::env::set_var("NIGIG_SITE_TEST_DEK", "09".repeat(32)); - assert!(matches!( - SiteStore::open_path(&path), - Err(StoreFailure::AuthenticationFailed) - )); - assert_eq!(std::fs::read(&path).unwrap(), original); - - std::env::set_var("NIGIG_SITE_TEST_DEK", "07".repeat(32)); - let mut tampered = original.clone(); - *tampered.last_mut().unwrap() ^= 1; - std::fs::write(&path, &tampered).unwrap(); - assert!(matches!( - SiteStore::open_path(&path), - Err(StoreFailure::AuthenticationFailed) - )); - assert_eq!(std::fs::read(&path).unwrap(), tampered); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); + fn existing_nigig2_opens_without_changing_bytes_and_reports_revision() { + let environment = TestEnvironment::new("open"); + let (store, _) = selected_store(); + let original = environment.write_store(&store, 9); + environment.reopen(); + assert_eq!(SiteStore::access_state(), StoreAccess::ReadyEncrypted); + assert_eq!(SiteStore::read().sites[0].name, "Confidential Sentinel"); + assert_eq!(std::fs::read(&environment.path).unwrap(), original); + let health = SiteStore::persistence_health(); + assert_eq!(health.accepted_revision, 9); + assert_eq!(health.durable_revision, 9); + assert!(!health.has_unsaved_changes()); } #[test] - fn plaintext_malformed_and_future_input_are_recovery_only() { - let (_guard, path) = temp_store_path("input-failures"); - - let raw_plaintext = serde_json::to_vec(&SiteStore::default()).unwrap(); - std::fs::write(&path, &raw_plaintext).unwrap(); - assert!(matches!( - SiteStore::open_path(&path), - Err(StoreFailure::PlaintextMigrationRequired) - )); - assert_eq!(std::fs::read(&path).unwrap(), raw_plaintext); - - let mut explicit_plaintext = b"NIGIG1\x00".to_vec(); - explicit_plaintext.extend_from_slice(b"{}"); - std::fs::write(&path, &explicit_plaintext).unwrap(); - assert!(matches!( - SiteStore::open_path(&path), - Err(StoreFailure::PlaintextMigrationRequired) - )); - assert_eq!(std::fs::read(&path).unwrap(), explicit_plaintext); - - let malformed = write_encrypted(&path, b"not json", &[7u8; 32]); - assert!(matches!( - SiteStore::open_path(&path), - Err(StoreFailure::MalformedJson) - )); - assert_eq!(std::fs::read(&path).unwrap(), malformed); - - let future = SiteStore { - version: STORE_VERSION + 1, - ..SiteStore::default() + fn malformed_unknown_older_future_and_missing_key_states_preserve_original() { + let environment = TestEnvironment::new("recovery"); + let malformed = { + let metadata = TestEnvironment::metadata(1); + crate::crypto::seal( + b"not-json", + EnvelopeHeader { + store_id: metadata.store_id, + key_id: metadata.key_id, + revision: 1, + }, + &zeroize::Zeroizing::new([7_u8; 32]), + &OsRandom, + ) + .unwrap() }; - let future_bytes = write_store(&path, &future); - assert!(matches!( - SiteStore::open_path(&path), - Err(StoreFailure::FutureVersion) - )); - assert_eq!(std::fs::read(&path).unwrap(), future_bytes); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn valid_encrypted_open_is_byte_identical_and_does_not_migrate() { - let (_guard, path) = temp_store_path("valid-byte-identical"); - let mut old = SiteStore { - version: 1, - ..SiteStore::default() - }; - old.sites.push(Site::new( - "Existing Site", - crate::domain::site::SiteNature::Road, - "Existing Location", - )); - let original = write_store(&path, &old); - let opened = SiteStore::open_path(&path).unwrap().unwrap(); - assert_eq!(opened.version, 1); - assert_eq!(opened.selected_site_id, None); - assert_eq!(opened.sites[0].name, "Existing Site"); - assert_eq!(std::fs::read(&path).unwrap(), original); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn recovery_state_blocks_mutation_closure_and_preserves_file() { - let (_guard, path) = temp_store_path("blocked-mutation"); - let original = write_encrypted(&path, b"malformed", &[7u8; 32]); - SiteStore::reset_global(); + std::fs::write(&environment.path, &malformed).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&environment.path, std::fs::Permissions::from_mode(0o600)) + .unwrap(); + } + environment.reopen(); assert_eq!( SiteStore::access_state(), StoreAccess::RecoveryRequired(StoreFailure::MalformedJson) ); - let called = std::sync::atomic::AtomicBool::new(false); - let accepted = SiteStore::mutate(|_| { - called.store(true, std::sync::atomic::Ordering::SeqCst); - }); - assert!(!accepted); - assert!(!called.load(std::sync::atomic::Ordering::SeqCst)); - assert_eq!(std::fs::read(&path).unwrap(), original); - assert_eq!( - std::fs::read_dir(path.parent().unwrap()).unwrap().count(), - 1 + assert_eq!(std::fs::read(&environment.path).unwrap(), malformed); + + let mut unknown = serde_json::to_value(SiteStore::default()).unwrap(); + unknown.as_object_mut().unwrap().insert( + "unreviewed_root_field".to_owned(), + serde_json::json!("must not be silently discarded"), ); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - SiteStore::reset_global(); - } - - #[test] - fn key_loss_blocks_closure_and_preserves_last_durable_revision() { - let (_guard, path) = temp_store_path("key-loss-on-write"); - let original = write_store(&path, &SiteStore::default()); - SiteStore::reset_global(); - assert_eq!(SiteStore::access_state(), StoreAccess::ReadyEncrypted); - - std::env::remove_var("NIGIG_SITE_TEST_DEK"); - std::env::set_var("NIGIG_SITE_NO_KEYSTORE", "1"); - let called = std::sync::atomic::AtomicBool::new(false); - assert!(!SiteStore::mutate(|_| { - called.store(true, std::sync::atomic::Ordering::SeqCst); - })); - assert!(!called.load(std::sync::atomic::Ordering::SeqCst)); + let unknown_bytes = environment.write_plaintext(&serde_json::to_vec(&unknown).unwrap(), 2); + environment.reopen(); assert_eq!( SiteStore::access_state(), - StoreAccess::ConfidentialWritesDisabled(StoreFailure::KeyUnavailable) + StoreAccess::RecoveryRequired(StoreFailure::MalformedJson) ); - assert_eq!(std::fs::read(&path).unwrap(), original); + assert_eq!(std::fs::read(&environment.path).unwrap(), unknown_bytes); - std::env::remove_var("NIGIG_SITE_NO_KEYSTORE"); - std::env::set_var("NIGIG_SITE_TEST_DEK", "07".repeat(32)); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - SiteStore::reset_global(); - } - - #[test] - fn durable_write_failure_never_publishes_candidate_or_destroys_original() { - use crate::domain::approvals::ConstructionTask; - - let (_guard, path) = temp_store_path("write-failure"); - let original = write_store(&path, &SiteStore::default()); - SiteStore::reset_global(); - assert_eq!(SiteStore::access_state(), StoreAccess::ReadyEncrypted); - - // Deterministically make the configured parent unavailable without - // relying on UID-specific permission behavior. The original is moved - // intact by the test, not by production recovery code. - let parent = path.parent().unwrap().to_path_buf(); - let preserved_parent = parent.with_extension("preserved"); - let _ = std::fs::remove_dir_all(&preserved_parent); - std::fs::rename(&parent, &preserved_parent).unwrap(); - - assert!(!SiteStore::mutate(|store| { - store.push_task(ConstructionTask::new("site-1", "must not publish")); - })); + // Version probing must precede strict current-schema decoding. These + // shapes deliberately cannot deserialize as today's `SiteStore`. + let future_bytes = environment.write_plaintext( + br#"{"version":4294967296,"future_shape":{"opaque":true}}"#, + 3, + ); + environment.reopen(); assert_eq!( SiteStore::access_state(), - StoreAccess::ConfidentialWritesDisabled(StoreFailure::DurableWriteFailed) + StoreAccess::RecoveryRequired(StoreFailure::FutureVersion) ); - assert!(SiteStore::read().tasks.is_empty()); - assert_eq!( - std::fs::read(preserved_parent.join("store.json")).unwrap(), - original - ); - let called_after_failure = std::sync::atomic::AtomicBool::new(false); - assert!(!SiteStore::mutate(|_| { - called_after_failure.store(true, std::sync::atomic::Ordering::SeqCst); - })); - assert!(!called_after_failure.load(std::sync::atomic::Ordering::SeqCst)); + assert_eq!(std::fs::read(&environment.path).unwrap(), future_bytes); - let _ = std::fs::remove_dir_all(&preserved_parent); - SiteStore::reset_global(); + let older_bytes = environment.write_plaintext(br#"{"version":1}"#, 4); + environment.reopen(); + assert_eq!( + SiteStore::access_state(), + StoreAccess::RecoveryRequired(StoreFailure::OlderVersionMigrationRequired) + ); + assert_eq!(std::fs::read(&environment.path).unwrap(), older_bytes); + + let (valid, _) = selected_store(); + let valid_bytes = environment.write_store(&valid, 5); + std::env::set_var("NIGIG_SITE_TEST_KEY_FAILURE", "missing"); + environment.reopen(); + assert_eq!( + SiteStore::access_state(), + StoreAccess::RecoveryRequired(StoreFailure::KeyMissing) + ); + assert_eq!(std::fs::read(&environment.path).unwrap(), valid_bytes); } #[test] - fn waiting_mutation_never_runs_after_leader_disables_writes() { - use crate::domain::approvals::ConstructionTask; + fn scoped_mutation_is_accepted_then_becomes_durable_without_plaintext() { + let environment = TestEnvironment::new("mutation"); + let (store, site_id) = selected_store(); + environment.write_store(&store, 1); + environment.reopen(); + assert!(SiteStore::mutate_scoped(&site_id, |candidate| { + candidate.push_task(ConstructionTask::new(&site_id, "Worker ID 12345678")); + })); + let accepted = SiteStore::persistence_health().accepted_revision; + assert_eq!(accepted, 2); + assert!(SiteStore::flush_writer_queue(10_000)); + let health = SiteStore::persistence_health(); + assert_eq!(health.durable_revision, 2); + assert!(!health.has_unsaved_changes()); + let ciphertext = std::fs::read(&environment.path).unwrap(); + assert!(ciphertext.starts_with(b"NIGIG2")); + assert!(!String::from_utf8_lossy(&ciphertext).contains("Worker ID 12345678")); + environment.reopen(); + assert_eq!(SiteStore::read().tasks.len(), 1); + } + + #[test] + fn invalid_or_implicit_scope_never_invokes_mutation() { use std::sync::atomic::{AtomicBool, Ordering}; - use std::sync::{Arc, Barrier}; - let (_guard, path) = temp_store_path("waiting-writer-failure"); - let original = write_store(&path, &SiteStore::default()); - SiteStore::reset_global(); - assert_eq!(SiteStore::access_state(), StoreAccess::ReadyEncrypted); + let environment = TestEnvironment::new("scope"); + let (store, _) = selected_store(); + environment.write_store(&store, 1); + environment.reopen(); + let called = AtomicBool::new(false); + assert!(!SiteStore::mutate_scoped("wrong-site", |_| { + called.store(true, Ordering::SeqCst); + })); + assert!(!called.load(Ordering::SeqCst)); + assert_eq!(SiteStore::persistence_health().accepted_revision, 1); + } - let leader_entered = Arc::new(Barrier::new(2)); - let release_leader = Arc::new(Barrier::new(2)); - let entered = Arc::clone(&leader_entered); - let release = Arc::clone(&release_leader); - let leader = std::thread::spawn(move || { - SiteStore::mutate(|store| { - entered.wait(); - release.wait(); - store.push_task(ConstructionTask::new("site-1", "leader")); - }) - }); - leader_entered.wait(); + #[test] + fn mutation_fences_discard_cross_site_and_non_profile_changes() { + let environment = TestEnvironment::new("mutation-fence"); + let (mut store, selected_id) = selected_store(); + let other = Site::new("Other", crate::domain::site::SiteNature::Road, "Elsewhere"); + let other_id = other.id.clone(); + store.push_site(other); + environment.write_store(&store, 1); + environment.reopen(); - // Force the follower past the initial fast-path check while the leader - // still owns the canonical revision lock. This one-shot hook is test - // only and cannot be compiled into a production library. - let follower_pre_lock = Arc::new(Barrier::new(2)); - *mutate_pre_lock_hook_cell() - .lock() - .unwrap_or_else(|error| error.into_inner()) = Some(Arc::clone(&follower_pre_lock)); - let follower_called = Arc::new(AtomicBool::new(false)); - let called = Arc::clone(&follower_called); - let follower = std::thread::spawn(move || { - SiteStore::mutate(|_| { - called.store(true, Ordering::SeqCst); - }) - }); - follower_pre_lock.wait(); - - let parent = path.parent().unwrap().to_path_buf(); - let preserved_parent = parent.with_extension("preserved"); - let _ = std::fs::remove_dir_all(&preserved_parent); - std::fs::rename(&parent, &preserved_parent).unwrap(); - release_leader.wait(); - - assert!(!leader.join().unwrap()); - assert!(!follower.join().unwrap()); - assert!(!follower_called.load(Ordering::SeqCst)); - assert_eq!( - SiteStore::access_state(), - StoreAccess::ConfidentialWritesDisabled(StoreFailure::DurableWriteFailed) - ); + assert!(!SiteStore::mutate_scoped(&selected_id, |candidate| { + candidate.push_task(ConstructionTask::new(&other_id, "wrong site")); + })); + assert!(!SiteStore::mutate_profile(|candidate| { + candidate.push_task(ConstructionTask::new(&selected_id, "not profile data")); + })); assert!(SiteStore::read().tasks.is_empty()); + assert_eq!(SiteStore::persistence_health().accepted_revision, 1); + assert!(SiteStore::flush_and_shutdown(10_000)); + } + + #[test] + fn asynchronous_write_failure_is_sticky_and_unsaved_is_visible() { + let environment = TestEnvironment::new("write-failure"); + let (store, site_id) = selected_store(); + let original = environment.write_store(&store, 1); + environment.reopen(); + let parent = environment.path.parent().unwrap().to_path_buf(); + let preserved = parent.with_extension("preserved"); + let _ = std::fs::remove_dir_all(&preserved); + std::fs::rename(&parent, &preserved).unwrap(); + + assert!(SiteStore::mutate_scoped(&site_id, |candidate| { + candidate.push_task(ConstructionTask::new(&site_id, "unsaved")); + })); + assert!(!SiteStore::flush_writer_queue(10_000)); + let health = SiteStore::persistence_health(); + assert!(health.has_unsaved_changes()); + assert_eq!(health.accepted_revision, 2); + assert_eq!(health.durable_revision, 1); + assert!(matches!( + SiteStore::access_state(), + StoreAccess::ConfidentialWritesDisabled(_) + )); + assert!(!SiteStore::mutate_profile(|_| panic!( + "sticky failure must block closure" + ))); assert_eq!( - std::fs::read(preserved_parent.join("store.json")).unwrap(), + std::fs::read(preserved.join("store.json")).unwrap(), original ); - let _ = std::fs::remove_dir_all(&preserved_parent); - SiteStore::reset_global(); + std::fs::rename(&preserved, &parent).unwrap(); } - #[cfg(unix)] #[test] - fn permission_error_is_classified_and_never_rewrites() { - use std::os::unix::fs::PermissionsExt; - - let (_guard, path) = temp_store_path("permission"); - let original = write_store(&path, &SiteStore::default()); - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap(); - let result = SiteStore::open_path(&path); - let uid_is_root = std::process::Command::new("id") - .arg("-u") - .output() - .ok() - .and_then(|output| String::from_utf8(output.stdout).ok()) - .is_some_and(|uid| uid.trim() == "0"); - if uid_is_root { - assert!( - result.is_ok(), - "root should be able to read mode-000 test data" - ); - } else { - assert!(matches!(result, Err(StoreFailure::PermissionDenied))); + fn graceful_shutdown_flushes_final_accepted_revision_and_closes_writer() { + let environment = TestEnvironment::new("shutdown"); + let (store, site_id) = selected_store(); + environment.write_store(&store, 4); + environment.reopen(); + for index in 0..30 { + assert!(SiteStore::mutate_scoped(&site_id, |candidate| { + candidate.push_task(ConstructionTask::new(&site_id, format!("task-{index:02}"))); + })); + assert!(SiteStore::persistence_health().pending_depth <= 1); } - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); - assert_eq!(std::fs::read(&path).unwrap(), original); - assert_eq!( - std::fs::read_dir(path.parent().unwrap()).unwrap().count(), - 1 - ); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); + let accepted = SiteStore::persistence_health().accepted_revision; + assert!(SiteStore::flush_and_shutdown(10_000)); + let bytes = std::fs::read(&environment.path).unwrap(); + let header = crate::crypto::parse_header(&bytes).unwrap(); + assert_eq!(header.revision, accepted); + assert!(!SiteStore::mutate_profile(|_| panic!( + "closed writer must reject" + ))); } #[test] - fn chat_threads_persist_capped_per_room() { - let mut s = SiteStore::default(); - for i in 0..55 { - s.push_chat_line("room-1", format!("m-{i:02}")); - } - s.push_chat_line("room-2", "hello".into()); - let lines = s.thread_lines("room-1"); - assert_eq!(lines.len(), 50); - assert_eq!(lines[0], "m-05"); - assert_eq!(lines[49], "m-54"); - assert_eq!(s.thread_lines("room-2"), vec!["hello".to_string()]); - assert!(s.thread_lines("nope").is_empty()); - // Round-trips through JSON (migration-safe new field). - let bytes = serde_json::to_vec_pretty(&s).unwrap(); - let back: SiteStore = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(back.thread_lines("room-1").len(), 50); - } - - #[test] - fn directory_unions_contacts_and_attendees_deduped() { - use crate::domain::meetings::{MeetingAttendee, SiteMeeting}; - // Build state without touching disk; production persistence is - // exclusively mediated by an accepted `SiteStore::mutate` call. - let mut s = SiteStore::default(); - s.directories - .push(crate::domain::meetings::ProjectDirectory { - site_id: "s1".into(), - contacts: vec![MeetingAttendee { - user_id: "u1".into(), - display_name: "Alice".into(), - email: None, - }], - }); - let mut m = SiteMeeting::new("s1", "Weekly", chrono::Utc::now()); - m.attendees.push(MeetingAttendee { - user_id: "u2".into(), - display_name: "alice".into(), - email: None, - }); - m.attendees.push(MeetingAttendee { - user_id: "u3".into(), - display_name: "Bob".into(), - email: None, - }); - s.meetings.push(m); - let names = s.directory_names("s1"); - assert_eq!(names, vec!["Alice".to_string(), "Bob".to_string()]); - assert!(s.directory_names("other").is_empty()); - } - - #[test] - fn remove_task_deletes_only_the_target() { - use crate::domain::approvals::ConstructionTask; + fn month_filter_and_selection_helpers_remain_deterministic() { let mut store = SiteStore::default(); - let first = ConstructionTask::new("s1", "A"); - let second = ConstructionTask::new("s1", "B"); - let first_id = first.id.clone(); - store.tasks.push(first); - store.tasks.push(second); - assert!(store.remove_task(&first_id)); - assert_eq!(store.tasks.len(), 1); - assert_eq!(store.tasks[0].title, "B"); - assert!(!store.remove_task(&first_id)); - } + let first = Site::new("A", crate::domain::site::SiteNature::Road, "x"); + let second = Site::new("B", crate::domain::site::SiteNature::Road, "y"); + let second_id = second.id.clone(); + store.sites.extend([first, second]); + assert!(store.selected_site().is_none()); + assert!(store.apply_selected(&second_id)); + assert_eq!(store.selected_site().unwrap().name, "B"); - #[test] - fn concurrent_mutations_all_survive() { - use crate::domain::approvals::ConstructionTask; - // 50 OS threads mutating through the global lock: every task must - // survive. Each accepted mutation is synchronously encrypted before - // the in-memory revision is published. - let (_guard, path) = temp_store_path("race"); - write_store(&path, &SiteStore::default()); - SiteStore::reset_global(); - let handles: Vec<_> = (0..50) - .map(|i| { - std::thread::spawn(move || { - SiteStore::mutate(|s| { - s.push_task(ConstructionTask::new("s1", format!("task-{i:02}"))); - }) - }) - }) - .collect(); - for handle in handles { - assert!(handle.join().unwrap(), "mutation must become durable"); - } - // Disk truth, bypassing the global: all 50 tasks present. - let back = SiteStore::load(); - let mut titles: Vec = back.tasks.iter().map(|t| t.title.clone()).collect(); - titles.sort(); - let expected: Vec = (0..50).map(|i| format!("task-{i:02}")).collect(); - assert_eq!(titles, expected); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - SiteStore::reset_global(); - } - - #[test] - fn sequential_writes_leave_newest_state() { - // 50 rapid durable writes: each encrypted rename must leave the newest - // complete revision, never a partial or plaintext file. - let (_guard, p) = temp_store_path("seq"); - let mut s = SiteStore::default(); - for i in 0..50 { - let r = DailyReport::new( - format!("site-{i}"), - NaiveDate::from_ymd_opt(2026, 9, 7).unwrap(), - ); - s.reports.push(r); - s.persist_to(&p).unwrap(); - } - let envelope = std::fs::read(&p).unwrap(); - assert!(envelope.starts_with(b"NIGIG1\x01")); - let bytes = crate::crypto::open_envelope(&envelope).unwrap(); - let back: SiteStore = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(back.reports.len(), 50); - assert_eq!(back.reports[49].site_id, "site-49"); - let _ = std::fs::remove_dir_all(p.parent().unwrap()); - } - - #[test] - fn selection_falls_back_to_first_and_rejects_unknown() { - let mut s = SiteStore::default(); - assert!(s.selected_or_first().is_none()); - let a = Site::new("A", crate::domain::site::SiteNature::Road, "x"); - let b = Site::new("B", crate::domain::site::SiteNature::Road, "y"); - let bid = b.id.clone(); - s.sites.push(a); - s.sites.push(b); - // No explicit selection → first site - assert_eq!(s.selected_or_first().unwrap().name, "A"); - // Unknown id is rejected, selection unchanged - assert!(!s.apply_selected("nope")); - assert_eq!(s.selected_or_first().unwrap().name, "A"); - // Valid selection sticks - assert!(s.apply_selected(&bid)); - assert_eq!(s.selected_or_first().unwrap().name, "B"); - // Round-trips through JSON (migration backfill path) - let bytes = serde_json::to_vec_pretty(&s).unwrap(); - let back: SiteStore = serde_json::from_slice(&bytes).unwrap(); - assert_eq!( - back.selected_site_id_or_first().as_deref(), - Some(bid.as_str()) - ); + let september = NaiveDate::from_ymd_opt(2026, 9, 7).unwrap(); + let august = NaiveDate::from_ymd_opt(2026, 8, 7).unwrap(); + store.reports.push(DailyReport::new(&second_id, september)); + store.reports.push(DailyReport::new(&second_id, august)); + assert_eq!(store.reports_for_month(&second_id, 2026, 9).len(), 1); } } diff --git a/crates/apps/nigig-site/tests/sync_e2e.rs b/crates/apps/nigig-site/tests/sync_e2e.rs index a3b1105..ef206c1 100644 --- a/crates/apps/nigig-site/tests/sync_e2e.rs +++ b/crates/apps/nigig-site/tests/sync_e2e.rs @@ -1,3 +1,5 @@ +#![cfg(target_os = "linux")] + //! Live end-to-end test against a real `nimanyatta` server. //! //! Run: start the server first, then diff --git a/crates/apps/nigig-site/tools/audit-production-deps.py b/crates/apps/nigig-site/tools/audit-production-deps.py new file mode 100755 index 0000000..035f31e --- /dev/null +++ b/crates/apps/nigig-site/tools/audit-production-deps.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Fail on RustSec findings reachable from nigig-site production dependencies. + +Cargo.lock is workspace-wide, so cargo-audit can report packages that are not in +this application's normal/build graph. This script consumes unmodified +`cargo metadata` and `cargo audit --json` output, traverses every non-dev edge +for all target predicates, and makes that containment explicit rather than +ignoring advisory IDs globally. +""" + +from __future__ import annotations + +import json +import sys +from collections import deque +from pathlib import Path +from typing import Any, NoReturn + + +def fail(message: str) -> NoReturn: + print(f"ERROR: {message}", file=sys.stderr) + raise SystemExit(1) + + +def load_json(path: str) -> dict[str, Any]: + try: + value = json.loads(Path(path).read_text()) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + fail(f"cannot parse {path}: {error}") + if not isinstance(value, dict): + fail(f"expected a JSON object in {path}") + return value + + +def main() -> None: + if len(sys.argv) != 4: + fail("usage: audit-production-deps.py METADATA.json AUDIT.json REPORT.txt") + metadata = load_json(sys.argv[1]) + audit = load_json(sys.argv[2]) + + packages = metadata.get("packages") + resolve = metadata.get("resolve") + if not isinstance(packages, list) or not isinstance(resolve, dict): + fail("cargo metadata is missing packages/resolve") + nodes = resolve.get("nodes") + if not isinstance(nodes, list): + fail("cargo metadata is missing resolve.nodes") + + roots = [ + package + for package in packages + if package.get("name") == "nigig-site" + and str(package.get("manifest_path", "")).replace("\\", "/").endswith( + "/crates/apps/nigig-site/Cargo.toml" + ) + ] + if len(roots) != 1: + fail(f"expected exactly one nigig-site package, found {len(roots)}") + root_id = roots[0].get("id") + node_by_id = {node.get("id"): node for node in nodes} + package_by_id = {package.get("id"): package for package in packages} + if root_id not in node_by_id or root_id not in package_by_id: + fail("nigig-site is absent from the resolved graph") + + reachable: set[str] = set() + queue: deque[str] = deque([root_id]) + while queue: + package_id = queue.popleft() + if package_id in reachable: + continue + reachable.add(package_id) + node = node_by_id.get(package_id) + if not isinstance(node, dict): + fail(f"resolved node is missing for {package_id}") + dependencies = node.get("deps") + if not isinstance(dependencies, list): + fail(f"resolved dependencies are malformed for {package_id}") + for dependency in dependencies: + kinds = dependency.get("dep_kinds") + if not isinstance(kinds, list) or not kinds: + fail(f"dependency kinds are missing for {dependency!r}") + # `kind: null` is a normal dependency. Build dependencies execute in + # the production build trust boundary. Dev-only edges are excluded. + if not any(kind.get("kind") in (None, "build") for kind in kinds): + continue + child = dependency.get("pkg") + if child not in node_by_id: + fail(f"resolved child node is missing for {child}") + queue.append(child) + + reachable_versions = { + (str(package_by_id[item].get("name")), str(package_by_id[item].get("version"))) + for item in reachable + } + + vulnerabilities = audit.get("vulnerabilities") + warnings = audit.get("warnings") + database = audit.get("database") + settings = audit.get("settings") + if not isinstance(vulnerabilities, dict) or not isinstance(warnings, dict): + fail("cargo-audit JSON is missing vulnerabilities/warnings") + if not isinstance(database, dict) or not database.get("last-commit"): + fail("cargo-audit JSON is missing database provenance") + if not isinstance(settings, dict): + fail("cargo-audit JSON is missing settings") + if settings.get("ignore") != []: + fail("cargo-audit advisory ignores are forbidden") + if settings.get("target_arch") != [] or settings.get("target_os") != []: + fail("cargo-audit target filters are forbidden") + informational = settings.get("informational_warnings") + if not isinstance(informational, list) or not { + "unmaintained", + "unsound", + "notice", + }.issubset(informational): + fail("cargo-audit informational warnings are not fully enabled") + vulnerability_list = vulnerabilities.get("list") + if not isinstance(vulnerability_list, list): + fail("cargo-audit vulnerability list is malformed") + if vulnerabilities.get("count") != len(vulnerability_list): + fail("cargo-audit vulnerability count does not match its list") + if vulnerabilities.get("found") is not bool(vulnerability_list): + fail("cargo-audit vulnerability flag does not match its list") + + findings: list[tuple[str, str, str, str]] = [] + excluded: list[tuple[str, str, str, str]] = [] + + def classify(kind: str, item: dict[str, Any]) -> None: + package = item.get("package") + advisory = item.get("advisory") + if not isinstance(package, dict) or not isinstance(advisory, dict): + fail(f"malformed cargo-audit {kind} item") + name = str(package.get("name")) + version = str(package.get("version")) + advisory_id = str(advisory.get("id")) + record = (kind, advisory_id, name, version) + if (name, version) in reachable_versions: + findings.append(record) + else: + excluded.append(record) + + for item in vulnerability_list: + if not isinstance(item, dict): + fail("malformed cargo-audit vulnerability item") + classify("vulnerability", item) + for warning_kind, items in warnings.items(): + if not isinstance(items, list): + fail(f"cargo-audit warning list is malformed: {warning_kind}") + for item in items: + if not isinstance(item, dict): + fail(f"malformed cargo-audit warning item: {warning_kind}") + classify(f"warning:{warning_kind}", item) + + report_lines = [ + "nigig-site production RustSec containment report", + f"reachable normal/build packages (all target predicates): {len(reachable)}", + f"workspace findings reported by cargo-audit: {len(findings) + len(excluded)}", + f"reachable findings: {len(findings)}", + f"excluded dev/unrelated-workspace findings: {len(excluded)}", + "", + ] + for prefix, records in (("REACHABLE", findings), ("EXCLUDED", excluded)): + for kind, advisory_id, name, version in sorted(records): + report_lines.append( + f"{prefix} {kind} {advisory_id} {name} {version}" + ) + report = "\n".join(report_lines) + "\n" + Path(sys.argv[3]).write_text(report) + print(report, end="") + if findings: + fail("RustSec findings are reachable from nigig-site production dependencies") + + +if __name__ == "__main__": + main() diff --git a/crates/apps/nigig-site/tools/ci-setup-ubuntu.sh b/crates/apps/nigig-site/tools/ci-setup-ubuntu.sh index bc1dff2..a94b85e 100755 --- a/crates/apps/nigig-site/tools/ci-setup-ubuntu.sh +++ b/crates/apps/nigig-site/tools/ci-setup-ubuntu.sh @@ -12,12 +12,18 @@ command -v rustup >/dev/null || { exit 1 } -sudo apt-get update -qq -sudo apt-get install -y -qq \ - time pkg-config \ - libwayland-dev libxcursor-dev libxrandr-dev libxi-dev libx11-dev \ - libgl1-mesa-dev libasound2-dev libglib2.0-dev libssl-dev \ +packages=( + time pkg-config + libwayland-dev libxcursor-dev libxrandr-dev libxi-dev libx11-dev + libgl1-mesa-dev libasound2-dev libglib2.0-dev libssl-dev libsqlite3-dev libudev-dev libpulse-dev libxkbcommon-dev libdbus-1-dev + xvfb xdotool imagemagick +) +if [[ ${NIGIG_SITE_INSTALL_NATIVE_KEYRING:-0} == 1 ]]; then + packages+=(dbus-x11 gnome-keyring libsecret-tools) +fi +sudo apt-get update -qq +sudo apt-get install -y -qq "${packages[@]}" channel=$(sed -n \ 's/^[[:space:]]*channel[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' \ diff --git a/crates/apps/nigig-site/tools/native-keyring-smoke.sh b/crates/apps/nigig-site/tools/native-keyring-smoke.sh new file mode 100755 index 0000000..9bb1546 --- /dev/null +++ b/crates/apps/nigig-site/tools/native-keyring-smoke.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Exercise the real Linux Secret Service provider in a disposable session. +# No credential is written to the user's actual HOME, session bus, or keyring. +set -euo pipefail + +if [[ "$(uname -s)" != Linux ]]; then + echo "ERROR: native-keyring-smoke.sh is Linux-only." >&2 + exit 2 +fi +for command in dbus-run-session gnome-keyring-daemon secret-tool cargo mktemp timeout; do + command -v "$command" >/dev/null || { + echo "ERROR: required command is unavailable: $command" >&2 + exit 2 + } +done + +# Preserve the already-installed toolchain/cache locations before HOME is +# redirected. Standard hosted Rust runners keep both beneath the real HOME. +original_home="$HOME" +export CARGO_HOME="${CARGO_HOME:-$original_home/.cargo}" +export RUSTUP_HOME="${RUSTUP_HOME:-$original_home/.rustup}" + +if [[ $# -eq 0 ]]; then + work_root="$(mktemp -d /tmp/nigig-site-keyring.XXXXXX)" +else + work_root="$1" + [[ "$work_root" == /tmp/nigig-site-* ]] || { + echo "ERROR: disposable keyring path must be beneath /tmp/nigig-site-*" >&2 + exit 2 + } + [[ ! -e "$work_root" ]] || { + echo "ERROR: disposable keyring path already exists: $work_root" >&2 + exit 2 + } + # Plain mkdir is atomic and refuses a symlink/path created after the check. + mkdir -m 700 -- "$work_root" +fi +home="$work_root/home" +runtime="$work_root/runtime" +mkdir -m 700 "$home" "$runtime" + +cleanup() { + rm -rf "$work_root" +} +trap cleanup EXIT INT TERM + +export HOME="$home" +export XDG_RUNTIME_DIR="$runtime" +export NIGIG_SITE_KEYRING_TEST_HOME="$home" +export NIGIG_SITE_LIVE_KEYRING_TEST=disposable-secret-service-v1 + +# The fixed string unlocks only this newly-created disposable keyring. It is +# neither an application credential nor persisted outside work_root. +dbus-run-session -- bash -euo pipefail -c ' + eval "$(printf site02-test-only-unlock | \ + gnome-keyring-daemon --unlock --components=secrets)" + # This command is also a supported standalone smoke: allow a cold, low-core + # host to compile the desktop dependency graph while retaining a hard bound. + timeout --signal=TERM --kill-after=10s 600s \ + cargo test --locked -p nigig-site --lib \ + repository::tests::linux_native_provider_real_vault_lifecycle -- \ + --ignored --exact --test-threads=1 +' + +echo "native keyring smoke passed: disposable Secret Service lifecycle and encrypted repository" diff --git a/crates/apps/nigig-site/tools/runtime-smoke.sh b/crates/apps/nigig-site/tools/runtime-smoke.sh new file mode 100755 index 0000000..30d9076 --- /dev/null +++ b/crates/apps/nigig-site/tools/runtime-smoke.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Launch the real desktop binary in an isolated X11 session, prove its window +# renders, capture evidence, request a normal window close, and require a clean +# process exit so SiteStandaloneApp::drop runs its bounded writer drain. +set -euo pipefail + +binary=${1:-target/debug/nigig-site} +evidence_dir=${2:-/tmp/nigig-site-ci} +[[ -x "$binary" ]] || { + echo "ERROR: runtime binary is not executable: $binary" >&2 + exit 1 +} +for command in xvfb-run xdotool import identify timeout; do + command -v "$command" >/dev/null || { + echo "ERROR: required runtime-smoke tool is missing: $command" >&2 + exit 1 + } +done + +mkdir -p "$evidence_dir" +runtime_root=$(mktemp -d) +cleanup() { + rm -rf "$runtime_root" +} +trap cleanup EXIT +mkdir -p "$runtime_root/home" "$runtime_root/data" "$runtime_root/cache" + +export HOME="$runtime_root/home" +export XDG_DATA_HOME="$runtime_root/data" +export XDG_CACHE_HOME="$runtime_root/cache" +export NIGIG_SITE_RUNTIME_BINARY="$binary" +export NIGIG_SITE_RUNTIME_ROOT="$runtime_root" +export NIGIG_SITE_RUNTIME_EVIDENCE="$evidence_dir" + +timeout --signal=TERM --kill-after=5s 30s xvfb-run -a bash -c ' + set -euo pipefail + "$NIGIG_SITE_RUNTIME_BINARY" >"$NIGIG_SITE_RUNTIME_EVIDENCE/runtime-app.log" 2>&1 & + pid=$! + cleanup_child() { + if [[ -n "${pid:-}" ]] && kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + fi + } + trap cleanup_child EXIT + window="" + for _ in $(seq 1 200); do + window=$(xdotool search --pid "$pid" --name "^nigig-site$" 2>/dev/null | head -n 1 || true) + [[ -n "$window" ]] && break + if ! kill -0 "$pid" 2>/dev/null; then + cat "$NIGIG_SITE_RUNTIME_EVIDENCE/runtime-app.log" >&2 + wait "$pid" + exit 1 + fi + sleep 0.1 + done + [[ -n "$window" ]] || { + echo "ERROR: nigig-site did not create its desktop window" >&2 + cat "$NIGIG_SITE_RUNTIME_EVIDENCE/runtime-app.log" >&2 + kill "$pid" 2>/dev/null || true + exit 1 + } + [[ $(xdotool getwindowname "$window") == nigig-site ]] + # Wait for the first GL present; finding a mapped-but-unpainted black window + # is not runtime UI evidence. + sleep 2 + import -window "$window" "$NIGIG_SITE_RUNTIME_EVIDENCE/runtime-ui.png" + identify "$NIGIG_SITE_RUNTIME_EVIDENCE/runtime-ui.png" \ + >"$NIGIG_SITE_RUNTIME_EVIDENCE/runtime-ui-image.txt" + mean=$(identify -format "%[fx:mean]" "$NIGIG_SITE_RUNTIME_EVIDENCE/runtime-ui.png") + awk -v mean="$mean" "BEGIN { exit !(mean > 0.05) }" || { + echo "ERROR: runtime window remained effectively black after first paint" >&2 + exit 1 + } + + # WM_DELETE_WINDOW follows the normal Makepad close path. A forced signal + # would not prove the Rust Drop implementation and is therefore forbidden. + xdotool windowclose "$window" + for _ in $(seq 1 200); do + ! kill -0 "$pid" 2>/dev/null && break + sleep 0.1 + done + if kill -0 "$pid" 2>/dev/null; then + echo "ERROR: nigig-site ignored a normal window-close request" >&2 + kill "$pid" 2>/dev/null || true + exit 1 + fi + wait "$pid" + pid="" +' + +# An absent repository must stay absent: startup is open-only and setup is +# security-review locked. Cache/shader output is outside XDG_DATA_HOME. +if find "$runtime_root/data" -type f -print -quit | grep -q .; then + echo "ERROR: runtime startup created repository data without setup consent" >&2 + find "$runtime_root/data" -type f -print >&2 + exit 1 +fi +if grep -Eiq 'panicked|fatal|segmentation fault|graceful persistence drain failed' \ + "$evidence_dir/runtime-app.log"; then + echo "ERROR: runtime log contains a fatal or shutdown failure" >&2 + cat "$evidence_dir/runtime-app.log" >&2 + exit 1 +fi +[[ -s "$evidence_dir/runtime-ui.png" ]] +[[ -s "$evidence_dir/runtime-ui-image.txt" ]] +echo "runtime smoke passed: isolated startup, rendered window, normal close, no repository creation"