From 9f235b7012b4bb12c0b7c2d237b0ba2632b91ae9 Mon Sep 17 00:00:00 2001 From: csd113 Date: Sat, 3 Oct 2026 22:30:51 -0700 Subject: [PATCH 1/2] Integrate single-binary updates and prepare RustChan 1.6.6 --- .cargo/audit.toml | 2 +- .dockerignore | 2 +- .github/workflows/audit.yml | 14 +- .github/workflows/ci.yml | 22 +- .github/workflows/container.yml | 20 +- .github/workflows/release.yml | 28 +- CHANGELOG.md | 11 + Cargo.lock | 588 +++++++------- Cargo.toml | 52 +- Dockerfile | 6 +- README.md | 12 +- SETUP.md | 4 +- deny.toml | 4 +- docs/containers.md | 2 +- docs/dependency-upgrade-1.6.6.md | 251 ++++++ docs/settings-restarts.md | 32 +- docs/software-updates.md | 261 ++++-- src/bin/rustchan-updater.rs | 15 - src/config.rs | 6 + src/db/pool.rs | 38 +- src/db/schema.rs | 78 +- src/handlers/admin/updates.rs | 4 +- src/main.rs | 42 +- src/restart.rs | 1 + src/server/server/lifecycle.rs | 10 +- src/templates/admin/updates.rs | 15 +- src/updates/bootstrap.rs | 583 ++++++++++++++ src/updates/control.rs | 366 +++++++++ src/updates/controller.rs | 474 +++++++++++ src/updates/coordinator.rs | 802 +++++++++++++++++++ src/updates/daemon.rs | 18 +- src/updates/guardian.rs | 341 ++++++++ src/updates/lifecycle.rs | 680 ++++++++++++++++ src/updates/mod.rs | 154 +++- src/updates/native.rs | 599 ++++++++++++++ src/updates/restart.rs | 15 +- src/updates/transaction.rs | 147 +++- src/updates/transaction_tests.rs | 167 +++- src/updates/trust.rs | 51 ++ src/utils/crypto.rs | 32 +- tools/test_update_package.py | 2 + tools/update_package.py | 2 +- vendor/symphonia-codec-aac/Cargo.toml | 2 +- vendor/symphonia-codec-aac/RUSTCHAN-PATCH.md | 2 +- 44 files changed, 5382 insertions(+), 575 deletions(-) create mode 100644 docs/dependency-upgrade-1.6.6.md delete mode 100644 src/bin/rustchan-updater.rs create mode 100644 src/updates/bootstrap.rs create mode 100644 src/updates/control.rs create mode 100644 src/updates/controller.rs create mode 100644 src/updates/coordinator.rs create mode 100644 src/updates/guardian.rs create mode 100644 src/updates/lifecycle.rs create mode 100644 src/updates/native.rs create mode 100644 src/updates/trust.rs diff --git a/.cargo/audit.toml b/.cargo/audit.toml index 176c90bc..d90693b8 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -3,7 +3,7 @@ # direct RSA usage. A call-site audit found public verification plus generic # private-key generation/signing support, but no calls to RSA decryption APIs or # decryption oracle, and no evidence that RustChan's v3 onion-service flow -# invokes private RSA operations. Re-reviewed with Arti 0.46.0 on 2026-09-08; +# invokes private RSA operations. Re-reviewed with Arti 0.47.0 on 2026-10-03; # re-review this temporary exception on every Arti upgrade. [advisories] ignore = ["RUSTSEC-2023-0071"] diff --git a/.dockerignore b/.dockerignore index 710368ee..8f2e4d07 100644 --- a/.dockerignore +++ b/.dockerignore @@ -32,5 +32,5 @@ tests *.md package.json package-lock.json -playwright.config.ts +playwright*.config.* deny.toml diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index ed3b4917..c77c66f8 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -40,26 +40,26 @@ jobs: steps: - name: Checkout - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable - name: Cache Rust artifacts - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: shared-key: audit - name: Install cargo-audit - uses: taiki-e/install-action@aa7e9e10a2fd43c88f1948b7de4af2f4efd56483 # cargo-audit + uses: taiki-e/install-action@861a07ce7084f55488e375df125cdc99bba60eb7 # v2.87.23 with: - tool: cargo-audit@0.22.1 + tool: cargo-audit@0.22.2 fallback: none - name: Install cargo-deny - uses: taiki-e/install-action@d865e9aac326f15b06ac52b57abb58cd0e3330e4 # cargo-deny + uses: taiki-e/install-action@861a07ce7084f55488e375df125cdc99bba60eb7 # v2.87.23 with: - tool: cargo-deny@0.19.0 + tool: cargo-deny@0.20.2 fallback: none - name: Verify cargo audit scans dependencies diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a671e14..6d77fe0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,26 +28,26 @@ jobs: steps: - name: Checkout - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Keep local tooling out of Git run: python3 tools/check_local_tooling.py - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable with: toolchain: 1.99.0 components: clippy, rustfmt - name: Cache Rust artifacts - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: shared-key: quality - name: Install cargo-deny - uses: taiki-e/install-action@d865e9aac326f15b06ac52b57abb58cd0e3330e4 # cargo-deny + uses: taiki-e/install-action@861a07ce7084f55488e375df125cdc99bba60eb7 # v2.87.23 with: - tool: cargo-deny@0.19.0 + tool: cargo-deny@0.20.2 fallback: none - name: Check formatting @@ -74,15 +74,15 @@ jobs: steps: - name: Checkout - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable with: toolchain: 1.99.0 - name: Cache Rust artifacts - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: shared-key: test @@ -110,16 +110,16 @@ jobs: steps: - name: Checkout - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable with: toolchain: 1.99.0 targets: ${{ matrix.target }} - name: Cache Rust artifacts - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: shared-key: platform-smoke key: ${{ matrix.target }} diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml index a7181e4c..35bd83ec 100644 --- a/.github/workflows/container.yml +++ b/.github/workflows/container.yml @@ -27,16 +27,16 @@ jobs: packages: write steps: - name: Check out source - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Normalize GHCR image name run: echo "IMAGE_NAME=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/rustchan" >> "$GITHUB_ENV" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Log in to GHCR - uses: docker/login-action@v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -44,7 +44,7 @@ jobs: - name: Image metadata id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ${{ env.IMAGE_NAME }} tags: | @@ -57,7 +57,7 @@ jobs: - name: Build and push platform image by digest id: build - uses: docker/build-push-action@v7 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . platforms: ${{ matrix.platform }} @@ -74,7 +74,7 @@ jobs: touch "$RUNNER_TEMP/digests/${DIGEST#sha256:}" - name: Upload digest - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: digests-${{ matrix.arch }} path: ${{ runner.temp }}/digests/* @@ -91,17 +91,17 @@ jobs: run: echo "IMAGE_NAME=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/rustchan" >> "$GITHUB_ENV" - name: Download digests - uses: actions/download-artifact@v7 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: ${{ runner.temp }}/digests pattern: digests-* merge-multiple: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Log in to GHCR - uses: docker/login-action@v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -109,7 +109,7 @@ jobs: - name: Image tags id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ${{ env.IMAGE_NAME }} tags: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 420f28df..814c42a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,16 +26,16 @@ jobs: steps: - name: Checkout - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable with: toolchain: 1.99.0 components: clippy, rustfmt - name: Cache Rust artifacts - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: shared-key: release-validation @@ -63,9 +63,9 @@ jobs: fi - name: Install cargo-deny - uses: taiki-e/install-action@d865e9aac326f15b06ac52b57abb58cd0e3330e4 # cargo-deny + uses: taiki-e/install-action@861a07ce7084f55488e375df125cdc99bba60eb7 # v2.87.23 with: - tool: cargo-deny@0.19.0 + tool: cargo-deny@0.20.2 fallback: none - name: Check formatting @@ -124,16 +124,16 @@ jobs: steps: - name: Checkout - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable with: toolchain: 1.99.0 targets: ${{ matrix.target }} - name: Cache Rust artifacts - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: shared-key: release key: ${{ matrix.target }} @@ -220,7 +220,7 @@ jobs: run: python3 tools/update_package.py package --binary target/${{ matrix.target }}/release/rustchan-cli --target ${{ matrix.target }} --version "$GITHUB_REF_NAME" --output dist - name: Upload artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: rustchan-cli-${{ matrix.asset_suffix }} path: | @@ -239,7 +239,7 @@ jobs: contents: write steps: - name: Checkout release changelog - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Extract release notes shell: bash @@ -263,7 +263,7 @@ jobs: PY - name: Download build artifacts - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: rustchan-cli-* path: release-assets @@ -313,7 +313,7 @@ jobs: echo "Refusing to alter an already published release" >&2 exit 1 fi - gh release create "$GITHUB_REF_NAME" --draft --title "Release $GITHUB_REF_NAME" --notes-file RELEASE_NOTES.md + gh release create "$GITHUB_REF_NAME" --draft --title "$GITHUB_REF_NAME" --notes-file RELEASE_NOTES.md fi release_id="$(gh api graphql \ -f owner="$GITHUB_REPOSITORY_OWNER" \ @@ -329,7 +329,7 @@ jobs: rm release-assets/build-info-*.json - name: Create GitHub Release - uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -337,6 +337,6 @@ jobs: prerelease: ${{ contains(github.ref_name, '-') }} make_latest: ${{ !contains(github.ref_name, '-') }} tag_name: ${{ github.ref_name }} - name: Release ${{ github.ref_name }} + name: ${{ github.ref_name }} body_path: RELEASE_NOTES.md files: release-assets/* diff --git a/CHANGELOG.md b/CHANGELOG.md index bbe23ac8..f9228833 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to RustChan will be documented in this file. +## RustChan 1.6.6 — 2026-10-03 + +- Fixed migration of supported earlier package baselines, including 1.6.0 databases missing the two newer post-query indexes. Unknown schema drift remains rejected; upgrades preserve existing content and retain full data-and-binary rollback. +- Documented the tested offline preparation required for 1.6.0 → the already published 1.6.5 release and stopped manual adoption of earlier source installations. +- Integrated the Linux controller and process monitor into the application executable, with embedded official release verification, full data rollback and public admission after durable commit. Exceptional loss of writer-exit proof can require a host reboot. New update packages require the 1.6.6 controller generation. +- Gave the first database connection a bounded 30-second initialization deadline while retaining one-second request checkout waits, asynchronous spare initialization and full SQLite durability. +- Updated all direct Rust dependencies to the latest compatible stable releases, including Arti 0.47, SQLite and Argon2 0.6. Existing Argon2 password hashes remain supported with unchanged cost limits. +- Updated pinned GitHub Actions and audit tools, and moved container images to Debian 13. Retained Rust 1.99 and the Rust-only TLS provider policy. +- Removed local agent instructions and the complete Playwright harness from Git tracking, restored their ignore rules and added a CI guard against accidental reintroduction. Local copies remain available for development. +- Native Linux reboot and power-loss recovery testing is being performed separately. Same-boot loss of writer-exit proof refuses startup and can require a host reboot; process-loss, installation, controller handoff and full rollback fixtures passed. + ## RustChan 1.6.5 — 2026-10-03 - Moved image processing, supported audio decoding, media probing and PDF previews into Rust, with optional FFmpeg fallback for video and uncovered audio codecs. diff --git a/Cargo.lock b/Cargo.lock index 7879c2c1..3927f890 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -31,15 +31,15 @@ dependencies = [ [[package]] name = "alloc-no-stdlib" -version = "2.0.4" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" [[package]] name = "alloc-stdlib" -version = "0.2.4" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" dependencies = [ "alloc-no-stdlib", ] @@ -181,13 +181,13 @@ dependencies = [ [[package]] name = "argon2" -version = "0.5.3" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" dependencies = [ "base64ct", "blake2", - "cpufeatures 0.2.17", + "cpufeatures 0.3.1", "password-hash", ] @@ -199,9 +199,9 @@ checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "arti-client" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aafcbc506657d886b27ef2d8e06a99edd9014b28130c798a3013935b46983097" +checksum = "43e6531f26fe6f44d71dabdc63b7845bae81547cc738aa942f0e3e482f776388" dependencies = [ "async-trait", "cfg-if", @@ -217,11 +217,11 @@ dependencies = [ "libc", "once_cell", "postage", - "rand 0.10.2", + "rand 0.10.3", "safelog", "serde", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", "tor-async-utils", "tor-basic-utils", @@ -269,7 +269,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", ] @@ -316,9 +316,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.48" +version = "0.4.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd" +checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1" dependencies = [ "compression-codecs", "compression-core", @@ -632,11 +632,11 @@ dependencies = [ [[package]] name = "blake2" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" dependencies = [ - "digest 0.10.7", + "digest 0.11.3", ] [[package]] @@ -684,9 +684,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.4" +version = "9.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -695,9 +695,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.3" +version = "6.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -788,9 +788,9 @@ dependencies = [ [[package]] name = "caret" -version = "0.10.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23a6f83e57018d15e7615a6f8e5e2c6b6b0226e24a1c562cd705f855a2bc751a" +checksum = "a3135adcc2ec978dbad556b41b7c71e0be7625d70b79159a8a19b5879a761115" [[package]] name = "castaway" @@ -803,9 +803,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" dependencies = [ "find-msvc-tools", "jobserver", @@ -914,9 +914,9 @@ checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] name = "coarsetime" -version = "0.1.37" +version = "0.1.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e58eb270476aa4fc7843849f8a35063e8743b4dbcdf6dd0f8ea0886980c204c2" +checksum = "eae63702c5627c75addbfb1ea9d1b3842205a6ffd43e4cce884a2a9eb1828fd7" dependencies = [ "libc", "wasix", @@ -970,9 +970,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.43" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9" +checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a" dependencies = [ "brotli", "compression-core", @@ -1377,7 +1377,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -1441,9 +1441,9 @@ dependencies = [ [[package]] name = "derive-deftly" -version = "1.11.5" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98e02cd118058400797c74550dca132b57cf2e6af0e9ea0672a83eb186fd655e" +checksum = "e64c6955def7f25b8c3becad9e1762ff6abc3ea62704b0883284c771149b45a6" dependencies = [ "derive-deftly-macros", "heck", @@ -1451,9 +1451,9 @@ dependencies = [ [[package]] name = "derive-deftly-macros" -version = "1.11.5" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba91f619216e76a5eb2515783f7ec2e271938b51aadac592f4930517f4626863" +checksum = "36e10ac4bd47bf8d24a4852f001df23b7acbbca6e59d0adec0c2e2c4409dfe5b" dependencies = [ "heck", "indexmap 2.14.2", @@ -1543,6 +1543,7 @@ dependencies = [ "block-buffer 0.12.1", "const-oid 0.10.2", "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -1686,13 +1687,12 @@ dependencies = [ [[package]] name = "encoding_rs" -version = "0.8.41" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" dependencies = [ "cfg-if", "core_detect", - "multiversion", "multiversion_no_op", "rustversion", "scopeguard", @@ -1887,9 +1887,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.13" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "flagset" @@ -1962,9 +1962,9 @@ dependencies = [ [[package]] name = "fs-mistrust" -version = "0.15.1" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b2c81a5a0e7d67644309a15694eb7f414a4d6556c64c1ece1e5969aa609c8a9" +checksum = "bc7f210768dd30e30895a6024be8472615bd7b6caec6df4f4105fc03e7608bdd" dependencies = [ "derive_builder_fork_arti", "dirs", @@ -1972,19 +1972,19 @@ dependencies = [ "libc", "pwd-grp", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "void", "walkdir", ] [[package]] name = "fslock-guard" -version = "0.8.2" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d10ce5036d49f8add4a11d08bc1b1afb0e3cefdd88778ab87fb4202d47b765e1" +checksum = "8082ded4af6b2e8e75d0ff71848ad7649d1ddff369a718e8e1db918795967bce" dependencies = [ "libc", - "thiserror 2.0.20", + "thiserror 2.0.21", "windows-sys 0.61.2", ] @@ -2309,11 +2309,11 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.11.1" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] @@ -2554,9 +2554,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http", "hyper", @@ -2569,16 +2569,17 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http", "http-body", + "httparse", "hyper", "ipnet", "libc", @@ -2848,9 +2849,9 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" dependencies = [ "darling 0.24.1", "indoc", @@ -2979,7 +2980,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.20", + "thiserror 2.0.21", "walkdir", "windows-link", ] @@ -3028,9 +3029,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -3064,7 +3065,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ "hashbrown 0.16.1", "portable-atomic", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -3120,18 +3121,18 @@ dependencies = [ [[package]] name = "lazy_static" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" dependencies = [ "spin", ] [[package]] name = "libc" -version = "0.2.189" +version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" [[package]] name = "libm" @@ -3141,18 +3142,18 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ "libc", ] [[package]] name = "libsqlite3-sys" -version = "0.37.0" +version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" dependencies = [ "cc", "pkg-config", @@ -3221,9 +3222,9 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru" -version = "0.18.4" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" dependencies = [ "hashbrown 0.17.1", ] @@ -3314,9 +3315,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.3" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "log", @@ -3351,27 +3352,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "multiversion" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" -dependencies = [ - "multiversion-macros", -] - -[[package]] -name = "multiversion-macros" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" -dependencies = [ - "proc-macro2", - "quote", - "rustversion", - "syn 3.0.6", -] - [[package]] name = "multiversion_no_op" version = "1.0.0" @@ -3587,9 +3567,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "oneshot-fused-workaround" -version = "0.7.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56892f7dcba21c5eb4181b8de221620855805891ee55ef960aad7cac3b5d88b2" +checksum = "40e2e88d3d699c52530078d715a6b5b10b3010afe570cfdc0240fee1c9d764ba" dependencies = [ "futures", ] @@ -3732,13 +3712,12 @@ dependencies = [ [[package]] name = "password-hash" -version = "0.5.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", + "getrandom 0.4.3", + "phc", ] [[package]] @@ -3804,6 +3783,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", + "getrandom 0.4.3", +] + [[package]] name = "phf" version = "0.13.1" @@ -4124,7 +4114,7 @@ dependencies = [ "derive-deftly", "libc", "paste", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -4173,9 +4163,9 @@ dependencies = [ [[package]] name = "r2d2_sqlite" -version = "0.34.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9a289c0a3bf56505c470efa2366e76010f1d892e2492a2f96b223386d63b7e2" +checksum = "d362d41ad1f9a278dd971842c63c0fc7ae2e01bccbfbbc8c9a4689a46fa7051f" dependencies = [ "r2d2", "rusqlite", @@ -4210,9 +4200,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -4318,7 +4308,7 @@ dependencies = [ "palette", "serde", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -4419,7 +4409,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" dependencies = [ "libredox", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -4524,9 +4514,9 @@ dependencies = [ [[package]] name = "retry-error" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d350ad359aca3414f7261cdcc763e37ccc9fa56fe40ca30a9758c28a295bcad0" +checksum = "90a0dea62e6084ede74beea58ee263fd2d70bdb59260a3bf3ede338dfb76f5cc" dependencies = [ "humantime", "web-time", @@ -4593,14 +4583,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] name = "rusqlite" -version = "0.39.0" +version = "0.40.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" dependencies = [ "bitflags 2.13.2", "fallible-iterator", @@ -4629,7 +4619,7 @@ dependencies = [ [[package]] name = "rustchan" -version = "1.6.5" +version = "1.6.6" dependencies = [ "anyhow", "argon2", @@ -4681,7 +4671,7 @@ dependencies = [ "symphonia", "tar", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", "tokio", "tokio-rustls", @@ -4758,7 +4748,7 @@ dependencies = [ "ring", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-util", "webpki-roots 1.0.9", @@ -4788,9 +4778,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" dependencies = [ "core-foundation", "core-foundation-sys", @@ -4809,9 +4799,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier-android" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" @@ -4838,15 +4828,15 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "safelog" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ef1d6e273fc8ce5cc1376cf498c06d23b786e80e4a7679a643bb0a838b0029" +checksum = "3e091498d7a0a629f344befcf15b1f823a24d5a83d584feeacacc7c764e67c11" dependencies = [ "derive_more", "educe", "either", "fluid-let", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -4869,9 +4859,12 @@ dependencies = [ [[package]] name = "saturating-time" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "802bdbfcca9a239cb46eeaaedea507e37bb13ba1a673762d2e2ef7a9dac63144" +checksum = "a586db154f85f3faf78398c37b6792d71f99d2253d3277571e14f8a8c6e49a65" +dependencies = [ + "web-time-compat", +] [[package]] name = "schannel" @@ -5080,9 +5073,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" dependencies = [ "base64 0.23.1", "bs58", @@ -5101,9 +5094,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" dependencies = [ "darling 0.24.1", "proc-macro2", @@ -5256,9 +5249,9 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "skrifa" @@ -5288,22 +5281,21 @@ dependencies = [ [[package]] name = "slotmap-careful" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba7bcbd01d9b495f4509e74f660ebaf3315219b2f8a81753667ee7d6d8a199ac" +checksum = "7dae6673c710ab011fc8d2ff604b16201df0a59f943976d911d83bbac2a1854f" dependencies = [ "paste", "serde", "slotmap", - "thiserror 2.0.20", - "void", + "thiserror 2.0.21", ] [[package]] name = "smallvec" -version = "1.16.1" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" [[package]] name = "socket2" @@ -5759,11 +5751,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.20", + "thiserror-impl 2.0.21", ] [[package]] @@ -5779,9 +5771,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", @@ -5884,9 +5876,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.53.1" +version = "1.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" dependencies = [ "bytes", "libc", @@ -5912,9 +5904,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.5" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", @@ -6029,9 +6021,9 @@ checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tor-async-utils" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31d9f28de36ed7646f0f03835ce2e306e2c5df8a1784a2cac27bd549cdc07599" +checksum = "64e39ddd4c352b8e3166ea0ec49b82a33ad9eeae8bdbbf76e1d2f1e856a6316b" dependencies = [ "cfg-if", "derive-deftly", @@ -6043,7 +6035,7 @@ dependencies = [ "postage", "slotmap-careful", "sync_wrapper", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-util", "tor-basic-utils", @@ -6055,9 +6047,9 @@ dependencies = [ [[package]] name = "tor-basic-utils" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2bd42110ae0faea1d446b35899aa5c3591cd31a94b593aa9264f00baba41da" +checksum = "e44c7b006f22966d44a2a0a6c1ee71e472c62182c7f0460077df3294d52f23c4" dependencies = [ "derive-deftly", "derive_more", @@ -6068,12 +6060,12 @@ dependencies = [ "itertools 0.15.0", "libc", "paste", - "rand 0.10.2", + "rand 0.10.3", "rand_chacha 0.10.0", "serde", "slab", "smallvec", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", "weak-table", "web-time-compat", @@ -6081,9 +6073,9 @@ dependencies = [ [[package]] name = "tor-bytes" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73de2ba54a125c27a440616f2d806a171bcb4e30013de166c7dc0a79bde44ab4" +checksum = "ccfed3adc9db61334445e921b95c26c896994db64e031c31e5cc48bd73e57ac7" dependencies = [ "bytes", "derive-deftly", @@ -6091,7 +6083,7 @@ dependencies = [ "educe", "getrandom 0.4.3", "safelog", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-error", "tor-llcrypto", "zeroize", @@ -6099,9 +6091,9 @@ dependencies = [ [[package]] name = "tor-cell" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "118f2b6a56605bedd077003ee5678fda215965d550dd0aef21ce8e10dd082750" +checksum = "b2d6398fd085306de5af739899d0390674b8646d4ff6cfb69d65f40a5f9d88da" dependencies = [ "amplify", "bitflags 2.13.2", @@ -6112,9 +6104,9 @@ dependencies = [ "educe", "itertools 0.15.0", "paste", - "rand 0.10.2", + "rand 0.10.3", "smallvec", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-basic-utils", "tor-bytes", "tor-cert", @@ -6130,16 +6122,15 @@ dependencies = [ [[package]] name = "tor-cert" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2cec6ae3abafc382e126a26e74f000bffbbf2c9b1a8c9ec26bb84463f0120b4" +checksum = "98dec924caf6e9548b4860878373132bf89536eb0d1658666df12dff72b855cd" dependencies = [ "caret", "derive_builder_fork_arti", "derive_more", "digest 0.10.7", - "saturating-time", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-bytes", "tor-checkable", "tor-error", @@ -6149,9 +6140,9 @@ dependencies = [ [[package]] name = "tor-chanmgr" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cef673b170f10790ff81d2f190e0a8150e5c9683c2c8f4c09652a6954c0e5ab" +checksum = "431ba53bf020ea8681ef0c2fbf0f0f854997fca9afe1b2c081f41f14ec00cf5d" dependencies = [ "async-trait", "base64ct", @@ -6165,11 +6156,11 @@ dependencies = [ "oneshot-fused-workaround", "percent-encoding", "postage", - "rand 0.10.2", + "rand 0.10.3", "safelog", "serde", "serde_with", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-async-utils", "tor-basic-utils", "tor-cell", @@ -6192,23 +6183,23 @@ dependencies = [ [[package]] name = "tor-checkable" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3872a5bda49647ad4c4a822c58774995ad3acd02327f1cabe6cfc019d714535" +checksum = "e58a50a2dbf5d21758c9e2dbaaf085403670b8bf52475c815dd1185698b7af9a" dependencies = [ "humantime", "itertools 0.15.0", "signature", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-llcrypto", "web-time-compat", ] [[package]] name = "tor-circmgr" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cf1eb6c2e3184c9e51c746df81f1a890ba7b663ac418debe2dac3756d840c8a" +checksum = "8b6b55747c690e0c931c5a081640ffdca6d1003c01b57b8102afcbdbaf3ec3fd" dependencies = [ "amplify", "async-trait", @@ -6225,11 +6216,11 @@ dependencies = [ "once_cell", "oneshot-fused-workaround", "pin-project", - "rand 0.10.2", + "rand 0.10.3", "retry-error", "safelog", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-async-utils", "tor-basic-utils", "tor-cell", @@ -6256,9 +6247,9 @@ dependencies = [ [[package]] name = "tor-config" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8ca5684100c9223590c11685f3f39c4e4c0037c65daddc26927b3ae4cc893d" +checksum = "b849c69c695b9534cf945c0422b85eaf5760bb6141a73c47b72fd6f95ea4d719" dependencies = [ "amplify", "cfg-if", @@ -6280,7 +6271,7 @@ dependencies = [ "serde-value", "serde_ignored", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "toml 1.1.6+spec-1.1.0", "tor-basic-utils", "tor-error", @@ -6291,30 +6282,30 @@ dependencies = [ [[package]] name = "tor-config-path" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbed06099bacbdbb1d91cfff1032863896d6097c33eece067ab20756e4a6fa2b" +checksum = "3b7f10bfd0d0fb159a8abc13d2593625bfd9f9a394715605262236d01bc5500f" dependencies = [ "directories", "serde", "shellexpand", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-error", "tor-general-addr", ] [[package]] name = "tor-consdiff" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9760d0051159a257b1d0065615ad6bc023da4e178d41ff5640222a29852de848" +checksum = "a6ed097865bfb6d453d9258620cbc952cf7481e9dcfc8901071ee1b8506cea86" dependencies = [ "derive_more", "digest 0.10.7", "hex", "imara-diff", "static_assertions", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-error", "tor-llcrypto", "tor-netdoc", @@ -6322,9 +6313,9 @@ dependencies = [ [[package]] name = "tor-dirclient" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403a79a0d00de6c78f15773a284e790459cff6983449e8578490e6b5dfb2afcd" +checksum = "f942f4ebaf2ac5275e48d02d77abe1616ce2e8854f032a1b0698ede0566dddfc" dependencies = [ "async-compression", "base64ct", @@ -6335,8 +6326,7 @@ dependencies = [ "httparse", "httpdate", "itertools 0.15.0", - "memchr", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-circmgr", "tor-error", "tor-hscrypto", @@ -6351,9 +6341,9 @@ dependencies = [ [[package]] name = "tor-dircommon" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5cd2a4f641ecb007cc50914bb1b46128de0975aba08baa56497c8e03c71c71" +checksum = "c16065e5166e529492d1be856e1e40316e2532053027c6b7ab5a4a53c14d8c51" dependencies = [ "base64ct", "derive-deftly", @@ -6372,9 +6362,9 @@ dependencies = [ [[package]] name = "tor-dirmgr" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "805da4e947311285ce0782ab129df318701c2c08046c316086cb6bb3eb7a463e" +checksum = "34ea5e77b43bf1ef5fa213a89cb67080f218df6b418c82527e6bb9862a39aef5" dependencies = [ "async-trait", "base64ct", @@ -6394,7 +6384,7 @@ dependencies = [ "oneshot-fused-workaround", "paste", "postage", - "rand 0.10.2", + "rand 0.10.3", "rusqlite", "safelog", "scopeguard", @@ -6403,7 +6393,7 @@ dependencies = [ "signature", "static_assertions", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", "tor-async-utils", "tor-basic-utils", @@ -6429,9 +6419,9 @@ dependencies = [ [[package]] name = "tor-error" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08f29e906f9f333a6e32e32afac9fb6712ac340191eb3292295b3337682b2dad" +checksum = "ca01de513501849b2298e4f16f8760832ef43344b10fe17600daed9a35128b24" dependencies = [ "derive_more", "futures", @@ -6439,7 +6429,7 @@ dependencies = [ "retry-error", "static_assertions", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", "void", "web-time-compat", @@ -6447,20 +6437,20 @@ dependencies = [ [[package]] name = "tor-general-addr" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b45ecd95dbf79070f9e477e1d84b22e47f03b2ab2f853abe31887daa9090cf0" +checksum = "72965a7044f573bb121467404d423d296592dd3cef87ad7aca4c96cc2b65e75a" dependencies = [ "derive_more", - "thiserror 2.0.20", + "thiserror 2.0.21", "void", ] [[package]] name = "tor-guardmgr" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e84beedbf276ce4ea170206b38ca01c995dbf1b9f678e9708f50e2e71dbfef4" +checksum = "87694603d6dcee5e0b050f0e7ac8be86348eb78ecb90c36ee8b70bd5822f3eff" dependencies = [ "amplify", "base64ct", @@ -6477,11 +6467,11 @@ dependencies = [ "oneshot-fused-workaround", "pin-project", "postage", - "rand 0.10.2", + "rand 0.10.3", "safelog", "serde", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-async-utils", "tor-basic-utils", "tor-config", @@ -6502,9 +6492,9 @@ dependencies = [ [[package]] name = "tor-hsclient" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eba66be03b5eb899185e5b4416d8893fdb09a97b33db9e450042f06e2550f0e" +checksum = "42addb7f2a7a86e813df223ed4485f2fb61b5736aff040cb5e8f551f83dfade0" dependencies = [ "async-trait", "cfg-if", @@ -6516,12 +6506,12 @@ dependencies = [ "itertools 0.15.0", "oneshot-fused-workaround", "postage", - "rand 0.10.2", + "rand 0.10.3", "retry-error", "safelog", "slotmap-careful", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-async-utils", "tor-basic-utils", "tor-bytes", @@ -6548,9 +6538,9 @@ dependencies = [ [[package]] name = "tor-hscrypto" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "118ff6a69bfa111b6e8d087ffb107ea9cbcd5d4469295cd7ada1b3cf19ba5a0f" +checksum = "6613c5336b71ebd34b8d90c233a7b6b06a0b81899d0545c656b0cac6f24e54a2" dependencies = [ "cipher 0.5.2", "data-encoding", @@ -6561,12 +6551,12 @@ dependencies = [ "humantime", "itertools 0.15.0", "paste", - "rand 0.10.2", + "rand 0.10.3", "safelog", "serde", "signature", "subtle", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-basic-utils", "tor-bytes", "tor-error", @@ -6581,9 +6571,9 @@ dependencies = [ [[package]] name = "tor-hsservice" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d61a6b5217c14780f54fa1c8610514b1349c93f657304dec98bd8e3083e1a09f" +checksum = "55809560d9295dcc2ee77002c5a4275d1f5f5a54203de7cd6acb52215f5344bd" dependencies = [ "amplify", "async-trait", @@ -6604,14 +6594,14 @@ dependencies = [ "once_cell", "oneshot-fused-workaround", "postage", - "rand 0.10.2", + "rand 0.10.3", "rand_core 0.10.1", "retry-error", "safelog", "serde", "serde_with", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-async-utils", "tor-basic-utils", "tor-bytes", @@ -6640,19 +6630,19 @@ dependencies = [ [[package]] name = "tor-key-forge" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1861b5eebab93515a0ab2401540f3d5a9fb376f1b1535d5793dcd697fb19f928" +checksum = "5534581a7f347ffcade7ea32ecccdbc035d36468bc8c03b0dce83363b6d41801" dependencies = [ "derive-deftly", "derive_more", "downcast-rs", "paste", - "rand 0.10.2", + "rand 0.10.3", "rsa", "signature", "ssh-key-fork-arti", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-bytes", "tor-cert", "tor-checkable", @@ -6662,13 +6652,14 @@ dependencies = [ [[package]] name = "tor-keymgr" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3088a573dadebb34acacb30d686751e2a94d12ccf9d6647946ab6c6b4ad5a0e" +checksum = "e269210a2ba305bf6524990a3c37aab4529b92a01f3d15ef6af23c067c8eb2c4" dependencies = [ "amplify", "arrayvec", "cfg-if", + "data-encoding", "derive-deftly", "derive_builder_fork_arti", "derive_more", @@ -6679,12 +6670,12 @@ dependencies = [ "humantime", "inventory", "itertools 0.15.0", - "rand 0.10.2", + "rand 0.10.3", "safelog", "serde", "signature", "ssh-key-fork-arti", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-basic-utils", "tor-bytes", "tor-config", @@ -6703,9 +6694,9 @@ dependencies = [ [[package]] name = "tor-linkspec" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d8d3a2aaf3bb67110218b9ff1718cd7d6c7e62226e2fee1a18efa6aa19ff07c" +checksum = "d287978bd5e1ffbe7ca2eb8daadb4e06e42d084bdad26bea4d4bbe1ff877e10f" dependencies = [ "base64ct", "by_address", @@ -6719,7 +6710,7 @@ dependencies = [ "serde", "serde_with", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-basic-utils", "tor-bytes", "tor-config", @@ -6730,9 +6721,9 @@ dependencies = [ [[package]] name = "tor-llcrypto" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee81755528eb34f8352ca6987d5f5c66ebba6af2e4618da4b527f17b0e912e" +checksum = "95f921a609ff73736e8e21d11dbee6617cd7568841790f2e3327295627a51b75" dependencies = [ "aes", "base64ct", @@ -6748,7 +6739,7 @@ dependencies = [ "getrandom 0.3.4", "getrandom 0.4.3", "hex", - "rand 0.10.2", + "rand 0.10.3", "rand_chacha 0.10.0", "rand_core 0.10.1", "rand_core 0.6.4", @@ -6763,7 +6754,7 @@ dependencies = [ "sha3 0.10.9", "signature", "subtle", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-error", "tor-memquota-cost", "visibility", @@ -6773,13 +6764,13 @@ dependencies = [ [[package]] name = "tor-log-ratelim" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f8696c8ea9090059d91eea287628b5df0b3b4fba433179d893e348857857544" +checksum = "2096a87f0691f0781bf564249c524d62b5338c3954ba1c6ef10ec90df1d97733" dependencies = [ "futures", "humantime", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-error", "tor-rtcompat", "tracing", @@ -6789,9 +6780,9 @@ dependencies = [ [[package]] name = "tor-memquota" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03786604757846a5f936285eef397e3fc0ed455893dc5012ceafdf455ada0d8d" +checksum = "96853fc516264fe2ebefbe40d276b6a3b84e15fc08b74fd7a85e2c5df9f11c37" dependencies = [ "cfg-if", "derive-deftly", @@ -6807,7 +6798,7 @@ dependencies = [ "slotmap-careful", "static_assertions", "sysinfo", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-async-utils", "tor-basic-utils", "tor-config", @@ -6821,9 +6812,9 @@ dependencies = [ [[package]] name = "tor-memquota-cost" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86d6f7e1d842e6272e414e1f866dc688306b788d368df42e47c4995e040c679c" +checksum = "f5e5712bfe2995a60eddcc32d07e2e38a42120318a8f5411d621bf0a8fa77219" dependencies = [ "derive-deftly", "itertools 0.15.0", @@ -6833,9 +6824,9 @@ dependencies = [ [[package]] name = "tor-netdir" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d3b84edfe732b77fb1420347902c06d92a1e6992e378ad19da6bdadcb8cd798" +checksum = "cd22ffd4d6aa9389ec8e20e9b0d579d61101682c5ec8379115a388e473e83297" dependencies = [ "async-trait", "bitflags 2.13.2", @@ -6846,10 +6837,10 @@ dependencies = [ "humantime", "itertools 0.15.0", "num_enum", - "rand 0.10.2", + "rand 0.10.3", "serde", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", "tor-basic-utils", "tor-error", @@ -6866,9 +6857,9 @@ dependencies = [ [[package]] name = "tor-netdoc" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b6abd7aaeb2bdbb3fbf760aab9aaa4e8e1a0f8b84d93a834df5a16882faa31e" +checksum = "78dc8a397a86fd00b17607941b204281dd8a77a6018ca8eedc257bdccb4f3aa7" dependencies = [ "amplify", "base64ct", @@ -6889,7 +6880,7 @@ dependencies = [ "memchr", "paste", "phf 0.14.0", - "rand 0.10.2", + "rand 0.10.3", "rangemap", "saturating-time", "serde", @@ -6898,7 +6889,7 @@ dependencies = [ "smallvec", "strum", "subtle", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", "tinystr", "tor-basic-utils", @@ -6919,9 +6910,9 @@ dependencies = [ [[package]] name = "tor-persist" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6f2a41d13a685e181361cfc3bdf25d319b96b56f5806594b6b8ed760d0099ed" +checksum = "e7a3bc60cbe74bf41cd63e2506a9a16bd52047668771578b12ff2453ec4c7522" dependencies = [ "amplify", "derive-deftly", @@ -6937,9 +6928,8 @@ dependencies = [ "sanitize-filename", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", - "tor-async-utils", "tor-basic-utils", "tor-error", "tracing", @@ -6949,9 +6939,9 @@ dependencies = [ [[package]] name = "tor-proto" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ddf767335ae2e255025536d48384b92c6b4b8557e5b345be600e7ca2de1c880" +checksum = "fceb9d4017849440584e541054b6a26517655a3ae48942b6e67476c5376262c3" dependencies = [ "aes", "amplify", @@ -6980,7 +6970,7 @@ dependencies = [ "pin-project", "polyval", "postage", - "rand 0.10.2", + "rand 0.10.3", "rand_core 0.10.1", "safelog", "slotmap-careful", @@ -6988,7 +6978,7 @@ dependencies = [ "static_assertions", "strum", "subtle", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-util", "tor-async-utils", @@ -7018,26 +7008,26 @@ dependencies = [ [[package]] name = "tor-protover" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab080a7551561bd56c0c2e3578b7114f0853700b96e43613fabc1fe06e5d885" +checksum = "3e69fbc196208a9d6232d2c38e32806f3bb064792fd84ac6ee12b5ba1e482240" dependencies = [ "caret", "derive-deftly", "paste", "serde_with", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-basic-utils", "tor-bytes", ] [[package]] name = "tor-relay-selection" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07a5c22b7cfd59c40e38d4e111906793c0d65cf881e31efe3a83cac42887b1c9" +checksum = "e420365ef1e89ee93b34b5ab71a6aa840cadcbe04e2ac4b72043846fb2f9e002" dependencies = [ - "rand 0.10.2", + "rand 0.10.3", "serde", "tor-basic-utils", "tor-linkspec", @@ -7047,9 +7037,9 @@ dependencies = [ [[package]] name = "tor-rtcompat" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "542f4e13054bbba8d764ee24178fd889f77afb59dca7c6ae0a370604118c20b6" +checksum = "99036f7a4fa9735e0beac7f241592da1a072c94a018e4fa702001700f5922291" dependencies = [ "amplify", "async-trait", @@ -7071,7 +7061,7 @@ dependencies = [ "rustls-pki-types", "rustls-webpki", "socket2", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-util", "tor-error", @@ -7084,9 +7074,9 @@ dependencies = [ [[package]] name = "tor-rtmock" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04cb78b3a39634e46c1bc795f62fca17b4d8a9404fead18750592a03216e1da3" +checksum = "9140515718465f1ede62fa33e96cc929acf075a3893cb349d1c606976c846a21" dependencies = [ "amplify", "assert_matches", @@ -7102,7 +7092,7 @@ dependencies = [ "priority-queue", "slotmap-careful", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-error", "tor-general-addr", "tor-rtcompat", @@ -7114,31 +7104,30 @@ dependencies = [ [[package]] name = "tor-socksproto" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f96e25e5d36b4aff59af40ec86f7ebb227a32b2f726ef1bc8cccf927f8fb31a" +checksum = "62d1f5c19f2339b2634556ed35865844276ab889708b482dc351fa18184c5b24" dependencies = [ - "amplify", "caret", "derive-deftly", "educe", "safelog", "subtle", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-bytes", "tor-error", ] [[package]] name = "tor-units" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a562c031f6c1f9d22f7bbcfbe715840d0e2cfe5e59d1296cc26000c17130555" +checksum = "aa976f4c84404c9aaf7e8690fe30904a69084d0069ff7b2dfc0c58919d65eebd" dependencies = [ "derive-deftly", "derive_more", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-memquota", ] @@ -7235,7 +7224,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", "symlink", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", "tracing-subscriber", ] @@ -7450,13 +7439,13 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.26.1" +version = "1.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" dependencies = [ "getrandom 0.4.3", "js-sys", - "rand 0.10.2", + "rand 0.10.3", "serde_core", "wasm-bindgen", ] @@ -7487,7 +7476,7 @@ dependencies = [ "peniko", "png 0.18.1", "smallvec", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -7504,7 +7493,7 @@ dependencies = [ "peniko", "png 0.18.1", "smallvec", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -7588,9 +7577,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -7601,19 +7590,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.78" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7621,9 +7611,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", @@ -7634,9 +7624,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -7653,9 +7643,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -7673,9 +7663,9 @@ dependencies = [ [[package]] name = "web-time-compat" -version = "0.2.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6ab47578e1cd38415489592da29326341e272bd281f4ddbc0c10b654a425ad7" +checksum = "b1c38a07cb4a2705c91feaedc9013a65481ec1b38d818b885d642b9f06ec6edd" dependencies = [ "web-time", ] @@ -8096,15 +8086,15 @@ dependencies = [ "oid-registry", "ring", "rusticata-macros", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", ] [[package]] name = "xxhash-rust" -version = "0.8.18" +version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" +checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7" [[package]] name = "yasna" @@ -8150,18 +8140,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.57" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.57" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 3960654a..b1038800 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustchan" -version = "1.6.5" +version = "1.6.6" edition = "2021" # Rust 1.99 is the supported minimum for the package and build tooling. rust-version.workspace = true @@ -32,10 +32,6 @@ panic = "abort" name = "rustchan-cli" path = "src/main.rs" -[[bin]] -name = "rustchan-updater" -path = "src/bin/rustchan-updater.rs" - [lib] name = "chan" path = "src/lib.rs" @@ -45,38 +41,34 @@ axum = { version = "0.8.9", features = ["multipart"] } axum-extra = { version = "0.12.6", features = ["cookie"] } tower = "0.5.3" tower-http = { version = "0.7.1", features = ["fs", "set-header", "compression-full", "limit", "trace"] } -tokio = { version = "1.53.1", features = ["full"] } +tokio = { version = "1.53.2", features = ["full"] } tokio-util = "0.7.19" captcha = { version = "1.0.0", default-features = false } -# Arti's tor-dirmgr requires rusqlite >=0.36,<0.40. Because rusqlite -# and libsqlite3-sys expose Cargo `links`, r2d2_sqlite 0.35/rusqlite 0.40 -# cannot coexist in the same graph. These are the newest Arti-compatible pair. -rusqlite = { version = "0.39.0", features = ["bundled", "backup"] } +# Arti 0.47 permits rusqlite >=0.36,<0.41. Keep the latest application and +# Tor directory-cache SQLite dependencies on the same bundled library. +rusqlite = { version = "0.40.2", features = ["bundled", "backup"] } r2d2 = "0.8.10" -r2d2_sqlite = "0.34.0" +r2d2_sqlite = "0.35.0" time = "0.3.55" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" # toml 1.0: serde parsing is built-in, no feature flag needed. -toml = "1.1.5" +toml = "1.1.6" -argon2 = "0.5.3" +argon2 = "0.6.0" sha2 = "0.11.0" # Signed update verification reuses the TLS provider and existing compression/semver crates. ring = "0.17.14" semver = "1.0.28" -flate2 = "1.1.9" +flate2 = "1.1.10" tar = { version = "0.4.46", default-features = false } -reqwest = { version = "0.13.4", default-features = false, features = ["rustls-no-provider", "blocking"] } +reqwest = { version = "0.13.5", default-features = false, features = ["rustls-no-provider", "blocking"] } subtle = "2.6.1" hex = "0.4.3" -# rand_core 0.10 updates RustChan's direct RNG trait dependency, but the -# password-hash stack still carries rand_core 0.6 transitively for salt -# generation. Keep direct OS entropy reads on getrandom::SysRng and only use -# password-hash's re-exported rand_core where that older trait set is required. +# Token and password-salt generation read OS entropy through getrandom::SysRng. rand_core = "0.10.1" getrandom = { version = "0.4.3", features = ["sys_rng"] } @@ -96,9 +88,9 @@ hayro = { version = "0.7.1", default-features = false, features = ["embed-fonts" # Apply JPEG Orientation tags before thumbnailing so phone photos display upright. kamadak-exif = "0.6.1" -clap = { version = "4.6.6", features = ["derive"] } +clap = { version = "4.6.7", features = ["derive"] } -uuid = { version = "1.26.0", features = ["v4", "serde"] } +uuid = { version = "1.27.0", features = ["v4", "serde"] } chrono = { version = "0.4.45", features = ["serde"] } dashmap = "6.2.1" parking_lot = "0.12.5" @@ -121,7 +113,7 @@ tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter", "fmt", "json"] } tracing-appender = "0.2.5" -thiserror = "2.0.20" +thiserror = "2.0.21" tempfile = "3.27.0" # Tor / onion-service stack @@ -137,16 +129,16 @@ tempfile = "3.27.0" # Verification: # cargo tree --edges features | grep -iE "openssl|native.tls" # expect nothing # cargo tree -i openssl-sys # expect error: not found -# Arti 0.46.0 requires Rust 1.91. Congestion control is now unconditional; +# Arti 0.47.0 requires Rust 1.92. Congestion control is unconditional; # the former flowctl-cc feature has been removed upstream. -arti-client = { version = "0.46.0", default-features = false, features = ["tokio", "rustls", "onion-service-service"] } +arti-client = { version = "0.47.0", default-features = false, features = ["tokio", "rustls", "onion-service-service"] } # tor-hsservice / tor-cell: also part of the arti workspace. Their `full` # default feature bundle can activate the native-tls TLS backend through # tor-rtcompat. Disable defaults; add back only what the build requires. # If compilation fails with "feature X not found", re-add that feature here. -tor-hsservice = { version = "0.46.0", default-features = false } -tor-cell = { version = "0.46.0", default-features = false } +tor-hsservice = { version = "0.47.0", default-features = false } +tor-cell = { version = "0.47.0", default-features = false } futures = "0.3.34" sha3 = "0.12.0" @@ -158,8 +150,8 @@ rustls-pki-types = "1.15.1" # RustChan installs rustls's ring provider explicitly at startup. Keep every # TLS consumer on that provider instead of also compiling the unused aws-lc-rs # defaults; logging, TLS 1.2, and the system-independent root set stay enabled. -tokio-rustls = { version = "0.26.5", default-features = false, features = ["logging", "ring", "tls12"] } -rustls = { version = "0.23.44", default-features = false, features = ["logging", "ring", "std", "tls12"] } +tokio-rustls = { version = "0.26.6", default-features = false, features = ["logging", "ring", "tls12"] } +rustls = { version = "0.23.45", default-features = false, features = ["logging", "ring", "std", "tls12"] } # Optional — gated by Cargo features rustls-acme = { version = "0.15.4", default-features = false, features = ["ring", "tls12", "tokio", "webpki-roots"], optional = true } @@ -170,7 +162,7 @@ x509-cert = { version = "0.3.0", optional = true } # HTTPS listen integration for Axum (handles Static cert path) axum-server = { version = "0.8.0", features = ["tls-rustls-no-provider"] } hyper = { version = "1.11.1", features = ["http1", "http2", "server"], optional = true } -hyper-util = { version = "0.1.20", features = ["tokio", "server"] } +hyper-util = { version = "0.1.21", features = ["tokio", "server"] } # Terminal UI # crossterm provides cross-platform cursor, event, and raw-mode control. @@ -181,7 +173,7 @@ ratatui = { version = "0.30.2", default-features = false, features = ["crossterm [target.'cfg(unix)'.dependencies] # Safe in-process filesystem-capacity queries for upload disk-space preflights. -rustix = { version = "1.1.4", features = ["fs"] } +rustix = { version = "1.1.5", features = ["fs", "process", "thread", "net", "stdio"] } [features] diff --git a/Dockerfile b/Dockerfile index bf91df5c..0791ddb0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ -# syntax=docker/dockerfile:1.7 +# syntax=docker/dockerfile:1 -FROM rust:1.99.0-bookworm AS builder +FROM rust:1.99.0-trixie AS builder WORKDIR /build ARG CARGO_BUILD_JOBS=2 ENV CARGO_BUILD_JOBS=${CARGO_BUILD_JOBS} @@ -16,7 +16,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \ cargo build --locked --release --bin rustchan-cli && \ install -Dm755 target/release/rustchan-cli /out/rustchan-cli -FROM debian:bookworm-slim AS runtime +FROM debian:trixie-slim AS runtime LABEL org.opencontainers.image.title="RustChan" \ org.opencontainers.image.description="Self-hosted imageboard" \ org.opencontainers.image.source="https://github.com/csd113/RustChan" \ diff --git a/README.md b/README.md index f8182193..72a42112 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Conversations are organized into **boards** (topics such as photography or techn RustChan runs as a single app, with a built-in database. You do not need to set up a separate database server. -**Source version:** v1.6.5. **Downloads:** [latest published release](https://github.com/csd113/RustChan/releases/latest). **Docker image:** `ghcr.io/csd113/rustchan:latest`. +**Source version:** v1.6.6. **Downloads:** [latest published release](https://github.com/csd113/RustChan/releases/latest). **Docker image:** `ghcr.io/csd113/rustchan:latest`. ## Quick start @@ -70,14 +70,14 @@ For **Docker Compose, updates, backups, or a public website**, follow the [conta Open the [latest release](https://github.com/csd113/RustChan/releases/latest), download the ZIP for your operating system, and extract it into a folder where the app can save files. -ZIP names include the release version; the v1.6.5 names are shown below. Choose the matching files from the published release you download. +ZIP names include the release version; the v1.6.6 names are shown below. Choose the matching files from the published release you download. | Your computer | Download | |---|---| -| Linux, Intel or AMD 64-bit | `rustchan-cli-v1.6.5-linux-x86_64.zip` | -| Linux, ARM64 | `rustchan-cli-v1.6.5-linux-arm64.zip` | -| macOS, Apple silicon | `rustchan-cli-v1.6.5-macos-apple-silicon.zip` | -| Windows, Intel or AMD 64-bit | `rustchan-cli-v1.6.5-windows-x86_64.zip` | +| Linux, Intel or AMD 64-bit | `rustchan-cli-v1.6.6-linux-x86_64.zip` | +| Linux, ARM64 | `rustchan-cli-v1.6.6-linux-arm64.zip` | +| macOS, Apple silicon | `rustchan-cli-v1.6.6-macos-apple-silicon.zip` | +| Windows, Intel or AMD 64-bit | `rustchan-cli-v1.6.6-windows-x86_64.zip` | From a terminal in the extracted folder, run: diff --git a/SETUP.md b/SETUP.md index 7a435964..45015054 100644 --- a/SETUP.md +++ b/SETUP.md @@ -2,7 +2,7 @@ Current setup and deployment guide for Linux, macOS, and Windows. -Current version: `1.6.5`. +Current version: `1.6.6`. This guide reflects the current RustChan architecture: @@ -704,7 +704,7 @@ Back that directory up if the onion address matters. ### Optional administrator-controlled software updates -For immutable native Linux version directories, a separate restricted updater service, signing trust setup and automatic backup/rollback, follow [Software Updates](docs/software-updates.md). The existing single-binary service remains deployment-managed until explicitly converted. +Eligible native Linux installs use the same `rustchan-cli` executable for the application and update controller. Start with the existing account and data directory; see [Software Updates](docs/software-updates.md) for ownership requirements, verified backups, source adoption and exceptional reboot recovery. Protected installations and containers use their deployment's upgrade procedure. ### Internal media processing diff --git a/deny.toml b/deny.toml index 360b3b7d..f2f2b43f 100644 --- a/deny.toml +++ b/deny.toml @@ -13,7 +13,7 @@ yanked = "deny" # direct RSA usage. A call-site audit found public verification plus generic # private-key generation/signing support, but no calls to RSA decryption APIs or # decryption oracle, and no evidence that RustChan's v3 onion-service flow -# invokes private RSA operations. Re-reviewed with Arti 0.46.0 on 2026-09-08; +# invokes private RSA operations. Re-reviewed with Arti 0.47.0 on 2026-10-03; # this temporary exception must be re-reviewed on every Arti upgrade. [[advisories.ignore]] id = "RUSTSEC-2023-0071" @@ -22,7 +22,7 @@ id = "RUSTSEC-2023-0071" # paste is a transitive proc-macro dep of arti-client / tor-hsservice. It runs # during compilation but is not linked into RustChan's runtime; no known # vulnerability or runtime exploit is associated with this maintenance notice. -# Re-reviewed with Arti 0.46.0 on 2026-09-08. This exception is temporary and +# Re-reviewed with Arti 0.47.0 on 2026-10-03. This exception is temporary and # must be re-reviewed on every Arti upgrade. [[advisories.ignore]] id = "RUSTSEC-2024-0436" diff --git a/docs/containers.md b/docs/containers.md index d9ccbe7e..2042fe2c 100644 --- a/docs/containers.md +++ b/docs/containers.md @@ -56,7 +56,7 @@ Stop RustChan before taking a filesystem copy of `/data` so the SQLite database ```bash docker run --rm -v rustchan-data:/data:ro -v "$PWD":/backup \ - debian:bookworm-slim tar -C /data -czf /backup/rustchan-data.tar.gz . + debian:trixie-slim tar -C /data -czf /backup/rustchan-data.tar.gz . docker start rustchan ``` diff --git a/docs/dependency-upgrade-1.6.6.md b/docs/dependency-upgrade-1.6.6.md new file mode 100644 index 00000000..742b729f --- /dev/null +++ b/docs/dependency-upgrade-1.6.6.md @@ -0,0 +1,251 @@ +# Dependency and upgrade review for 1.6.6 + +Reviewed against stable upstream releases on 2026-10-03. Rust 1.99 remains the +supported compiler. No OxideAV or OxiMedia dependencies were introduced. + +## Dependency changes + +All 67 direct root-manifest dependencies were checked against the crates.io +registry, including optional and development dependencies. The vendored AAC +manifest and build/audit tools were checked separately. Seventeen root dependency +requirements changed: + +| Dependency | Previous requirement | New requirement | +| --- | --- | --- | +| tokio | 1.53.1 | 1.53.2 | +| rusqlite | 0.39.0 | 0.40.2 | +| r2d2_sqlite | 0.34.0 | 0.35.0 | +| toml | 1.1.5 | 1.1.6 | +| argon2 | 0.5.3 | 0.6.0 | +| flate2 | 1.1.9 | 1.1.10 | +| reqwest | 0.13.4 | 0.13.5 | +| clap | 4.6.6 | 4.6.7 | +| uuid | 1.26.0 | 1.27.0 | +| thiserror | 2.0.20 | 2.0.21 | +| arti-client, tor-hsservice, tor-cell | 0.46.0 | 0.47.0 | +| tokio-rustls | 0.26.5 | 0.26.6 | +| rustls | 0.23.44 | 0.23.45 | +| hyper-util | 0.1.20 | 0.1.21 | +| rustix | 1.1.4 | 1.1.5 | + +The lockfile selects TOML 1.1.6+spec-1.1.0; its metadata suffix does not indicate a +prerelease. The vendored AAC implementation remains Symphonia 0.6.1 with its +existing source patch; only its lazy_static lower bound changed to 1.5.1. + +Arti 0.47 permits rusqlite >=0.36,<0.41, allowing the newest application SQLite +pair without duplicate bundled SQLite versions. Argon2 0.6 required the new PHC +parser/error names and byte-salt API. Salt generation still uses OS entropy; +Argon2id v19, memory/time/parallelism settings and verification cost limits remain +unchanged. A PHC fixture generated independently with Argon2 0.5.3 verifies with +the new implementation, including rejection of a wrong password. + +`cargo update --dry-run --verbose` reports no remaining compatible updates. Four +transitive packages remain behind a newer stable release because their latest +upstream parents restrict them: + +| Package | Selected | Newer stable | Upstream restriction | +| --- | --- | --- | --- | +| derive-deftly / derive-deftly-macros | 1.12.1 | 1.13.0 | Arti uses ~1.12.0 | +| generic-array | 0.14.7 | 0.14.9 | crypto-common 0.1.7 pins =0.14.7 | +| matchit | 0.8.4 | 0.8.6 | Axum 0.8.9 pins =0.8.4 | + +These constraints were retained rather than forking upstream dependencies. +The resolved graph has no dependency requiring a compiler newer than Rust 1.99, +and no openssl, native-tls or aws-lc-rs package with all features enabled. + +## Build and distribution tooling + +GitHub Actions were updated to current stable releases and remain pinned to full +commit IDs: checkout 7.0.1, upload-artifact 7.0.1, download-artifact 8.0.1, +rust-cache 2.9.2, install-action 2.87.23, action-gh-release 3.0.3, +setup-buildx-action 4.4.1, login-action 4.6.0, metadata-action 6.2.0 and +build-push-action 7.4.0. The toolchain action uses its reviewed stable commit with +explicit Rust 1.99.0. Hosted runner support and major-release behavior were +reviewed against upstream release notes. + +cargo-deny is 0.20.2 and cargo-audit is 0.22.2; cross remains at its latest stable +0.2.5. Container builder/runtime bases use Debian 13 Trixie. Runtime FFmpeg comes +from that stable distribution, retaining distribution security maintenance; +upstream FFmpeg is not compiled into the application image. Optional system +media tools remain operator-managed outside containers. + +Locked license/source/advisory policy passes with the existing narrowly +documented RSA timing and unmaintained paste exceptions. The Arti RSA exposure +was re-reviewed; this change does not add RSA private-key decryption. No advisory +exception or license allowance was broadened. + +## Upgrade compatibility corrections + +An isolated Linux fixture using the actual published 1.6.0 and 1.6.5 executables +reproduced startup failure on a canonical 1.6.0 database: the two newer post-query +indexes were missing, and the repair allowlist did not recognize the 1.6.0 stamp. +Supported stable prior package stamps now reach the existing narrowly checked +repairs. Complete structural/domain/integrity checks still reject unknown drift; +regressions verify post preservation, repeated migration and rejection without +stamping success. + +The unchanged published 1.6.5 artifact works after the documented offline +two-index preparation. The fixture preserved administrator authentication, board, +thread/post rows, settings, media and a private runtime file's contents/mode, then +restored the matching full stopped data and 1.6.0 executable and verified readiness. +The 1.6.5 manifest signature verified against the 1.6.0 public key; archive and +executable sizes/hashes matched the signed manifest. No published artifact or tag +was changed. See [the operator procedure](software-updates.md#manual-upgrade-of-a-source-install). + +A separate static audit found that the published 1.6.5 manifest advertises +minimum updater 1.6.0 even though managed application startup now requires the +closed `Started` request introduced after 1.6.0. The old controller has no such +request and cannot acknowledge it. The manual/source fixtures above do not use +that native IPC path. The accepted replacement must correct signed compatibility +bounds and test old-controller adoption; migration repair alone does not establish +every managed upgrade path. + +The independent intermittent CLI startup failure used a one-second request +checkout deadline while waiting for the first initialized connection. First +initialization now has a bounded 30-second deadline; normal request checkout +remains one second. Slow-first-connection, unusable-database, blocked-spare, +capacity, WAL/FULL durability and overload regressions pass. + +Native Linux source builds now include the fixed official Ed25519 public +verification key. A fixture from the published 1.6.5 release verifies with that +embedded identity; modified manifest bytes and a modified key fail verification. +Only the public key is included. Trust is not established by downloading a new +key alongside a release. Container/native installation boundaries remain enforced. +The install confirmation explicitly states that the official prebuilt executable +replaces local source modifications while retaining application data/configuration. + +The single-binary recovery policy is accepted: normal operations restart only +RustChan; exceptional loss of descendant-exit proof can require a host reboot. +The implementation retains a durable launch record before spawning writers and +blocks same-boot recovery while writer lifetime remains uncertain. A free lock +or missing PID cannot clear that uncertainty. Restarting only RustChan cannot +substitute for a changed kernel boot or proven complete descendant cleanup. + +The same-executable full controller and process monitor are integrated. The +separate updater binary target has been removed; package manifests require +updater 1.6.6 and `--update-info` advertises protocol 2. The user authorized +protected main integration and the 1.6.6 release, with native reboot/power-loss +testing handled separately. Actual kernel boot recovery remains unvalidated; +the accepted exceptional reboot fallback does not establish that proof. + +## Validation + +The ordinary-UID Linux Rust foundation suite passed 47 checks. It includes +descriptor closure on malformed/truncated/extra-rights/EOF frames, CLOEXEC and +close-only lease handoff, real competing-process admission during lock +conversion, and injected ENOSPC failures at the real lifetime write/rename/fsync +boundaries. A double-forked process that starts a new session and closes its +descriptors is reaped before lifetime completion. That test first caught an +incorrect process-group wait and passed after the wait was corrected to cover +every child. The original failing run remains preserved. + +SQLite/configuration transaction regressions prove that a failed replacement +controller initialization rolls back before commit, while a lost acknowledgment +after commit preserves accepted writes and the healthy replacement. These are +actual Rust/filesystem/SQLite tests, not full native update or power-loss proof. +The actual Linux executable also passes a disposable foreground fixture on +Linux-local storage: independent admin CLI admission while serving, a competing +server rejected before database/configuration changes, protected root-owned +executable serving in manual mode, preserved stdin, parent-death cleanup, and +surviving-monitor descendant cleanup after guardian SIGKILL. Combined root and +guardian loss retains an uncertain lifetime and refuses a same-boot cold start +without changing database/configuration bytes. Exact local source bytes are +retained and hash-verified. An interrupted initial retention can be replayed +before any application writer starts. The earlier macOS bind-mount fixture failed +the strict private-directory check and its red evidence remains retained; those +checks were not relaxed. + +The end-to-end offline fixture now passes actual executable installation, +newest full-controller handoff, durable canonical commit/public admission and +complete post-migration rollback. A compiled 1.6.6 source installs a compiled +1.6.7 candidate and atomically publishes it at the original source path. The +new controller's exact kernel identity replaces the old one while the root +remains active. Candidate health-check requests receive HTTP 503; public +admission opens only after Succeeded commits and the newest controller activates. +Admin IPC and matching independent CLI work after installation. + +A compiled 1.6.8 fixture then changes the database/schema, settings, media and +private runtime file before deliberately failing. Rollback restores 1.6.7, +validates database integrity/foreign keys, removes failed migration state and +preserves file hashes/modes and an acknowledged write made after the successful +installation. A subsequent stopped/offline CLI and cold launch select the newest +committed controller and preserve the rollback result. Evidence is retained in +`output/dependency-upgrade-1.6.6/native-update-run-3/`. + +The final protocol-2 runtime repeat also passed in +`output/dependency-upgrade-1.6.6/native-update-run-6-complete-inputs/`. +Both signed synthetic packages were complete and hash-checked before startup; +all three compiled fixture binaries contained the offline fetch substitution. +The earlier shared-cache mix-up and incomplete-input runs remain available and +are not counted as successful rollback evidence. Production trust stayed unchanged. + +The fixture replaces network fetch and the embedded public key only in a copied, +ignored build context, using a synthetic Ed25519 identity. Actual signature, +archive, ownership, transaction and readiness checks run. Its deliberate failed +candidate changes only disposable fixture data. No production trust, official +release key, published asset or tag was changed. Production updater regressions +pass 47 tests with two ignored process helpers. This does not establish native +kernel reboot/power-loss behavior or final release approval. + +Final macOS workspace regressions passed 1,349 tests in each library/application +target, with four existing ignored tests in each, all five CLI process tests, +the policy and rendered-fixture regressions, and the documentation test. Strict +all-target/all-feature Clippy, warning-free documentation, and both no-default +and ACME-only feature checks passed on macOS. Linux strict all-target/all-feature +Clippy and warning-free documentation passed; +the complete Linux unit suite passed as ordinary UID 501: 1,363 passed and six +existing helpers ignored. Packaging/signing regressions passed all seven tests. +Evidence is retained in the `final-workspace-tests-3.log`, +`final-linux-unit-tests-3.log`, `final-macos-clippy-2.log`, +`final-macos-docs-2.log`, `final-no-default-features-2.log`, and +`final-packaging-tests-2.log` files under `output/dependency-upgrade-1.6.6/`. +Earlier failed checks remain available. The Linux test runner was corrected to +use its own writable executable directory, the external `/bin/kill` needed by +existing liveness assertions, and an init process to reap orphaned children; +application assertions and timeouts were not relaxed. + +The final complete Chromium gate passed all 325 tests: 287 expected results, +38 skips, zero unexpected failures and zero flaky results, in 17 minutes +44 seconds. Both the dashboard-theme and long-content cases that timed out in +earlier complete runs passed. The suite used its original two workers, zero +retries, assertions and timeouts. Reports, browser lifecycle logs and host +resource samples are retained in `browser-chromium-final-clean/`. + +The preceding complete run's 90-second dashboard timeout remains recorded as a +failure in `browser-chromium-final-complete/`. Its trace already contained the +expected open attribute, a 75-millisecond successful dashboard response and +prompt assets. Chromium's `Runtime.callFunctionOn` did not return until page +teardown; the installed driver's first assertion evaluation was unbounded by +the assertion timeout. Three unchanged isolated cases and three unchanged +desktop-clock-then-contrast sequences passed before the clean full run. The +precise renderer/OS cause remains unestablished; no speculative application or +harness patch was made. See `browser-dashboard-stall-diagnosis.json`. All failed +evidence remains preserved. + +Actual native Linux reboot/power-loss testing remains unperformed and is being +handled separately under the user's release decision. No host installation or +reboot was performed. This is an explicit validation limitation. + +End-of-work cleanup removed the idle Cargo target after a preservation assertion +failed to stop the sequence. The exact macOS executable from the final passing +browser run (SHA-256 `a7dbdfc00fa37ea05780dd27c0f8bd7407d971136568ef556fbd0a9926e4f34f`) +was not retained; 21 preserved runtime copies had no matching build. Its recorded +test results, source and build metadata remain available, but exact-byte replay +is limited. The preserved earlier all-feature macOS executable is a distinct +build. Linux fixture executables and all recovery/browser evidence remain intact. + +Before the updater redesign, strict all-target/all-feature compilation, Clippy +and documentation passed. The dependency/migration baseline full workspace suite +passed: 1,346 unit tests in each library/application target, +five CLI process tests, the deny-policy regression and the documentation test; +four existing unit tests remain explicitly ignored in each target. Packaging +and signing regressions passed. The full Chromium run had 286 passes, 38 skips +and one long-content timeout; that unchanged case passed alone and in three +repeats. The full run is still recorded as failed. Focused WebKit/no-JavaScript +update and backup/restore coverage had 33 passes and one skip. Native kernel +reboot/power-loss validation remains separate follow-up work; those container +fixtures do not establish real boot or power-loss behavior. + +Local instructions and the complete browser harness remain ignored and +untracked. Their preserved copies and regression evidence are local artifacts; +they are not added to release commits or container contexts. diff --git a/docs/settings-restarts.md b/docs/settings-restarts.md index 23b3983e..b43b345e 100644 --- a/docs/settings-restarts.md +++ b/docs/settings-restarts.md @@ -49,21 +49,16 @@ authoritative at startup. Existing sessions retain their stored expiry when only session lifetime changes. Manual database actions and content/moderation operations continue to apply immediately. -## Native managed Linux - -Use the existing `rustchan-updater` deployment in [Software Updates](software-updates.md). -Install the matching updater build (1.6.0 or newer with settings restart support), -fixed root-owned configuration, service units and polkit rule. No additional -privileged daemon, socket or service authority is introduced. The same -`/run/rustchan-updater/control.sock` accepts only the configured web UID, with -0660 socket permissions and bounded request/reply sizes and deadlines. - -The Restart operation has only the authenticated administrator ID and the running -process's server-generated identity. It cannot name a command, executable, -argument, service, environment or filesystem path. The helper still controls only -`rustchan.service` with fixed systemctl start/stop argv, without a shell. Existing -software-install password reauthentication, expiring one-use approvals, release -signatures and archive validation remain required for installation. +## Native Linux + +Eligible installations use the same `rustchan-cli` invocation and ordinary account +for application supervision and the full update controller; see +[Software Updates](software-updates.md) for adoption and ownership requirements. +Private local IPC authenticates the exact selected application process. The +Restart operation contains only the administrator ID and server-generated +running instance. It cannot name a command, executable, arguments, service, +environment or filesystem path. Software installation still requires password +reauthentication, an expiring one-use approval and verified signed release bytes. The updater's existing `update.lock`, durable status phases and startup/recovery admission also own settings restarts. A shared `.settings.lock` file serializes all @@ -78,11 +73,10 @@ The updater retains the last ready configuration in its own private state direct After gracefully stopping the service it starts the same executable, requires the fixed loopback `/readyz` to report the expected version, healthy database and a new process identity in the nonsecret `X-RustChan-Instance` readiness header, and checks that the replacement loaded the requested settings. -Startup and health checks are bounded to 60 seconds. Keep `health_port` aligned -with an HTTP application listener reachable at 127.0.0.1; the supplied unit pins -CHAN_HOST/CHAN_PORT to that endpoint. Changes that remove that probe path cannot +Startup and health checks are bounded to 60 seconds. Keep the effective HTTP port reachable at 127.0.0.1. Original launcher port +overrides continue to apply to replacement processes. Changes that remove that probe path cannot commit, including HTTPS-only configurations without a suitable local backend. -Use the reverse proxy for public TLS with this supplied service model. +Use a reverse proxy when public TLS would otherwise remove the local probe path. Failed or interrupted settings startup enters the existing updater recovery barrier, stops the application before restoring the verified last healthy file, diff --git a/docs/software-updates.md b/docs/software-updates.md index d20792ac..9649b773 100644 --- a/docs/software-updates.md +++ b/docs/software-updates.md @@ -8,84 +8,162 @@ package version**. It never reports release discovery or update transactions. ## Boundary and supported deployments -The web process sends only Status, Check, Ready, Started, Restart and Install operations over -`/run/rustchan-updater/control.sock`. Install contains a one-use opaque approval -and an authenticated administrator ID; no request supplies a path, URL, command, -service or environment. Existing session-scoped CSRF and same-origin checks apply -to POST mutations. Installation additionally requires the current administrator -password and typing INSTALL. Failures share the existing login lockout. Approvals -expire 10 minutes after a compatible release check and are durably consumed -before an installation worker starts. - -The updater accepts only the configured web UID, verifies socket/file ownership, -serializes transactions using OS file locks and persists an atomic fsynced journal. -Configuration and trust keys are root-owned. The web identity cannot write the -updater executable, state, backup snapshots, version directories or active link. -Service actions are fixed argv calls to systemctl for **rustchan.service** start -and stop, with bounded execution; there is no shell or arbitrary service choice. - -Native installation supports Linux GNU x86_64 and aarch64 under the supplied -systemd setup. macOS, Windows, custom layouts and containers are deployment-managed -and expose Check only. Never enable native managed mode inside a container. -Ordinary manual/scheduled backup and restore controls remain available. Native installation holds RustChan’s existing maintenance gate through the preparation transaction, preventing overlap with backup/restore while ordinary posts continue. If an install reply is lost before approval consumption can be confirmed, maintenance remains paused; inspect updater status/logs before restarting the web service to clear an unconsumed request. - -## One-time Linux setup - -Use a disposable staging machine first. Keep an independent full backup before -converting an existing deployment. Do not perform conversion against a running -application. Commands below assume the default `/var/lib/rustchan` layout and an -existing configured instance. Override deployments require conversion to that -layout before native updates; database/media/runtime paths cannot point elsewhere. - -Create distinct locked service identities `rustchan` and `rustchan-updater` with -matching primary groups. Build both binaries with `cargo build --locked --release --bins` from the corresponding release source. Install the updater outside the version directories at -`/usr/local/libexec/rustchan-updater`, root-owned mode 0755. It is operator-managed; -software updates cannot replace their own trust/transaction engine. A release -requiring a newer updater is refused until the operator upgrades this executable. - -Prepare these paths and permissions: - -| Path | Owner | Mode / access | -| --- | --- | --- | -| `/etc/rustchan` | root:root | 0755, no group/other write | -| `/etc/rustchan/updater.toml` | root:root | 0644 | -| `/etc/rustchan/update-public-key.hex` | root:root | 0644 | -| `/opt/rustchan` and `versions` | rustchan-updater:rustchan-updater | 0755, no group/other write | -| `/opt/rustchan/versions//rustchan-cli` | rustchan-updater:rustchan-updater | 0755 | -| `/var/lib/rustchan-updater` and `backups` | rustchan-updater:rustchan-updater | 0700 | -| `/var/lib/rustchan` and its persistent files | rustchan:rustchan | existing RustChan private permissions | -| `/run/rustchan-updater` | rustchan-updater:rustchan | 0750 | -| `control.sock` | rustchan-updater:rustchan | 0660; peer UID must still match | - -Copy the running binary into `versions//rustchan-cli` and create -a relative `current -> versions/` symlink inside `/opt/rustchan`. -Preserve `chan.db`, `settings.toml`, `boards` and `runtime`; required roots must -exist and must not contain symlinks, hardlinked files or special files. -Ensure every ancestor of protected installation/state/key/config paths is owned -by root or the updater and cannot be written by the web account. Root-owned sticky -temporary parents are tolerated for isolated tests, not recommended deployment. - -Install the unit files and polkit rule from `deploy/systemd`, all root-owned. -Set `web_uid` in `/etc/rustchan/updater.toml` to `id -u rustchan`, confirm -`health_port` matches a working loopback HTTP `/readyz` listener (use the reverse proxy for public TLS), and create the private backups -directory before starting the updater. Install the trusted public key **through -an independently authenticated operator channel**. A downloaded key is not trusted -merely because it appears beside release assets. - -For managed updates, enabled ACME state must remain under the data directory’s `runtime/` tree; external mutable caches are rejected before stopping RustChan. - -The updater's systemd capability set allows reading owner-only application files -and restoring their original owner/mode. `ProtectSystem=strict`, `ProtectHome` -and explicit writable paths confine filesystem mutation to the managed application, -state and data roots. The web service remains unprivileged with NoNewPrivileges. -The polkit rule grants only start/stop of rustchan.service to the updater identity. -Review these privileges against the host's systemd/polkit policies. - -Validate with `systemd-analyze verify deploy/systemd/*.service`, then install units, -`systemctl daemon-reload`, and start the updater before the application. Managed -startup refuses to open/migrate the database or reconcile filesystem operations -until recovered updater admission matches the active running package version. -Check `journalctl -u rustchan-updater -u rustchan` and administrator readiness. +Eligible native Linux GNU x86_64 and aarch64 installations use `rustchan-cli` +for the application, lifecycle guardians and selected full update controller. +The existing ordinary account must own the replaceable executable, its writable +installation directory and the private data directory. Paths must be free of +symlinks, hardlinked files, special permission bits and foreign write access. +Protected executables, root invocations, macOS, Windows and containers expose +release checks and use the deployment's manual upgrade procedure. + +Start with the usual `rustchan-cli --data-dir /absolute/path/to/data serve`. +The same invocation supervises its children and retains the exact original +executable before permitting application startup. The data directory's private +`.software-updates` state holds validated versions, durable journals and rollback +snapshots. Keep this state with the data directory. The first adoption requires +all older, uncooperative RustChan processes to be stopped beforehand. + +Local control authenticates the actual account, process ID and kernel start +identity of each selected child. Closed operations cannot supply a path, URL, +command, service or environment. Existing session-scoped CSRF and same-origin +checks apply to administrator POSTs. Installation additionally requires the +current administrator password and typing INSTALL. Approvals expire after ten +minutes and are consumed durably before installation starts. OS file locks +serialize installation, settings restarts and consistent snapshots. + +The official Ed25519 public verification key is embedded in source builds. +Installing an official prebuilt release replaces local source modifications; +application data and configuration are retained. Same-account supervision +protects against accidental races and ordinary descendant loss. It does not +create a security boundary against malicious code already running as that +account and able to modify its installation or data. + +A durable pre-spawn lifetime record and guardians track every admitted writer. +Normal updates and recoverable failures restart RustChan. Exceptional loss of +complete descendant-exit proof can require a **host reboot**. A missing PID, +free lock, process restart or container recreation cannot substitute for that +proof. After a real changed kernel boot, the retained committed controller +recovers the journal before permitting application migrations or public traffic. +Keep independent backups before recovery and preserve logs for diagnosis. +Native reboot and power-loss behavior has not yet been validated; that testing +is being handled separately from the 1.6.6 release. + +## Manual upgrade of a source install + +Compiling from source is supported. The controlled single-binary layout begins +with 1.6.6; earlier installations require one stopped, manual adoption. New signed +packages declare minimum updater 1.6.6 rather than accepting controllers whose +startup and handoff protocol cannot run them. An unexpected source rebuild after +retention is preserved and refused before data writes; use an explicit stopped +adoption and retain the matching full backup instead of bypassing its byte check. + +For an existing source installation: + +1. Identify the running executable, its **absolute data directory**, service + identity, working directory, environment and launch arguments. Without an + explicit `--data-dir`, data defaults to `rustchan-data` beside the executable; + moving the executable can otherwise look like a fresh installation. +2. Build the chosen release in a separate checkout with + `cargo build --locked --release --bin rustchan-cli`. Keep the running source tree, + executable and data untouched during compilation. Alternatively use the + matching official platform release and verify it using an independently + authenticated signing key. Retain the old executable for rollback. +3. Stop the application through its existing service manager. Confirm it has + exited, then copy the **entire** data directory to a separate private backup, + preserving ownership and modes. Include SQLite WAL/SHM files if present, + settings, boards/media, runtime, Tor identity, TLS state and any configured + external persistent paths. A database-only backup cannot restore an upgrade. +4. If installing the already published **1.6.5 over 1.6.0**, perform the offline + preparation below against the stopped database, after that full backup. + Releases with the corrected migration perform this step during startup. +5. Replace the executable at its existing location, retaining its owner and + executable mode. Start with the same identity, environment and arguments; + use the same absolute `--data-dir`. Check `/readyz` reports the chosen running + version, administrator login works, and existing boards/posts/media remain + available. Run `admin db-status` against that same data directory to verify + the resulting schema. Inspect startup logs before reopening public access. +6. If any verification fails, stop the new process. Retain the failed state for + diagnosis, restore the **matching complete stopped data backup and old + executable together**, and restart the old service. Verify its readiness and + administrator login. Never run the old binary on the migrated database or + manually lower `schema_version`. + +### Preparing 1.6.0 for the published 1.6.5 release + +The published 1.6.5 executable fails startup on a canonical 1.6.0 database with +`missing index idx_posts_board_ip_created; missing index idx_posts_thread_live`. +Its repair allowlist omitted the 1.6.0 package stamp, although both indexes are +already recognized additive repairs. The release signature and package remain +valid; this is a database migration compatibility error. + +For this **specific** error and baseline, stop the application and take the full +backup above. An operator with Python 3 can add the two indexes to the stopped +database using the following command, substituting its actual absolute path: + +```sh +python3 - /absolute/path/to/rustchan-data/chan.db <<'PY' +import sqlite3 +import sys +from pathlib import Path + +path = Path(sys.argv[1]) +if not path.is_absolute() or not path.is_file(): + raise SystemExit("Expected the existing stopped database's absolute path") +with sqlite3.connect(path) as db: + db.execute("BEGIN IMMEDIATE") + if db.execute("SELECT version FROM schema_version").fetchall() != [("1.6.0",)]: + raise SystemExit("This preparation applies only to a 1.6.0 database") + if db.execute("PRAGMA integrity_check").fetchall() != [("ok",)]: + raise SystemExit("Database integrity failed; do not upgrade") + if db.execute("PRAGMA foreign_key_check").fetchall(): + raise SystemExit("Foreign-key validation failed; do not upgrade") + for name, sql in ( + ("idx_posts_thread_live", "CREATE INDEX idx_posts_thread_live ON posts(thread_id, id)"), + ("idx_posts_board_ip_created", "CREATE INDEX idx_posts_board_ip_created ON posts(board_id, ip_hash, created_at DESC)"), + ): + if db.execute("SELECT 1 FROM sqlite_master WHERE name = ?", (name,)).fetchone(): + raise SystemExit("Index already exists; inspect the schema instead of applying this preparation") + db.execute(sql) + # Commit both additive indexes together. Do not change rows or schema_version. +PY +``` + +Keep the application stopped between preparation and replacement with 1.6.5. +The old executable's exact schema verifier also rejects the newer indexes, so +returning to 1.6.0 requires the complete pre-preparation backup. If a different +schema mismatch is reported, restore that backup and investigate; do not add +arbitrary indexes, drop constraints or edit the version stamp to bypass checks. +The unchanged official Linux ARM64 1.6.0 and 1.6.5 binaries have been tested on an +isolated Linux fixture through this route, including administrator authentication, +post/config/media/private-file preservation and full data-and-binary rollback. +Adopt the controlled single-binary layout only after the old invocation is stopped. + +The published 1.6.5 native manifest also advertises minimum updater 1.6.0, but its +managed startup requires the newer `Started` control operation, which the 1.6.0 +updater cannot recognize. Keeping an old 1.6.0 updater while replacing only the +managed application with 1.6.5 is therefore not a supported transition. The +manual source procedure above uses ordinary unmanaged startup and does not +exercise that separate control protocol. The 1.6.6 candidate implements the +single-binary layout, advertises protocol 2 and requires updater 1.6.6 in new +signed packages. Actual kernel reboot/power-loss validation remains separate +follow-up work and has not been established by process-loss fixtures. + +## Adopting an earlier managed deployment + +Take the complete stopped backup described above and retain the old application +and its matching controller for rollback. Remove the previous deployment's +managed-controller environment setting before starting the new ordinary-account +invocation. Keep the same absolute data directory, account, working directory, +environment and port arguments. An existing service manager may launch this one +executable; desktop source launches work through the same foreground invocation. +Protected installations remain deployment-managed until the operator deliberately +prepares an eligible layout. The application never changes ownership or gains +privileges to adopt protected files. + +The separate updater units and polkit examples under `deploy/systemd` describe +historical 1.6.0–1.6.5 deployments. They are not the single-binary source setup. +Do not mix an old controller with a new application merely by changing its +version link. The old deployment must be fully stopped before adoption. ## Verification and transactions @@ -117,8 +195,9 @@ is retained after success. Activation intent is journaled before atomic current-link replacement. The new service performs its usual startup migration/reconciliation. The updater checks bounded `/readyz` health and expected running version, then independently checks -the expected recorded schema and database integrity. Success is persisted only -after these checks pass. HTTP mutations fail closed during activation/recovery; +the expected recorded schema and database integrity. The selected newest full controller must initialize before success is persisted. +Only after the durable terminal journal commits does it become active, publish +the new executable at the original source path and open public admission. Public requests fail closed during source activation/recovery; release discovery and download verification do not block ordinary writes. After an activation, migration, startup or readiness failure, the updater stops @@ -146,9 +225,8 @@ openssl pkey -in rustchan-update-key.pem -pubout -outform DER -out rustchan-upda ``` Store the private PEM as the repository secret `RUSTCHAN_UPDATE_SIGNING_KEY`. -Keep an offline recovery copy and never commit it. The trusted key file on the -installation contains the final 32 Ed25519 public-key bytes as 64 hex characters, -not the whole DER/PEM. The signing tool validates that the public DER has the +Keep an offline recovery copy and never commit it. The source build embeds the reviewed final 32 Ed25519 public-key bytes. Public +release artifacts provide the hex and PEM encodings for independent verification. The signing tool validates that the public DER has the Ed25519 prefix and emits `.hex`/`.pem` public artifacts for operator distribution. Key rotation is an explicit operator action, never an HTTP configuration change. @@ -167,9 +245,9 @@ artifacts, are explicitly marked prerelease, and cannot advance stable latest. Existing GHCR main/latest and version-tag policy remains unchanged. Run `python3 -m unittest discover -s tools -p test_update_package.py` for offline -packaging/signing regressions. Native systemd/polkit capabilities, reboot during -activation, actual migration binaries and service readiness still require a -disposable Linux/systemd staging deployment before production rollout. +packaging/signing regressions. Native kernel reboot and power loss during activation still require a disposable +Linux staging deployment before production rollout. Container process tests do +not establish those boot guarantees. ## Troubleshooting @@ -180,11 +258,16 @@ or expired approvals. Resolve insufficient disk space before retrying; snapshots include media and can be large. Never relax path ownership to fix an IPC failure. For failed rollback, keep RustChan stopped and inspect updater logs/journal and -immutable snapshots as the operator. Correct permissions, space, service/polkit or -snapshot integrity issues before restarting the updater. No web action can force +immutable snapshots as the operator. Correct permissions, space or snapshot +integrity issues before restarting RustChan. If descendant exit remains uncertain, +restart the host before journal recovery. No web action can force an unverified restore or clear failed recovery. Keep independent manual/offsite backups; native rollback snapshots protect the local transaction, not disk loss. ## Settings restarts -[Administrator settings restarts](settings-restarts.md) reuse this same socket, service controller, transaction lock, journal and recovery admission. Their rollback restores configuration only. Install the matching updater build before enabling this UI; newly packaged releases require updater 1.6.0 or newer. Software installation retains its existing password/approval and complete snapshot protections. +[Administrator settings restarts](settings-restarts.md) reuse the same selected +controller, transaction lock, durable journal and recovery admission. Their +rollback restores configuration only. The controller comes from the installed +1.6.6-or-newer executable. Software installation retains its password, approval +and persistent-state snapshot protections. diff --git a/src/bin/rustchan-updater.rs b/src/bin/rustchan-updater.rs deleted file mode 100644 index 8f6b255e..00000000 --- a/src/bin/rustchan-updater.rs +++ /dev/null @@ -1,15 +0,0 @@ -#![expect( - unused_crate_dependencies, - reason = "the updater links the shared library; Cargo also exposes dependencies used only by the web server" -)] -//! Restricted native Linux updater daemon. Configuration is operator-owned. - -/// Start the daemon with its fixed root-owned configuration. -#[tokio::main] -async fn main() -> anyhow::Result<()> { - rustls::crypto::ring::default_provider() - .install_default() - .map_err(|_provider| anyhow::anyhow!("TLS provider already configured"))?; - tracing_subscriber::fmt().with_env_filter("info").init(); - chan::updates::run(std::path::Path::new("/etc/rustchan/updater.toml")).await -} diff --git a/src/config.rs b/src/config.rs index 03552477..d98771c2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -58,6 +58,12 @@ static DATA_DIR_OVERRIDE: OnceLock = OnceLock::new(); /// Launcher port selected before immutable configuration is initialized. static CLI_PORT_OVERRIDE: OnceLock = OnceLock::new(); +/// Preserve only the original launcher port in startup-authenticated internal actors. +#[cfg(target_os = "linux")] +pub(crate) fn launcher_port() -> Option { + CLI_PORT_OVERRIDE.get().copied() +} + /// Absolute path to the directory the running binary lives in. fn binary_dir() -> PathBuf { env::current_exe() diff --git a/src/db/pool.rs b/src/db/pool.rs index 0cbde1f0..3083c7a7 100644 --- a/src/db/pool.rs +++ b/src/db/pool.rs @@ -21,18 +21,26 @@ pub(super) const CONNECTION_PRAGMAS: &str = " /// Maximum time callers wait for a pooled connection. const POOL_CONNECTION_TIMEOUT: Duration = Duration::from_secs(1); +/// Bound first connection initialization separately from request checkout waits. +const POOL_STARTUP_TIMEOUT: Duration = Duration::from_secs(30); + /// Require one initialized connection before startup; fill spare capacity asynchronously. fn build_pool(manager: SqliteConnectionManager, pool_size: u32) -> Result { // r2d2's build() waits for max_size connections by default. That couples // first-run startup to every spare initializer's scheduling even when a // usable connection is ready. Keep eager refill and the one-second request - // deadline, but validate readiness through a real checkout instead. + // deadline, but validate readiness through a real checkout instead. The + // first connection may need disk initialization and scheduler time under + // concurrent startup load, so it gets a separate bounded startup deadline. let pool = Pool::builder() .max_size(pool_size) .event_handler(Box::new(super::diagnostics::PoolEvents::new())) .connection_timeout(POOL_CONNECTION_TIMEOUT) .build_unchecked(manager); - drop(pool.get().context("Failed to initialize database pool")?); + drop( + pool.get_timeout(POOL_STARTUP_TIMEOUT) + .context("Failed to initialize database pool")?, + ); Ok(pool) } @@ -192,6 +200,32 @@ mod tests { Ok(()) } + /// Slow first-run initialization must not inherit the short request deadline. + #[test] + fn startup_allows_a_slow_first_connection_without_extending_request_waits() -> Result<()> { + let directory = tempfile::tempdir()?; + let manager = + r2d2_sqlite::SqliteConnectionManager::file(directory.path().join("slow-start.db")) + .with_init(|conn| { + std::thread::sleep( + super::POOL_CONNECTION_TIMEOUT + std::time::Duration::from_secs(1), + ); + conn.execute_batch(super::CONNECTION_PRAGMAS) + }); + let pool = super::build_pool(manager, 1)?; + anyhow::ensure!(pool.connection_timeout() == super::POOL_CONNECTION_TIMEOUT); + let connection = pool.get()?; + let started = std::time::Instant::now(); + anyhow::ensure!(pool.get().is_err(), "exhausted pool must remain bounded"); + anyhow::ensure!( + started.elapsed() < std::time::Duration::from_secs(3), + "request checkout inherited the startup deadline" + ); + super::install_or_migrate_schema(&connection)?; + crate::db::verify_database_schema(&connection)?; + Ok(()) + } + #[test] fn every_pool_connection_enforces_wal_full_and_foreign_keys() -> Result<()> { let pool = super::init_test_pool()?; diff --git a/src/db/schema.rs b/src/db/schema.rs index f5ce1a2f..99040aa0 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -898,11 +898,22 @@ fn repair_known_legacy_baseline_drift(conn: &rusqlite::Connection) -> Result<()> /// Return whether a recorded version belongs to a recognized repairable baseline. fn is_known_legacy_schema_version(version: Option<&str>) -> bool { - if matches!(version, None | Some("1.3.0" | "1.4.0" | "1.4.1")) - || version == Some(BASELINE_SCHEMA_VERSION) - { + if version.is_none() { return true; } + // Package versions have been the schema stamp since 1.3.0. Earlier + // releases in this supported range must reach the same narrowly checked + // additive repairs as the current release, including the 1.6.0 indexes. + // The complete schema-shape checks still reject unrecognized drift. + if let (Some(Ok(recorded)), Ok(current)) = ( + version.map(semver::Version::parse), + semver::Version::parse(BASELINE_SCHEMA_VERSION), + ) { + return recorded.pre.is_empty() + && recorded.build.is_empty() + && recorded >= semver::Version::new(1, 3, 0) + && recorded <= current; + } match version.and_then(|value| value.parse::().ok()) { Some(1..=41) => true, Some(_) | None => false, @@ -3062,6 +3073,67 @@ mod tests { Ok(()) } + /// The published 1.6.0 baseline predates the two post-query indexes. + #[test] + fn source_160_baseline_receives_missing_indexes_without_losing_posts() -> Result<()> { + let conn = rusqlite::Connection::open_in_memory()?; + create_baseline_schema_objects(&conn)?; + conn.execute_batch( + "CREATE TABLE schema_version (version TEXT NOT NULL PRIMARY KEY); + INSERT INTO schema_version VALUES ('1.6.0'); + DROP INDEX idx_posts_thread_live; + DROP INDEX idx_posts_board_ip_created; + INSERT INTO boards (id, short_name, name) VALUES (1, 'b', 'Retained'); + INSERT INTO threads (id, board_id) VALUES (10, 1); + INSERT INTO posts (id, thread_id, board_id, body, body_html, deletion_token, is_op) + VALUES (100, 10, 1, 'retained', 'retained', 'fixture-token', 1);", + )?; + install_or_migrate_schema(&conn)?; + install_or_migrate_schema(&conn)?; + verify_database_schema(&conn)?; + let retained: String = + conn.query_row("SELECT body FROM posts WHERE id = 100", [], |row| { + row.get(0) + })?; + ensure!( + retained == "retained", + "upgrade must preserve existing posts" + ); + ensure!(schema_version(&conn)? == baseline_schema_version()); + Ok(()) + } + + /// Recognizing an old package stamp must not accept unknown or future schemas. + #[test] + fn old_package_stamp_does_not_authorize_unrecognized_schema_changes() -> Result<()> { + let conn = rusqlite::Connection::open_in_memory()?; + install_or_migrate_schema(&conn)?; + conn.execute_batch( + "UPDATE schema_version SET version = '1.6.0'; + DROP INDEX idx_posts_thread_live; + ALTER TABLE posts ADD COLUMN unknown_external_change TEXT;", + )?; + ensure!(install_or_migrate_schema(&conn).is_err()); + ensure!( + schema_version(&conn)? == "1.6.0", + "failure must not stamp success" + ); + ensure!(!object_exists(&conn, "index", "idx_posts_thread_live")?); + for version in [ + "1.2.2", + "1.6.0-rc.1", + "1.6.0+modified", + "999.0.0", + "invalid", + ] { + ensure!( + !super::is_known_legacy_schema_version(Some(version)), + "unsupported version {version} became repairable" + ); + } + Ok(()) + } + #[test] fn legacy_domain_migration_repairs_counters_and_matches_fresh_schema() -> Result<()> { let migrated = rusqlite::Connection::open_in_memory()?; diff --git a/src/handlers/admin/updates.rs b/src/handlers/admin/updates.rs index b9ac78f9..d755428c 100644 --- a/src/handlers/admin/updates.rs +++ b/src/handlers/admin/updates.rs @@ -59,7 +59,7 @@ pub(super) async fn snapshot() -> Status { if updates::managed() { return updates::request(&Request::Status).await.map_or_else(|_| Status { installed: updates::VERSION.to_owned(), - message: "Updater unavailable. Check the native updater service; installation is disabled.".to_owned(), + message: "Update controller unavailable. Check RustChan's launch logs; installation is disabled.".to_owned(), ..Status::default() }, |reply| reply.status); } @@ -114,7 +114,7 @@ pub(in crate::server) async fn check( tokio::task::spawn_blocking(|| -> anyhow::Result<()> { let status = Status { installed: updates::VERSION.to_owned(), - discovery: Some(updates::discover(updates::VERSION, None)), + discovery: Some(updates::discover_official(updates::VERSION)), checked_at: Some(chrono::Utc::now().to_rfc3339()), message: "Release check completed. Installation is deployment-managed.".to_owned(), ..Status::default() diff --git a/src/main.rs b/src/main.rs index d3b901d0..17e4eb0c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -145,26 +145,43 @@ fn relaunch_in_terminal_if_needed() -> anyhow::Result { // can be tuned via `blocking_threads` in settings.toml or the // CHAN_BLOCKING_THREADS environment variable. +/// Print update compatibility without creating runtime state or opening the database. +/// +/// # Errors +/// +/// Returns an error if writing the probe response fails. +fn print_update_info() -> anyhow::Result<()> { + writeln!( + io::stdout().lock(), + "{}", + serde_json::json!({ + "version": env!("CARGO_PKG_VERSION"), "target": updates::platform_target(), + "schema": db::baseline_schema_version(), "minimum_schema": "1.5.0", "updater_protocol": 2_u32 + }) + )?; + Ok(()) +} + fn main() -> anyhow::Result<()> { + if updates::native_entry()? { + return Ok(()); + } if std::env::args_os() .nth(1) .is_some_and(|arg| arg == "--update-info") { - writeln!( - io::stdout().lock(), - "{}", - serde_json::json!({ - "version": env!("CARGO_PKG_VERSION"), "target": updates::platform_target(), - "schema": db::baseline_schema_version(), "minimum_schema": "1.5.0", "updater_protocol": 1_u32 - }) - )?; + print_update_info()?; return Ok(()); } // Parse before terminal attachment, filesystem creation, logging, settings // generation, or CONFIG access. Clap handles --help and --version here and // exits successfully without mutating runtime state. let cli = server::cli::Cli::parse(); - config::configure_data_dir(cli.data_dir.as_deref())?; + let startup_data = cli + .data_dir + .as_deref() + .or_else(|| updates::admitted_data_dir()); + config::configure_data_dir(startup_data)?; config::configure_port_override(cli.port)?; if updates::managed() { @@ -182,6 +199,13 @@ fn main() -> anyhow::Result<()> { return Ok(()); } + if updates::supervise_native(matches!( + &cli.command, + Some(server::cli::Command::Admin { action: _ }) + ))? { + return Ok(()); + } + // Create the runtime data directories before init_logging so the rolling // file appender can open the directory immediately on startup. let data_dir = config::data_dir(); diff --git a/src/restart.rs b/src/restart.rs index eaf599f5..831e2cdd 100644 --- a/src/restart.rs +++ b/src/restart.rs @@ -294,6 +294,7 @@ pub fn startup_digest() -> anyhow::Result { /// # Errors /// Rejects unavailable control, failed health probes and configuration/generation mismatches. pub async fn started(digest: String) -> anyhow::Result<()> { + crate::updates::writer_initialized().await?; if crate::updates::managed() { // Establish a web-owned lock inode before the privileged controller uses it. if !crate::config::data_dir() diff --git a/src/server/server/lifecycle.rs b/src/server/server/lifecycle.rs index 9ede8a08..422a27d1 100644 --- a/src/server/server/lifecycle.rs +++ b/src/server/server/lifecycle.rs @@ -20,13 +20,9 @@ pub(super) async fn track_requests( { return recovery_unavailable(); } - if crate::updates::managed() - && !matches!( - *req.method(), - axum::http::Method::GET | axum::http::Method::HEAD | axum::http::Method::OPTIONS - ) - && !crate::updates::mutations_allowed(Some(std::path::Path::new(crate::updates::SOCKET))) - .await + if req.uri().path() != "/readyz" + && crate::updates::managed() + && !crate::updates::requests_allowed().await { return recovery_unavailable(); } diff --git a/src/templates/admin/updates.rs b/src/templates/admin/updates.rs index d5d4af4e..08e20a1c 100644 --- a/src/templates/admin/updates.rs +++ b/src/templates/admin/updates.rs @@ -37,7 +37,7 @@ pub(super) fn render(status: &Status, csrf: &str, managed: bool, container: bool crate::templates::append_html( &mut controls, format_args!( - r#"

Install verified update

A verified backup is mandatory. RustChan will briefly restart. Failed upgrades restore the previous software, database, configuration and persistent files.

Approval expires after 10 minutes and can be used once.

"#, + r#"

Install verified update

This installs the official prebuilt RustChan release and replaces the running executable, including any local source modifications. Your data and configuration are retained.

A verified backup is mandatory. RustChan will briefly restart. Failed upgrades restore the previous software, database, configuration and persistent files.

Approval expires after 10 minutes and can be used once.

"#, escape_html(csrf), escape_html(approval) ), @@ -47,7 +47,7 @@ pub(super) fn render(status: &Status, csrf: &str, managed: bool, container: bool let note = if container { "Container installation is deployment-managed. Pull the chosen image and recreate the container with its persistent data volume." } else { - "Installation is deployment-managed on this system. Install the official release using your deployment tools. Native self-updates require the supported Linux updater service." + "Installation is deployment-managed on this system. Source builds are supported. Eligible Linux installations support built-in updates when the existing account owns the executable and data. For a manual upgrade, stop RustChan, back up the complete data directory, and replace the executable while keeping the same data directory. See the upgrade guide for preparation and rollback." }; crate::templates::append_html( &mut controls, @@ -162,4 +162,15 @@ mod tests { "containers should explain the upgrade procedure" ); } + + /// A source installation needs an actionable manual route while trust stays explicit. + #[test] + fn source_install_explains_manual_upgrade_without_enabling_installation() { + let status = Status::default(); + let html = render(&status, "csrf", false, false); + assert!(html.contains("Source builds are supported")); + assert!(html.contains("back up the complete data directory")); + assert!(html.contains("#manual-upgrade-of-a-source-install")); + assert!(!html.contains("id=\"admin-update-install\"")); + } } diff --git a/src/updates/bootstrap.rs b/src/updates/bootstrap.rs new file mode 100644 index 00000000..cde239ad --- /dev/null +++ b/src/updates/bootstrap.rs @@ -0,0 +1,583 @@ +//! Retained same-account controller identity, independent of the active trial link. +//! +//! This preparation never makes installation available by itself. The full +//! controller handoff must open that admission after verifying initialization. + +use super::{ + lifecycle::{self, Layout}, + transaction::native::{Config, Engine, Ownership}, +}; +use anyhow::Context as _; +use serde::{Deserialize, Serialize}; +use std::{ + fs::{self, File}, + io::{Read as _, Write as _}, + os::unix::fs::{MetadataExt as _, PermissionsExt as _}, + path::{Path, PathBuf}, +}; + +/// Bounded controller identity, separate from application configuration secrets. +const MAX_IDENTITY: u64 = 4096; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +/// The first authorized source executable, retained before any trial can replace it. +struct Origin { + /// Stable layout contract. + format: u32, + /// Original startup executable path; never supplied by the browser. + program: PathBuf, + /// Stable package version of this saved source build. + version: String, + /// Exact executable bytes, including local source changes. + sha256: String, + /// Initial copy may be replayed before any application writer is launched. + phase: OriginPhase, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +/// Original retention commits only after executable and pointer directory fsync. +enum OriginPhase { + /// Intent is durable; interrupted preparation may safely retry the exact original bytes. + Retaining, + /// The retained original is durable and may be selected as a full controller. + Ready, +} + +/// Ordinary source ownership gives no authority to replace protected installations. +pub(super) fn replaceable(program: &Path, uid: u32) -> bool { + let inspected = (|| { + lifecycle::owned_ancestors(program, uid)?; + let metadata = fs::symlink_metadata(program)?; + anyhow::ensure!( + metadata.is_file() + && metadata.uid() == uid + && metadata.nlink() == 1 + && metadata.mode() & 0o7022 == 0 + && metadata.mode() & 0o500 == 0o500, + "source executable ownership or access is unsafe" + ); + let parent = program + .parent() + .context("source executable has no parent")?; + let parent_metadata = fs::metadata(parent)?; + anyhow::ensure!( + parent_metadata.uid() == uid && parent_metadata.mode() & 0o300 == 0o300, + "source installation directory cannot be replaced by this account" + ); + Ok::<_, anyhow::Error>(()) + })(); + inspected.is_ok() +} + +/// Retain and validate the original full controller before consulting an active trial. +/// Protected installations return check/manual mode without changing permissions. +pub(super) fn prepare(layout: &Layout) -> anyhow::Result> { + let program = std::env::current_exe()?; + if !replaceable(&program, layout.uid) { + return Ok(None); + } + let origin_path = layout.state.join("origin.json"); + if !origin_path.try_exists()? { + retain_origin(layout, &program, &origin_path)?; + } + let mut origin: Origin = serde_json::from_slice(&lifecycle::read_private( + &origin_path, + layout.uid, + MAX_IDENTITY, + )?)?; + anyhow::ensure!( + origin.format == lifecycle::FORMAT && origin.program == program, + "source executable differs from the retained installation; manual adoption is required" + ); + if origin.phase == OriginPhase::Retaining { + finish_origin(layout, &mut origin, &origin_path)?; + } + // The known controller is verified before Engine::validate examines current, + // which may point at an interrupted non-executable or corrupt trial. + drop(known_controller(layout, &origin)?); + let engine = engine(layout)?; + let status = engine.status()?; + if !status.phase.active() { + let known = version_binary(layout, &status.installed)?; + let actual = digest(&program)?; + let previous_matches = status.previous_version.as_ref().is_some_and(|previous| { + version_binary(layout, previous) + .and_then(|path| digest(&path)) + .is_ok_and(|old| old == actual) + }); + anyhow::ensure!(actual == digest(&known)? || previous_matches, + "source build changed after retention; controlled source adoption must complete before startup (the new source executable is preserved)"); + } + if engine.config.settings_path.try_exists()? { + engine.validate_layout()?; + } + Ok(Some(engine)) +} + +/// An offline CLI cannot migrate data owned by a different committed application version. +pub(super) fn admit_cli(layout: &Layout) -> anyhow::Result<()> { + if !layout.state.join("origin.json").try_exists()? { + // First source adoption remains an explicitly local operation; older + // uncooperative binaries must already be stopped by the operator. + return Ok(()); + } + let path = layout.state.join("status.json"); + let selected = if path.try_exists()? { + let status: super::Status = + serde_json::from_slice(&lifecycle::read_private(&path, layout.uid, 512 * 1024)?)?; + anyhow::ensure!( + !status.phase.active() && status.phase != super::Phase::FailedManualIntervention, + "updater recovery must complete before an offline administration command" + ); + status.installed + } else { + let origin: Origin = serde_json::from_slice(&lifecycle::read_private( + &layout.state.join("origin.json"), + layout.uid, + MAX_IDENTITY, + )?)?; + anyhow::ensure!( + origin.phase == OriginPhase::Ready, + "initial source retention is incomplete" + ); + origin.version + }; + anyhow::ensure!(selected == super::VERSION, + "administration CLI differs from the committed application; use the matching executable after recovery"); + let origin: Origin = serde_json::from_slice(&lifecycle::read_private( + &layout.state.join("origin.json"), + layout.uid, + MAX_IDENTITY, + )?)?; + anyhow::ensure!(known_controller(layout, &origin)? == selected + && digest(&std::env::current_exe()?)? == digest(&version_binary(layout, &selected)?)?, + "administration CLI bytes differ from the retained application; controlled source adoption is required"); + Ok(()) +} + +/// Build the fixed same-account layout with the embedded public verification key. +pub(super) fn engine(layout: &Layout) -> anyhow::Result { + let path = layout.data.join("settings.toml"); + let bytes = if path.try_exists()? { + crate::restart::Store::read(&path, 4 * 1024 * 1024)? + } else { + Vec::new() + }; + let config = crate::config::admin::resolve_file( + std::str::from_utf8(&bytes)?, + &crate::config::Environment::Process, + )?; + Ok(Engine { + config: Config { + install_dir: layout.install.clone(), + state_dir: layout.state.clone(), + data_dir: layout.data.clone(), + settings_path: path, + health_port: crate::config::launcher_port().unwrap_or(config.port), + web_uid: layout.uid, + public_key: None, + retention: 3, + }, + key: super::trust::official_public_key()?, + ownership: Ownership::SameAccount, + }) +} + +/// Copy original executable bytes and publish identity only after all files are durable. +fn retain_origin(layout: &Layout, program: &Path, origin_path: &Path) -> anyhow::Result<()> { + let directory = layout.install.join("versions").join(super::VERSION); + anyhow::ensure!( + fs::symlink_metadata(directory) + .is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound) + && fs::symlink_metadata(layout.install.join("current")) + .is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound), + "source installation exists without its durable origin identity" + ); + let mut origin = Origin { + format: lifecycle::FORMAT, + program: program.to_owned(), + version: super::VERSION.to_owned(), + sha256: digest(program)?, + phase: OriginPhase::Retaining, + }; + publish_origin(layout, &origin, origin_path)?; + finish_origin(layout, &mut origin, origin_path) +} + +/// Replay only the source retention whose fixed initial intent was fsynced before any writer. +fn finish_origin(layout: &Layout, origin: &mut Origin, origin_path: &Path) -> anyhow::Result<()> { + anyhow::ensure!( + origin.phase == OriginPhase::Retaining + && origin.version == super::VERSION + && replaceable(&origin.program, layout.uid) + && origin.sha256 == digest(&origin.program)?, + "source executable changed during unfinished controller retention" + ); + let directory = layout.install.join("versions").join(&origin.version); + match fs::create_dir(&directory) { + Ok(()) => fs::set_permissions(&directory, fs::Permissions::from_mode(0o700))?, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error.into()), + } + lifecycle::owned_ancestors(&directory, layout.uid)?; + let directory_metadata = fs::symlink_metadata(&directory)?; + anyhow::ensure!( + directory_metadata.is_dir() + && directory_metadata.uid() == layout.uid + && directory_metadata.mode().trailing_zeros() >= 6, + "initial controller directory is unsafe" + ); + let executable = directory.join("rustchan-cli"); + match fs::symlink_metadata(&executable) { + Ok(metadata) => { + anyhow::ensure!( + metadata.is_file() && metadata.uid() == layout.uid && metadata.nlink() == 1, + "initial controller file is unsafe" + ); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + let mut output = tempfile::NamedTempFile::new_in(&directory)?; + let mut input = File::open(&origin.program)?; + std::io::copy(&mut input, &mut output).map(|_bytes_copied| ())?; + output + .as_file() + .set_permissions(fs::Permissions::from_mode(0o700))?; + output.as_file().sync_all()?; + anyhow::ensure!( + digest(output.path())? == origin.sha256 && digest(&origin.program)? == origin.sha256, + "source executable changed during retention" + ); + drop(output.persist(&executable).map_err(|error| error.error)?); + File::open(&directory)?.sync_all()?; + File::open(layout.install.join("versions"))?.sync_all()?; + let pointer = layout.install.join("current"); + let target = Path::new("versions").join(&origin.version); + match fs::symlink_metadata(&pointer) { + Ok(metadata) => { + anyhow::ensure!( + metadata.file_type().is_symlink() + && metadata.uid() == layout.uid + && fs::read_link(&pointer)? == target, + "initial controller pointer is unsafe" + ); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + std::os::unix::fs::symlink(&target, &pointer)?; + } + Err(error) => return Err(error.into()), + } + File::open(&layout.install)?.sync_all()?; + origin.phase = OriginPhase::Ready; + publish_origin(layout, origin, origin_path) +} + +/// Persist initial identity through the same bounded private staging root as writer intent. +fn publish_origin(layout: &Layout, origin: &Origin, origin_path: &Path) -> anyhow::Result<()> { + let mut identity = tempfile::NamedTempFile::new_in(layout.state.join("journal-staging"))?; + identity.write_all(&serde_json::to_vec(origin)?)?; + identity.as_file().sync_all()?; + drop(identity.persist(origin_path).map_err(|error| error.error)?); + File::open(layout.state.join("journal-staging"))?.sync_all()?; + File::open(&layout.state)?.sync_all()?; + Ok(()) +} + +/// The canonical terminal journal selects software; current alone cannot select a trial. +fn known_controller(layout: &Layout, origin: &Origin) -> anyhow::Result { + anyhow::ensure!( + origin.phase == OriginPhase::Ready, + "source retention has not committed" + ); + let status_path = layout.state.join("status.json"); + let selected = if status_path.try_exists()? { + let status: super::Status = serde_json::from_slice(&lifecycle::read_private( + &status_path, + layout.uid, + 512 * 1024, + )?)?; + status.installed + } else { + origin.version.clone() + }; + super::release::stable_version(&selected).map(|_stable_version| ())?; + if selected != origin.version { + drop(version_binary(layout, &selected)?); + return Ok(selected); + } + let directory = layout.install.join("versions").join(&selected); + lifecycle::owned_ancestors(&directory, layout.uid)?; + let executable = directory.join("rustchan-cli"); + let metadata = fs::symlink_metadata(&executable)?; + anyhow::ensure!( + metadata.is_file() + && metadata.uid() == layout.uid + && metadata.nlink() == 1 + && metadata.mode() & 0o7022 == 0 + && metadata.mode() & 0o500 == 0o500 + && digest(&executable)? == origin.sha256, + "retained controller failed ownership, execution or byte verification" + ); + Ok(selected) +} + +/// Validate one immutable namespace against the original source or approved release identity. +pub(super) fn version_binary(layout: &Layout, version: &str) -> anyhow::Result { + let parsed = super::release::stable_version(version)?; + anyhow::ensure!( + parsed.to_string() == version, + "noncanonical controller version" + ); + let origin: Origin = serde_json::from_slice(&lifecycle::read_private( + &layout.state.join("origin.json"), + layout.uid, + MAX_IDENTITY, + )?)?; + let expected = if origin.version == version { + anyhow::ensure!( + origin.phase == OriginPhase::Ready, + "source retention is incomplete" + ); + origin.sha256 + } else { + let manifest: super::release::Manifest = serde_json::from_slice(&lifecycle::read_private( + &layout + .state + .join("programs") + .join(format!("{version}.json")), + layout.uid, + 16 * 1024, + )?)?; + anyhow::ensure!( + manifest.version == version + && Some(manifest.target.as_str()) == super::platform_target(), + "retained release identity mismatch" + ); + manifest.executable_sha256 + }; + let path = layout + .install + .join("versions") + .join(version) + .join("rustchan-cli"); + lifecycle::owned_ancestors(&path, layout.uid)?; + let metadata = fs::symlink_metadata(&path)?; + anyhow::ensure!( + metadata.is_file() + && metadata.uid() == layout.uid + && metadata.nlink() == 1 + && metadata.mode() & 0o7022 == 0 + && metadata.mode() & 0o500 == 0o500 + && digest(&path)? == expected, + "retained controller failed byte or permission verification" + ); + Ok(path) +} + +/// Enroll only the staged executable named by the signature-verified approved discovery. +pub(super) fn enroll_candidate(layout: &Layout, version: &str) -> anyhow::Result<()> { + if version_binary(layout, version).is_ok() { + return Ok(()); + } + let status = engine(layout)?.status()?; + let Some(super::Discovery::Available(release)) = status.discovery else { + anyhow::bail!("candidate has no approved signed identity"); + }; + let manifest = release.manifest.context("candidate is unverified")?; + anyhow::ensure!( + manifest.version == version + && status.target_version.as_deref() == Some(version) + && super::release::stable_version(&manifest.minimum_updater)? + >= super::release::stable_version("1.6.6")?, + "candidate requires single-binary protocol adoption" + ); + let path = layout + .install + .join("versions") + .join(version) + .join("rustchan-cli"); + lifecycle::owned_ancestors(&path, layout.uid)?; + anyhow::ensure!( + digest(&path)? == manifest.executable_sha256 + && fs::metadata(&path)?.len() == manifest.executable_size, + "staged candidate differs from signed executable identity" + ); + let directory = layout.state.join("programs"); + crate::config::ensure_private_dir(&directory)?; + super::transaction::native::atomic_write( + &directory.join(format!("{version}.json")), + &serde_json::to_vec(&manifest)?, + )?; + drop(version_binary(layout, version)?); + Ok(()) +} + +/// The startup-only source path remains fixed even after its old mapped inode is replaced. +pub(super) fn original_program(layout: &Layout) -> anyhow::Result { + let origin: Origin = serde_json::from_slice(&lifecycle::read_private( + &layout.state.join("origin.json"), + layout.uid, + MAX_IDENTITY, + )?)?; + anyhow::ensure!( + replaceable(&origin.program, layout.uid), + "source installation is no longer replaceable" + ); + Ok(origin.program) +} + +/// Publication follows canonical code/data commit; unexpected local rebuilds are preserved. +pub(super) fn publish_original( + layout: &Layout, + version: &str, + expected: &str, +) -> anyhow::Result { + let program = original_program(layout)?; + anyhow::ensure!( + digest(&program)? == expected, + "source executable changed during the update; new local bytes are preserved" + ); + let source = version_binary(layout, version)?; + let target_digest = digest(&source)?; + if target_digest == expected { + return Ok(target_digest); + } + let mut temporary = + tempfile::NamedTempFile::new_in(program.parent().context("source parent missing")?)?; + std::io::copy(&mut File::open(source)?, &mut temporary).map(|_bytes_copied| ())?; + temporary + .as_file() + .set_permissions(fs::metadata(&program)?.permissions())?; + temporary.as_file().sync_all()?; + anyhow::ensure!( + digest(temporary.path())? == target_digest && digest(&program)? == expected, + "source executable changed during publication" + ); + drop(temporary.persist(&program).map_err(|error| error.error)?); + File::open(program.parent().context("source parent missing")?)?.sync_all()?; + Ok(target_digest) +} + +/// Stream executable identity without buffering or interpreting executable contents. +pub(super) fn digest(path: &Path) -> anyhow::Result { + use sha2::{Digest as _, Sha256}; + let mut file = File::open(path)?; + let mut digest = Sha256::new(); + let mut bytes = [0_u8; 16 * 1024]; + loop { + let count = file.read(&mut bytes)?; + if count == 0 { + break; + } + digest.update( + bytes + .get(..count) + .context("invalid executable byte count")?, + ); + } + Ok(hex::encode(digest.finalize())) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A pending broken trial never becomes the selected retained controller. + #[test] + fn retained_origin_precedes_non_executable_trial() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let layout = Layout::prepare(temporary.path())?; + let program = temporary.path().join("source"); + fs::write(&program, b"saved source modifications")?; + fs::set_permissions(&program, fs::Permissions::from_mode(0o700))?; + let identity = layout.state.join("origin.json"); + retain_origin(&layout, &program, &identity)?; + let origin: Origin = serde_json::from_slice(&lifecycle::read_private( + &identity, + layout.uid, + MAX_IDENTITY, + )?)?; + let candidate = layout.install.join("versions/1.6.7"); + fs::create_dir(&candidate)?; + fs::write(candidate.join("rustchan-cli"), b"non-executable trial")?; + fs::remove_file(layout.install.join("current"))?; + std::os::unix::fs::symlink("versions/1.6.7", layout.install.join("current"))?; + let status = super::super::Status { + installed: super::super::VERSION.to_owned(), + target_version: Some("1.6.7".into()), + phase: super::super::Phase::HealthChecking, + ..super::super::Status::default() + }; + crate::config::admin::atomic_replace( + &layout.state.join("status.json"), + &serde_json::to_string(&status)?, + )?; + anyhow::ensure!(known_controller(&layout, &origin)? == super::super::VERSION); + let retained = layout + .install + .join("versions") + .join(super::super::VERSION) + .join("rustchan-cli"); + fs::set_permissions(&retained, fs::Permissions::from_mode(0o600))?; + anyhow::ensure!(known_controller(&layout, &origin).is_err()); + fs::set_permissions(&retained, fs::Permissions::from_mode(0o700))?; + fs::write(&retained, b"corrupt controller")?; + anyhow::ensure!(known_controller(&layout, &origin).is_err()); + anyhow::ensure!(fs::read(program)? == b"saved source modifications"); + Ok(()) + } + + /// Even an executable owned by this UID needs a replaceable parent and safe ancestors. + #[test] + fn source_replaceability_rejects_links_and_shared_write() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let uid = rustix::process::getuid().as_raw(); + let program = temporary.path().join("source"); + fs::write(&program, b"ordinary executable")?; + fs::set_permissions(&program, fs::Permissions::from_mode(0o700))?; + anyhow::ensure!(replaceable(&program, uid)); + let link = temporary.path().join("linked"); + std::os::unix::fs::symlink(&program, &link)?; + anyhow::ensure!(!replaceable(&link, uid)); + fs::set_permissions(&program, fs::Permissions::from_mode(0o720))?; + anyhow::ensure!(!replaceable(&program, uid)); + Ok(()) + } + + /// A cold retry can complete initial retention after the copy/pointer became durable. + #[test] + fn initial_retention_replays_exact_source_without_touching_data() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let layout = Layout::prepare(temporary.path())?; + let program = temporary.path().join("source"); + fs::write(&program, b"exact original source")?; + fs::set_permissions(&program, fs::Permissions::from_mode(0o700))?; + fs::write(temporary.path().join("settings.toml"), b"port = 8080\n")?; + fs::write( + temporary.path().join("chan.db"), + b"existing database sentinel", + )?; + let identity = layout.state.join("origin.json"); + retain_origin(&layout, &program, &identity)?; + let mut origin: Origin = serde_json::from_slice(&lifecycle::read_private( + &identity, + layout.uid, + MAX_IDENTITY, + )?)?; + origin.phase = OriginPhase::Retaining; + publish_origin(&layout, &origin, &identity)?; + anyhow::ensure!(known_controller(&layout, &origin).is_err()); + finish_origin(&layout, &mut origin, &identity)?; + anyhow::ensure!(known_controller(&layout, &origin)? == super::super::VERSION); + engine(&layout)?.validate()?; + anyhow::ensure!( + fs::read(temporary.path().join("chan.db"))? == b"existing database sentinel" + && fs::read(temporary.path().join("settings.toml"))? == b"port = 8080\n" + && fs::read(program)? == b"exact original source" + ); + Ok(()) + } +} diff --git a/src/updates/control.rs b/src/updates/control.rs new file mode 100644 index 00000000..86e1c4e7 --- /dev/null +++ b/src/updates/control.rs @@ -0,0 +1,366 @@ +//! Private Linux control transport. SCM_RIGHTS duplicates an OFD, not its lock. +//! +//! Every received descriptor is CLOEXEC before it becomes visible to application +//! code. Malformed/truncated frames drop all received OwnedFd values. The sender +//! retains its descriptor and never unlocks it as part of transfer or cleanup. + +use super::lifecycle::ProcessIdentity; +use anyhow::Context as _; +use serde::{de::DeserializeOwned, Serialize}; +use std::fs::File; +use std::io::{IoSlice, IoSliceMut, Read as _, Write as _}; +use std::mem::MaybeUninit; +use std::os::linux::net::SocketAddrExt as _; +use std::os::unix::net::{SocketAddr, UnixListener, UnixStream}; +use std::time::Duration; + +/// Frames cannot become unbounded paths, environments or arbitrary application commands. +const MAX_FRAME: usize = 16 * 1024; +/// Bound private IPC stalls independently from application HTTP traffic. +const IO_TIMEOUT: Duration = Duration::from_secs(5); + +/// Bind a short abstract endpoint; filesystem path length and systemd are irrelevant. +pub(super) fn listen(name: &str) -> anyhow::Result { + anyhow::ensure!( + !name.is_empty() && name.len() <= 96 && name.is_ascii(), + "invalid internal control endpoint" + ); + Ok(UnixListener::bind_addr(&SocketAddr::from_abstract_name( + name.as_bytes(), + )?)?) +} + +/// Connect only to an endpoint belonging to the expected same-account process. +pub(super) fn connect( + name: &str, + uid: u32, + expected: ProcessIdentity, +) -> anyhow::Result { + let stream = UnixStream::connect_addr(&SocketAddr::from_abstract_name(name.as_bytes())?)?; + authorize(&stream, uid, expected)?; + stream.set_read_timeout(Some(IO_TIMEOUT))?; + stream.set_write_timeout(Some(IO_TIMEOUT))?; + Ok(stream) +} + +/// Verify both kernel peer credentials and the expected process-start identity. +pub(super) fn authorize( + stream: &UnixStream, + uid: u32, + expected: ProcessIdentity, +) -> anyhow::Result<()> { + let credentials = rustix::net::sockopt::socket_peercred(stream)?; + anyhow::ensure!( + credentials.uid.as_raw() == uid && credentials.pid.as_raw_nonzero().get() == expected.pid, + "unauthorized internal control peer" + ); + anyhow::ensure!( + ProcessIdentity::read(expected.pid)? == expected, + "stale internal control process" + ); + Ok(()) +} + +/// Send one length-prefixed strict operation and at most one borrowed lease descriptor. +pub(super) fn send( + stream: &mut UnixStream, + value: &T, + lease: Option<&File>, +) -> anyhow::Result<()> { + let body = serde_json::to_vec(value)?; + anyhow::ensure!( + !body.is_empty() && body.len() <= MAX_FRAME, + "internal control frame exceeds bound" + ); + let mut frame = u32::try_from(body.len())?.to_be_bytes().to_vec(); + frame.extend_from_slice(&body); + let descriptors: Vec<_> = lease.into_iter().map(std::os::fd::AsFd::as_fd).collect(); + let mut space = [MaybeUninit::uninit(); rustix::cmsg_space!(ScmRights(1))]; + let mut ancillary = rustix::net::SendAncillaryBuffer::new(&mut space); + if !descriptors.is_empty() { + anyhow::ensure!( + ancillary.push(rustix::net::SendAncillaryMessage::ScmRights(&descriptors)), + "lease transfer buffer is insufficient" + ); + } + let sent = rustix::net::sendmsg( + &*stream, + &[IoSlice::new(&frame)], + &mut ancillary, + rustix::net::SendFlags::NOSIGNAL, + )?; + anyhow::ensure!( + sent > 0 && sent <= frame.len(), + "internal control send failed" + ); + stream.write_all(frame.get(sent..).context("invalid control send length")?)?; + Ok(()) +} + +/// Receive a strict frame and close unexpected/extra descriptors on every failure path. +pub(super) fn receive( + stream: &mut UnixStream, + expect_lease: bool, +) -> anyhow::Result<(T, Option)> { + receive_with_timeout(stream, expect_lease, IO_TIMEOUT) +} + +/// Lifecycle commands keep a bounded acknowledgment deadline through full writer drain. +pub(super) fn receive_with_timeout( + stream: &mut UnixStream, + expect_lease: bool, + timeout: Duration, +) -> anyhow::Result<(T, Option)> { + stream.set_read_timeout(Some(timeout))?; + let mut header = [0_u8; 4]; + // Capacity for two lets us explicitly reject an extra descriptor. Overflow + // receives CTRUNC, and both the kernel/library close truncation leftovers. + let mut space = [MaybeUninit::uninit(); rustix::cmsg_space!(ScmRights(2))]; + let mut ancillary = rustix::net::RecvAncillaryBuffer::new(&mut space); + let received = rustix::net::recvmsg( + &*stream, + &mut [IoSliceMut::new(&mut header)], + &mut ancillary, + rustix::net::RecvFlags::CMSG_CLOEXEC, + )?; + let mut descriptors = Vec::new(); + for message in ancillary.drain() { + match message { + rustix::net::RecvAncillaryMessage::ScmRights(rights) => descriptors.extend(rights), + rustix::net::RecvAncillaryMessage::ScmCredentials(_) => { + anyhow::bail!("unexpected ancillary credentials") + } + _ => anyhow::bail!("unknown control ancillary data"), + } + } + anyhow::ensure!( + !received.flags.contains(rustix::net::ReturnFlags::CTRUNC) + && !received.flags.contains(rustix::net::ReturnFlags::TRUNC) + && received.bytes > 0 + && received.bytes <= header.len(), + "truncated internal control frame" + ); + anyhow::ensure!( + descriptors.len() == usize::from(expect_lease), + "unexpected number of control descriptors" + ); + for descriptor in &descriptors { + anyhow::ensure!( + rustix::io::fcntl_getfd(descriptor)?.contains(rustix::io::FdFlags::CLOEXEC), + "received descriptor lacks close-on-exec" + ); + } + stream.read_exact( + header + .get_mut(received.bytes..) + .context("invalid control header length")?, + )?; + let length = usize::try_from(u32::from_be_bytes(header))?; + anyhow::ensure!( + (1..=MAX_FRAME).contains(&length), + "internal control frame exceeds bound" + ); + let mut body = vec![0_u8; length]; + stream.read_exact(&mut body)?; + let value = serde_json::from_slice(&body)?; + let lease = descriptors.pop().map(File::from); + Ok((value, lease)) +} + +#[cfg(test)] +/// Real Linux descriptor lifetime, peer identity and malformed-frame regressions. +mod tests { + use super::*; + use std::os::fd::AsFd as _; + use std::os::fd::AsRawFd as _; + + #[derive(Debug, Serialize, serde::Deserialize)] + #[serde(deny_unknown_fields)] + /// Minimal fixed operation for transport tests. + struct Operation { + /// Opaque parent-issued operation identifier. + id: uuid::Uuid, + } + + /// A transferred clone retains the same flock, without consuming or unlocking the sender. + #[test] + fn rights_transfer_is_close_on_exec_and_close_only() -> anyhow::Result<()> { + let root = tempfile::tempdir()?; + let path = root.path().join("lease"); + let sender = File::options() + .read(true) + .write(true) + .create_new(true) + .open(&path)?; + sender.try_lock()?; + let (mut left, mut right) = UnixStream::pair()?; + let expected = ProcessIdentity::current()?; + let uid = rustix::process::getuid().as_raw(); + authorize(&right, uid, expected)?; + let operation = Operation { + id: uuid::Uuid::new_v4(), + }; + send(&mut left, &operation, Some(&sender))?; + let (observed, received): (Operation, _) = receive(&mut right, true)?; + anyhow::ensure!(observed.id == operation.id); + let received = received.context("missing transferred lease")?; + anyhow::ensure!( + rustix::io::fcntl_getfd(received.as_fd())?.contains(rustix::io::FdFlags::CLOEXEC) + ); + let stdin_before = fs_descriptor_identity(0)?; + drop(sender); + anyhow::ensure!(File::options() + .read(true) + .write(true) + .open(&path)? + .try_lock() + .is_err()); + drop(received); + File::options() + .read(true) + .write(true) + .open(&path)? + .try_lock()?; + anyhow::ensure!( + fs_descriptor_identity(0)? == stdin_before, + "lease transfer must preserve ordinary stdin" + ); + Ok(()) + } + + /// Compare the existing stdin target without modifying its descriptor. + fn fs_descriptor_identity(fd: i32) -> anyhow::Result { + Ok(std::fs::read_link(format!("/proc/self/fd/{fd}"))?) + } + + /// Invalid JSON must close a received descriptor rather than leave the shared OFD locked. + #[test] + fn parse_failure_drops_received_rights() -> anyhow::Result<()> { + let root = tempfile::tempdir()?; + let path = root.path().join("lease"); + let original = File::options() + .read(true) + .write(true) + .create_new(true) + .open(&path)?; + original.try_lock()?; + let (left, mut right) = UnixStream::pair()?; + let borrowed = [original.as_fd()]; + let mut space = [MaybeUninit::uninit(); rustix::cmsg_space!(ScmRights(1))]; + let mut ancillary = rustix::net::SendAncillaryBuffer::new(&mut space); + anyhow::ensure!(ancillary.push(rustix::net::SendAncillaryMessage::ScmRights(&borrowed))); + let mut frame = 1_u32.to_be_bytes().to_vec(); + frame.push(b'{'); + let _sent = rustix::net::sendmsg( + &left, + &[IoSlice::new(&frame)], + &mut ancillary, + rustix::net::SendFlags::NOSIGNAL, + )?; + drop(original); + anyhow::ensure!(receive::(&mut right, true).is_err()); + File::options() + .read(true) + .write(true) + .open(path)? + .try_lock()?; + Ok(()) + } + + /// Truncation, descriptor overflow, EOF and unknown fields all close transferred rights. + #[test] + fn every_frame_rejection_releases_all_received_descriptors() -> anyhow::Result<()> { + let body = serde_json::to_vec(&Operation { + id: uuid::Uuid::new_v4(), + })?; + let mut valid = u32::try_from(body.len())?.to_be_bytes().to_vec(); + valid.extend_from_slice(&body); + let mut short_body = 3_u32.to_be_bytes().to_vec(); + short_body.push(b'{'); + let unknown = serde_json::to_vec( + &serde_json::json!({"id": uuid::Uuid::new_v4(), "path": "/tmp/forbidden"}), + )?; + let mut unknown_frame = u32::try_from(unknown.len())?.to_be_bytes().to_vec(); + unknown_frame.extend_from_slice(&unknown); + let oversized = u32::try_from(MAX_FRAME)? + .checked_add(1) + .context("frame bound overflow")? + .to_be_bytes() + .to_vec(); + for (frame, count, expect) in [ + (0_u32.to_be_bytes().to_vec(), 1, true), + (oversized, 1, true), + (vec![0_u8], 1, true), + (short_body, 1, true), + (unknown_frame, 1, true), + (valid.clone(), 1, false), + (valid.clone(), 2, true), + (valid.clone(), 8, true), + (valid, 0, true), + ] { + let root = tempfile::tempdir()?; + let path = root.path().join("lease"); + let original = File::options() + .read(true) + .write(true) + .create_new(true) + .open(&path)?; + original.try_lock()?; + let (left, mut right) = UnixStream::pair()?; + let borrowed: Vec<_> = std::iter::repeat_n(original.as_fd(), count).collect(); + let mut space = [MaybeUninit::uninit(); rustix::cmsg_space!(ScmRights(8))]; + let mut ancillary = rustix::net::SendAncillaryBuffer::new(&mut space); + if !borrowed.is_empty() { + anyhow::ensure!( + ancillary.push(rustix::net::SendAncillaryMessage::ScmRights(&borrowed)) + ); + } + let sent = rustix::net::sendmsg( + &left, + &[IoSlice::new(&frame)], + &mut ancillary, + rustix::net::SendFlags::NOSIGNAL, + )?; + anyhow::ensure!(sent == frame.len(), "fixture send was incomplete"); + drop(borrowed); + drop(left); + drop(original); + anyhow::ensure!( + receive::(&mut right, expect).is_err(), + "malformed fixture was admitted" + ); + File::options() + .read(true) + .write(true) + .open(path)? + .try_lock()?; + } + Ok(()) + } + + /// Kernel credentials reject wrong PIDs/UIDs, and reused start identities fail closed. + #[test] + fn abstract_endpoint_and_peer_identity_are_bounded() -> anyhow::Result<()> { + let name = format!("rustchan-test-{}", uuid::Uuid::new_v4()); + let listener = listen(&name)?; + let current = ProcessIdentity::current()?; + let uid = rustix::process::getuid().as_raw(); + let client = connect(&name, uid, current)?; + let (server, _address) = listener.accept()?; + authorize(&server, uid, current)?; + let wrong = ProcessIdentity { + start_tick: current.start_tick.checked_add(1).context("tick overflow")?, + ..current + }; + anyhow::ensure!(authorize(&client, uid, wrong).is_err()); + anyhow::ensure!(authorize( + &client, + uid.checked_add(1).context("UID overflow")?, + current + ) + .is_err()); + anyhow::ensure!(listen(&"x".repeat(97)).is_err()); + anyhow::ensure!(client.as_raw_fd() >= 0_i32); + Ok(()) + } +} diff --git a/src/updates/controller.rs b/src/updates/controller.rs new file mode 100644 index 00000000..68442a54 --- /dev/null +++ b/src/updates/controller.rs @@ -0,0 +1,474 @@ +//! Full same-executable update controller. The root monitor only supplies kernel lifecycle control. + +use super::{ + bootstrap, control, coordinator, + lifecycle::{Layout, ProcessIdentity}, + native::Context, + transaction::native::{Engine, Service}, + Phase, Reply, Request, +}; +use anyhow::Context as _; +use std::{ + io::{Read as _, Write as _}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + time::{Duration, Instant}, +}; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + +/// A root-selected controller initializes its full policy before reporting readiness. +pub(super) fn run(context: &Context) -> anyhow::Result<()> { + let _logging = tracing_subscriber::fmt() + .with_env_filter("info") + .with_writer(std::io::stderr) + .try_init(); + crate::config::configure_data_dir(Some(&context.data))?; + crate::config::configure_port_override(context.port)?; + drop(rustls::crypto::ring::default_provider().install_default()); + let layout = Layout::prepare(&context.data)?; + let engine = Arc::new(bootstrap::engine(&layout)?); + if engine.config.settings_path.try_exists()? { + engine.validate_layout()?; + } else { + anyhow::ensure!( + !context.data.join("chan.db").try_exists()?, + "existing data is missing settings" + ); + } + let listener = control::listen(&format!("rustchan-controller-{}", context.id))?; + listener.set_nonblocking(true)?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + let entered = runtime.enter(); + let listener = tokio::net::UnixListener::from_std(listener)?; + let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?; + drop(entered); + drop(coordinator::call( + context, + coordinator::Request::ControllerReady { + version: super::VERSION.to_owned(), + epoch: coordinator::EPOCH, + }, + )?); + let recovered = Arc::new(AtomicBool::new(false)); + let busy = Arc::new(AtomicBool::new(false)); + let slots = Arc::new(tokio::sync::Semaphore::new(16)); + runtime.block_on(async { + let mut activated = false; + let mut closing = false; + loop { + let observed = coordinator::call(context, coordinator::Request::View)?; + let view = observed.view.context("monitor omitted controller view")?; + let active = view.controller.as_ref().is_some_and(|current| current.id == context.id); + if active && !activated { + activated = true; + let status = engine.status()?; + let warm = view.application.as_ref().is_some_and(|application| application.initialized) + && !status.phase.active() && status.phase != Phase::FailedManualIntervention; + if warm { + // Complete initialization happened while standby. The canonical terminal + // journal already committed this exact controller and ready application. + recovered.store(true, Ordering::Release); + drop(coordinator::call(context, coordinator::Request::Activated)?); + } else { + start_recovery(Arc::clone(&engine), context.clone(), Arc::clone(&recovered), &busy)?; + } + } + if (!active && activated || closing) && !busy.load(Ordering::Acquire) { + return Ok::<_, anyhow::Error>(()); + } + tokio::select! { + accepted = listener.accept() => { + let (socket, _address) = accepted?; + let Ok(permit) = Arc::clone(&slots).try_acquire_owned() else { continue; }; + let worker_engine = Arc::clone(&engine); + let worker_context = context.clone(); + let worker_recovered = Arc::clone(&recovered); + let worker_busy = Arc::clone(&busy); + drop(tokio::spawn(async move { + let _permit = permit; + let result = exchange(socket, worker_engine, worker_context, worker_recovered, worker_busy).await; + if let Err(error) = result { + tracing::warn!(%error, "source controller request rejected"); + } + })); + } + _signal = terminate.recv() => { closing = true; recovered.store(false, Ordering::Release); } + () = tokio::time::sleep(Duration::from_millis(100)) => {} + } + } + }) +} + +/// A worker lifetime stays visible until its complete journal operation finishes. +struct Activity(Arc); +impl Drop for Activity { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +/// Run recovery before any controlled start; full source backups retain the existing engine. +fn start_recovery( + engine: Arc, + context: Context, + recovered: Arc, + busy: &Arc, +) -> anyhow::Result<()> { + busy.store(true, Ordering::Release); + let activity = Activity(Arc::clone(busy)); + let _worker = std::thread::Builder::new() + .name("rustchan-source-recovery".into()) + .spawn(move || { + let _activity = activity; + let service = NativeService { context }; + let result = (|| { + engine.recover(&service)?; + let status = engine.status()?; + anyhow::ensure!( + status.phase != Phase::FailedManualIntervention, + "manual recovery remains required" + ); + let view = service.view()?; + if view.application.is_none() { + service.start()?; + } + service.health(&status.installed, crate::db::baseline_schema_version())?; + service.prepare_commit(&status.installed)?; + service.commit()?; + Ok::<_, anyhow::Error>(()) + })(); + if let Err(error) = result { + tracing::error!(%error, "source startup recovery failed; admission remains closed"); + } else { + recovered.store(true, Ordering::Release); + } + })?; + Ok(()) +} + +/// Authorize the exact running application and bound full admin IPC independently of core frames. +async fn exchange( + mut socket: tokio::net::UnixStream, + engine: Arc, + context: Context, + recovered: Arc, + busy: Arc, +) -> anyhow::Result<()> { + let peer = socket.peer_cred()?; + anyhow::ensure!( + peer.uid() == engine.config.web_uid, + "controller peer has a different account" + ); + let pid = peer.pid().context("controller peer omitted PID")?; + let peer_identity = ProcessIdentity::read(pid)?; + let view = coordinator::call(&context, coordinator::Request::View)? + .view + .context("monitor omitted view")?; + anyhow::ensure!( + view.application + .as_ref() + .is_some_and(|application| application.identity == peer_identity), + "controller caller is not the admitted application" + ); + let mut bytes = Vec::new(); + let _received = tokio::time::timeout( + Duration::from_secs(5), + (&mut socket).take(4097).read_to_end(&mut bytes), + ) + .await??; + anyhow::ensure!(bytes.len() <= 4096, "controller request exceeds limit"); + let request: Request = serde_json::from_slice(&bytes)?; + let reply = + tokio::task::spawn_blocking(move || handle(&engine, &context, &recovered, &busy, request)) + .await??; + let reply_bytes = serde_json::to_vec(&reply)?; + anyhow::ensure!( + reply_bytes.len() <= 512 * 1024, + "controller reply exceeds limit" + ); + socket.write_all(&reply_bytes).await?; + socket.shutdown().await?; + Ok(()) +} + +/// Full policy operations are available only in the selected initialized controller. +#[expect( + clippy::too_many_lines, + reason = "closed authenticated operations share one controller authorization and bounded reply boundary" +)] +fn handle( + engine: &Arc, + context: &Context, + recovered: &Arc, + busy: &Arc, + request: Request, +) -> anyhow::Result { + let service = NativeService { + context: context.clone(), + }; + let result = (|| { + let view = service.view()?; + anyhow::ensure!( + view.controller + .as_ref() + .is_some_and(|controller| controller.id == context.id), + "controller is no longer active" + ); + match request { + Request::Status => Ok(( + engine.status()?, + view.public && recovered.load(Ordering::Acquire), + )), + Request::Started { + instance, + configuration, + } => { + anyhow::ensure!( + view.application + .as_ref() + .is_some_and(|application| application.initialized), + "application did not initialize" + ); + Ok((engine.started(instance, &configuration)?, false)) + } + Request::Check => { + anyhow::ensure!( + recovered.load(Ordering::Acquire) && !busy.load(Ordering::Acquire), + "source recovery or installation is active" + ); + Ok((engine.check()?, false)) + } + Request::Install { + approval, + administrator, + } => { + anyhow::ensure!( + recovered.load(Ordering::Acquire) + && busy + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok(), + "source recovery or another operation is active" + ); + let activity = Activity(Arc::clone(busy)); + let update = engine.lock()?; + let (status, manifest) = engine.approve(&approval, administrator)?; + let mut worker_status = status.clone(); + let worker_engine = Arc::clone(engine); + let worker_recovered = Arc::clone(recovered); + let worker_service = service; + let _worker = std::thread::Builder::new() + .name("rustchan-source-update".into()) + .spawn(move || { + let _activity = activity; + let _update = update; + std::thread::sleep(Duration::from_secs(1)); + let result = + worker_engine.install(&mut worker_status, &manifest, &worker_service); + if let Err(error) = result { + worker_recovered.store(false, Ordering::Release); + tracing::error!(%error, "source update requires recovery"); + } + })?; + Ok((status, false)) + } + Request::Restart { + instance, + administrator, + } => { + anyhow::ensure!( + recovered.load(Ordering::Acquire) + && busy + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok(), + "source recovery or another operation is active" + ); + let activity = Activity(Arc::clone(busy)); + let (status, update, settings) = + engine.approve_restart(instance, administrator, &service)?; + let mut worker_status = status.clone(); + let worker_engine = Arc::clone(engine); + let worker_recovered = Arc::clone(recovered); + let _worker = std::thread::Builder::new() + .name("rustchan-source-settings".into()) + .spawn(move || { + let _activity = activity; + let _update = update; + let _settings = settings; + std::thread::sleep(Duration::from_secs(1)); + let result = worker_engine.restart_settings(&mut worker_status, &service); + if let Err(error) = result { + worker_recovered.store(false, Ordering::Release); + tracing::error!(%error, "source settings restart requires recovery"); + } + })?; + Ok((status, false)) + } + Request::Ready { version: _ } => { + anyhow::bail!("startup admission belongs to the fixed root monitor") + } + } + })(); + match result { + Ok((status, ready)) => Ok(Reply { + status, + ready, + error: None, + }), + Err(error) => { + tracing::warn!(%error, "source controller operation failed"); + Ok(Reply { status: engine.status()?, ready: false, + error: Some("Operation could not proceed. Recovery or another operation may be active; check controller logs.".into()) }) + } + } +} + +/// Full controller delegates all spawn/reap/admission decisions to the dedicated root thread. +struct NativeService { + /// Startup-authenticated creating monitor and durable controller lifetime. + context: Context, +} +impl NativeService { + /// Read the small lifecycle view from the exact creating monitor. + fn view(&self) -> anyhow::Result { + coordinator::call(&self.context, coordinator::Request::View)? + .view + .context("monitor omitted view") + } + + /// Bind bounded HTTP readiness to the actual admitted instance/configuration observation. + fn probe(&self, version: &str, previous: Option) -> anyhow::Result<()> { + let layout = Layout::prepare(&self.context.data)?; + let client = reqwest::blocking::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(2)) + .build()?; + let started = Instant::now(); + while started.elapsed() < Duration::from_secs(60) { + let checked = (|| { + let view = self.view()?; + let application = view.application.context("application is not running")?; + anyhow::ensure!( + application.version == version && application.initialized, + "application is not initialized" + ); + let engine = bootstrap::engine(&layout)?; + let response = client + .get(format!( + "http://127.0.0.1:{}/readyz", + engine.config.health_port + )) + .send()? + .error_for_status()?; + let instance = response + .headers() + .get("x-rustchan-instance") + .and_then(|value| value.to_str().ok()) + .and_then(|value| uuid::Uuid::parse_str(value).ok()) + .context("readiness instance is missing")?; + let running = engine.restart_store().running()?; + anyhow::ensure!( + running.instance == instance && previous.is_none_or(|old| old != instance), + "readiness instance mismatch" + ); + let mut bytes = Vec::new(); + response + .take(4097) + .read_to_end(&mut bytes) + .map(|_read| ())?; + anyhow::ensure!(bytes.len() <= 4096, "readiness response exceeds limit"); + let observed: serde_json::Value = serde_json::from_slice(&bytes)?; + anyhow::ensure!( + observed.get("status").and_then(serde_json::Value::as_str) == Some("ready") + && observed.get("version").and_then(serde_json::Value::as_str) + == Some(version), + "readiness version mismatch" + ); + Ok::<_, anyhow::Error>(()) + })(); + if checked.is_ok() { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(100)); + } + anyhow::bail!("application failed bounded readiness/version validation") + } +} + +impl Service for NativeService { + fn preflight(&self) -> anyhow::Result<()> { + let view = self.view()?; + anyhow::ensure!( + view.controller + .as_ref() + .is_some_and(|controller| controller.id == self.context.id), + "full controller is no longer active" + ); + Ok(()) + } + fn stop(&self) -> anyhow::Result<()> { + coordinator::call(&self.context, coordinator::Request::Stop).map(|_ack| ()) + } + fn start(&self) -> anyhow::Result<()> { + coordinator::call(&self.context, coordinator::Request::Start).map(|_ack| ()) + } + fn health(&self, version: &str, _schema: &str) -> anyhow::Result<()> { + self.probe(version, None) + } + fn health_instance(&self, version: &str, previous: uuid::Uuid) -> anyhow::Result<()> { + self.probe(version, Some(previous)) + } + fn prepare_commit(&self, version: &str) -> anyhow::Result<()> { + coordinator::call( + &self.context, + coordinator::Request::Prepare { + version: version.to_owned(), + }, + ) + .map(|_ack| ()) + } + fn commit(&self) -> anyhow::Result<()> { + coordinator::call(&self.context, coordinator::Request::Commit).map(|_ack| ()) + } +} + +/// Send full controller requests only through the startup-authenticated root locator. +pub(super) async fn request(request: &Request) -> anyhow::Result { + let context = super::native::application_context()?; + let request = request.clone(); + tokio::task::spawn_blocking(move || { + if let Request::Ready { version } = request { + drop(coordinator::call( + &context, + coordinator::Request::CanStart { version }, + )?); + return Ok(Reply { + status: super::Status::default(), + ready: true, + error: None, + }); + } + let layout = Layout::prepare(&context.data)?; + let view = coordinator::call(&context, coordinator::Request::View)? + .view + .context("monitor omitted controller view")?; + let controller = view + .controller + .context("full controller is not initialized")?; + let mut socket = control::connect(&controller.endpoint, layout.uid, controller.identity)?; + socket.set_read_timeout(Some(Duration::from_secs(45)))?; + socket.write_all(&serde_json::to_vec(&request)?)?; + socket.shutdown(std::net::Shutdown::Write)?; + let mut bytes = Vec::new(); + socket + .take(512 * 1024 + 1) + .read_to_end(&mut bytes) + .map(|_read| ())?; + anyhow::ensure!(bytes.len() <= 512 * 1024, "controller reply exceeds limit"); + Ok::<_, anyhow::Error>(serde_json::from_slice(&bytes)?) + }) + .await? +} diff --git a/src/updates/coordinator.rs b/src/updates/coordinator.rs new file mode 100644 index 00000000..9b19f614 --- /dev/null +++ b/src/updates/coordinator.rs @@ -0,0 +1,802 @@ +//! Stable ordinary-account process monitor; full update policy lives in a selected controller. + +use super::{ + bootstrap, control, guardian, + lifecycle::{self, Layout, LifePhase, Lifetime, ProcessIdentity, WriterRole}, + native::{Context, Granted, Message, Monitor, Role, Signals}, +}; +use anyhow::Context as _; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + fs::{self, File}, + os::unix::net::{UnixListener, UnixStream}, + process::{Child, Command}, + time::{Duration, Instant}, +}; +use uuid::Uuid; + +/// Frozen small monitor contract, distinct from the full controller package version. +pub(super) const EPOCH: u32 = 1; +/// Lifecycle drain may exceed a short ordinary IPC exchange. +const DEADLINE: Duration = Duration::from_secs(90); + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields, tag = "action", rename_all = "snake_case")] +/// No operation accepts a path, URL, executable, environment or arbitrary command. +pub(super) enum Request { + /// Locate the current controller and admitted application. + View, + /// Require exact controlled application startup before migration. + CanStart { + /// Exact package version of the admitted application. + version: String, + }, + /// Check public request admission independently of readiness probing. + PublicAdmission, + /// Initialize one root-selected full controller before it can become active. + ControllerReady { + /// Exact selected full controller package version. + version: String, + /// Frozen small monitor contract understood by this controller. + epoch: u32, + }, + /// A prepared controller has entered its full active event loop. + Activated, + /// Close admission and prove every application/CLI writer exited. + Stop, + /// Spawn only the fixed validated active version. + Start, + /// Initialize the selected full controller before canonical data/code commit. + Prepare { + /// Fixed retained version to initialize before canonical commit. + version: String, + }, + /// Open admission only after the canonical terminal journal is durable. + Commit, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +/// Kernel identity and fixed endpoint of the active full controller. +pub(super) struct Controller { + /// Root-created durable guardian lifetime. + pub id: Uuid, + /// Registered primary process identity checked against socket credentials. + pub identity: ProcessIdentity, + /// Abstract socket derived solely from the durable lifetime identifier. + pub endpoint: String, + /// Package version of the selected full controller. + pub version: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +/// Only the root-owned exact application may report startup or use the controller. +pub(super) struct Application { + /// Registered primary process identity checked against socket credentials. + pub identity: ProcessIdentity, + /// Package version of the selected application. + pub version: String, + /// Listener/database initialization acknowledged by the owned application. + pub initialized: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +/// Small monitor facts contain no settings, release notes or application data. +pub(super) struct View { + /// Fully initialized active controller, absent during its initial launch. + pub controller: Option, + /// Registered application, absent until the guardian records its primary. + pub application: Option, + /// Whether canonical commit and activation permit external traffic. + pub public: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +/// Bounded core acknowledgment; larger admin status stays in controller IPC. +pub(super) struct Reply { + /// Small selected-process facts for locator and lifecycle queries. + pub view: Option, + /// Fixed operation acknowledgment or admission decision. + pub ready: bool, + /// Bounded failure preventing lifecycle admission. + pub error: Option, +} + +/// A guardian owns all descendants of this root-selected actor. +struct Actor { + /// Owned unreaped guardian; only the dedicated root thread waits on it. + child: Child, + /// Kernel identity authenticated during descriptor handoff. + guardian: ProcessIdentity, + /// Durable launch identity and fixed actor class. + lifetime: Lifetime, + /// Retained program version selected before spawn. + version: String, + /// Application/controller initialization acknowledged by its exact primary. + initialized: bool, +} + +/// A caller retains its connection while the dedicated main thread drains or initializes. +enum Pending { + /// Wait for all non-controller writers to finish before snapshot/restore. + Stop { + /// Retained authenticated caller connection for the final acknowledgment. + socket: UnixStream, + /// Absolute bounded-operation start observation. + started: Instant, + }, + /// Wait for the fixed selected replacement controller to initialize. + Prepare { + /// Retained authenticated caller connection for the final acknowledgment. + socket: UnixStream, + /// Root-created replacement controller lifetime. + id: Uuid, + /// Absolute bounded-operation start observation. + started: Instant, + }, + /// Wait for the prepared full controller to enter its active event loop. + Commit { + /// Retained authenticated caller connection for the final acknowledgment. + socket: UnixStream, + /// Initialized controller selected by the canonical terminal journal. + id: Uuid, + /// Absolute bounded-operation start observation. + started: Instant, + }, +} + +/// Only the main thread creates processes or reaps its owned guard subtrees. +struct Root<'a> { + /// Canonical owned data, journal and retained executable namespace. + layout: &'a Layout, + /// Private fixed core socket owned by this invocation. + listener: UnixListener, + /// Abstract address transferred only through authenticated startup context. + endpoint: String, + /// Dedicated long-lived main spawner identity. + identity: ProcessIdentity, + /// Close-only shared OFD lease, converted only after quiescence proof. + lease: File, + /// Root-owned unreaped guardian subtrees. + actors: HashMap, + /// Selected initialized full controller lifetime. + controller: Option, + /// Replacement initialized before canonical code/data commit. + prepared: Option, + /// Current application guardian lifetime. + application: Option, + /// One lifecycle acknowledgment whose proof remains pending. + pending: Option, + /// External admission opens only after durable commit and activation. + public: bool, + /// Normal shutdown prevents further grants or spawns. + stopping: bool, + /// Exit after all owned guardians prove complete subtree cleanup. + exit_requested: bool, + /// Expected original path bytes; unexpected local rebuilds are preserved. + original_hash: String, +} + +/// Own the one foreground/service invocation and always bootstrap the committed full controller. +pub(super) fn run( + layout: &Layout, + admission: File, + lease: File, + signals: Signals, +) -> anyhow::Result { + let endpoint = format!("rustchan-root-{}", Uuid::new_v4()); + let listener = control::listen(&endpoint)?; + listener.set_nonblocking(true)?; + rustix::process::set_child_subreaper(Some(rustix::process::Pid::INIT))?; + let status = bootstrap::engine(layout)?.status()?; + let mut root = Root { + layout, + listener, + endpoint, + identity: ProcessIdentity::current()?, + lease, + actors: HashMap::new(), + controller: None, + prepared: None, + application: None, + pending: None, + public: false, + stopping: false, + exit_requested: false, + original_hash: bootstrap::digest(&bootstrap::original_program(layout)?)?, + }; + let current = root.spawn(WriterRole::Controller, &status.installed)?; + root.controller = Some(current); + let Signals { + runtime, + mut terminate, + mut interrupt, + } = signals; + let result = runtime.block_on(async { + loop { + root.reap()?; + root.advance()?; + if root.exit_requested && root.actors.is_empty() { + return Ok::<_, anyhow::Error>(()); + } + let accepted = super::native::accept(&root.listener)?; + if let Some(mut socket) = accepted { + let handled = root.dispatch(&mut socket); + match handled { + Ok(Some(pending)) => root.pending = Some(pending), + Ok(None) => {} + Err(error) => tracing::warn!(%error, "native core request rejected"), + } + } + tokio::select! { + _signal = terminate.recv() => root.shutdown()?, + _signal = interrupt.recv() => root.shutdown()?, + () = tokio::time::sleep(Duration::from_millis(25)) => {} + } + } + }); + if result.is_err() { + root.public = false; + runtime.block_on(guardian::drain_remaining(terminate, interrupt))?; + // Complete kernel ECHILD proof covers all this root's possible ordinary descendants. + for actor in root.actors.values() { + let mut record = layout.lifetime(actor.lifetime.id)?; + anyhow::ensure!(record.parent == root.identity, "actor ownership changed"); + record.phase = LifePhase::Stopped; + layout.save_lifetime(&record)?; + } + } + let metadata = layout.state.join("monitor.json"); + if metadata.try_exists()? { + fs::remove_file(metadata)?; + File::open(&layout.state)?.sync_all()?; + } + drop(root); + drop(admission); + result?; + Ok(true) +} + +impl Root<'_> { + /// Commands derive only from one fixed namespace and the original launcher arguments. + fn spawn(&mut self, role: WriterRole, version: &str) -> anyhow::Result { + let program = bootstrap::version_binary(self.layout, version)?; + let lifetime = self.layout.launch(role)?; + let context = Context { + role: Role::Guardian, + data: self.layout.data.clone(), + id: lifetime.id, + parent: self.identity, + monitor: self.identity, + endpoint: self.endpoint.clone(), + automatic: true, + port: crate::config::launcher_port(), + }; + let mut command = Command::new(program); + if role == WriterRole::Controller { + let _arguments = command.arg("--native-controller"); + } else { + let _arguments = command.args(std::env::args_os().skip(1)); + } + let _environment = command + .env(super::native::INTERNAL, serde_json::to_string(&context)?) + .env("RUSTCHAN_SPAWNED", "1"); + let child = match command.spawn() { + Ok(child) => child, + Err(error) => { + let mut stopped = lifetime; + stopped.phase = LifePhase::Stopped; + self.layout.save_lifetime(&stopped)?; + return Err(error.into()); + } + }; + let guardian = ProcessIdentity::read(i32::try_from(child.id())?)?; + let id = lifetime.id; + let _previous = self.actors.insert( + id, + Actor { + child, + guardian, + lifetime, + version: version.to_owned(), + initialized: false, + }, + ); + Ok(id) + } + + /// Kernel peer credentials bind every frame to a selected guardian or its exact primary. + fn dispatch(&mut self, socket: &mut UnixStream) -> anyhow::Result> { + let peer = rustix::net::sockopt::socket_peercred(&*socket)?; + anyhow::ensure!( + peer.uid.as_raw() == self.layout.uid, + "different native core owner" + ); + let identity = ProcessIdentity::read(peer.pid.as_raw_nonzero().get())?; + let (message, _unexpected): (Message, _) = control::receive(socket, false)?; + match message { + Message::Guardian { id } => { + let actor = self.actors.get(&id).context("unknown native guardian")?; + control::authorize(socket, self.layout.uid, actor.guardian)?; + anyhow::ensure!(!self.stopping, "native shutdown has started"); + control::send(socket, &Granted { id }, Some(&self.lease))?; + } + Message::Initialized { id } => { + let actor = self + .actors + .get_mut(&id) + .context("unknown native application")?; + let record = self.layout.lifetime(id)?; + anyhow::ensure!( + record.role == WriterRole::Server + && record.child == Some(identity) + && record.guardian == Some(actor.guardian) + && self.application == Some(id), + "different initialized application" + ); + actor.initialized = true; + self.lease.lock_shared()?; + control::send(socket, &Granted { id }, None)?; + } + Message::Administrator { + id, + version, + program_sha256, + } => { + anyhow::ensure!( + self.public && !self.stopping && self.pending.is_none(), + "administration admission is closed" + ); + let application = self.application.context("application is unavailable")?; + let actor = self + .actors + .get(&application) + .context("application is unavailable")?; + let program = bootstrap::version_binary(self.layout, &actor.version)?; + anyhow::ensure!( + actor.version == version && bootstrap::digest(&program)? == program_sha256, + "administration executable differs from the active application" + ); + let record = self.layout.lifetime(id)?; + anyhow::ensure!( + record.role == WriterRole::Administrator + && record.phase == LifePhase::Launching + && record.parent == identity + && record.guardian.is_none() + && record.child.is_none(), + "invalid administration intent" + ); + let shared = self.layout.shared_writer()?; + control::send(socket, &Granted { id }, Some(&shared))?; + } + Message::Core { request } => { + let id = self.primary(identity)?; + return self.core(socket, id, request); + } + } + Ok(None) + } + + /// Find only an owned exact primary identity; missing PIDs never clear lifetime records. + fn primary(&self, identity: ProcessIdentity) -> anyhow::Result { + for actor in self.actors.values() { + let record = self.layout.lifetime(actor.lifetime.id)?; + if record.child == Some(identity) && record.guardian == Some(actor.guardian) { + return Ok(record.id); + } + } + anyhow::bail!("core caller is not an admitted primary") + } + + /// Small fixed core operations; full discovery, signing, snapshots and schema policy stay outside. + #[expect( + clippy::too_many_lines, + reason = "the closed monitor protocol keeps authentication, lifecycle state and acknowledgments in one reviewable dispatch boundary" + )] + fn core( + &mut self, + socket: &mut UnixStream, + id: Uuid, + request: Request, + ) -> anyhow::Result> { + match request { + Request::View => Self::reply(socket, Some(self.view()?), true)?, + Request::CanStart { version } => { + let actor = self.actors.get(&id).context("missing application")?; + Self::reply( + socket, + None, + self.application == Some(id) && actor.version == version && !self.stopping, + )?; + } + Request::PublicAdmission => Self::reply( + socket, + None, + self.application == Some(id) && self.public && !self.stopping, + )?, + Request::ControllerReady { version, epoch } => { + let actor = self.actors.get_mut(&id).context("missing controller")?; + anyhow::ensure!( + actor.lifetime.role == WriterRole::Controller + && actor.version == version + && epoch == EPOCH, + "controller monitor contract mismatch" + ); + actor.initialized = true; + Self::reply(socket, None, true)?; + } + Request::Activated => { + anyhow::ensure!( + self.controller == Some(id) + && self.actors.get(&id).is_some_and(|actor| actor.initialized), + "controller is not active and initialized" + ); + let pending = self.pending.take(); + match pending { + Some(Pending::Commit { + socket: mut committed, + id: expected, + started: _, + }) if expected == id => { + self.open_committed()?; + Self::reply(&mut committed, None, true)?; + } + other => self.pending = other, + } + Self::reply(socket, None, true)?; + } + Request::Stop => { + self.authorize_controller(id)?; + anyhow::ensure!(self.pending.is_none(), "another core command is pending"); + self.public = false; + if let Some(app) = self.application { + super::native::signal( + self.actors + .get(&app) + .context("missing application")? + .guardian, + )?; + } + return Ok(Some(Pending::Stop { + socket: socket.try_clone()?, + started: Instant::now(), + })); + } + Request::Start => { + self.authorize_controller(id)?; + anyhow::ensure!( + self.application.is_none() && self.pending.is_none() && !self.stopping, + "application start is not quiescent" + ); + self.quiescent()?; + let version = bootstrap::engine(self.layout)?.current_version()?; + bootstrap::enroll_candidate(self.layout, &version)?; + self.application = Some(self.spawn(WriterRole::Server, &version)?); + Self::reply(socket, None, true)?; + } + Request::Prepare { version } => { + self.authorize_controller(id)?; + anyhow::ensure!( + self.pending.is_none() + && bootstrap::digest(&bootstrap::original_program(self.layout)?)? + == self.original_hash, + "source changed or another core command is pending" + ); + let app = self + .application + .and_then(|app| self.actors.get(&app)) + .context("application is unavailable")?; + anyhow::ensure!( + app.initialized && app.version == version, + "candidate did not initialize" + ); + if self + .actors + .get(&id) + .is_some_and(|actor| actor.version == version && actor.initialized) + { + self.prepared = Some(id); + Self::reply(socket, None, true)?; + } else { + bootstrap::enroll_candidate(self.layout, &version)?; + let prepared = self.spawn(WriterRole::Controller, &version)?; + self.prepared = Some(prepared); + return Ok(Some(Pending::Prepare { + socket: socket.try_clone()?, + id: prepared, + started: Instant::now(), + })); + } + } + Request::Commit => { + self.authorize_controller(id)?; + anyhow::ensure!(self.pending.is_none(), "another core command is pending"); + let status = bootstrap::engine(self.layout)?.status()?; + anyhow::ensure!( + !status.phase.active() + && status.phase != super::Phase::FailedManualIntervention, + "canonical commit is not terminal" + ); + let prepared = self + .prepared + .or(self.controller) + .context("controller is unavailable")?; + let actor = self + .actors + .get(&prepared) + .context("prepared controller is unavailable")?; + anyhow::ensure!( + actor.initialized && actor.version == status.installed, + "newest controller is not initialized" + ); + self.controller = Some(prepared); + self.prepared = None; + if prepared == id { + self.open_committed()?; + Self::reply(socket, None, true)?; + } else { + return Ok(Some(Pending::Commit { + socket: socket.try_clone()?, + id: prepared, + started: Instant::now(), + })); + } + } + } + Ok(None) + } + + /// Only the initialized active full controller may change controlled lifecycle state. + fn authorize_controller(&self, id: Uuid) -> anyhow::Result<()> { + anyhow::ensure!( + self.controller == Some(id) + && self.actors.get(&id).is_some_and(|actor| actor.initialized), + "only the active full controller may control lifecycle" + ); + Ok(()) + } + + /// Keep root-owned controller guards while proving every other writer completed. + fn quiescent(&self) -> anyhow::Result<()> { + self.lease + .try_lock() + .context("independent administration writers remain active")?; + let controllers: Vec<_> = self + .actors + .values() + .filter(|actor| actor.lifetime.role == WriterRole::Controller) + .map(|actor| actor.lifetime.id) + .collect(); + self.layout + .prove_quiescence_except(lifecycle::kernel_boot_id()?, &controllers) + } + + /// Durable terminal code/data selection precedes source publication and public admission. + fn open_committed(&mut self) -> anyhow::Result<()> { + let status = bootstrap::engine(self.layout)?.status()?; + let app = self + .application + .and_then(|id| self.actors.get(&id)) + .context("committed application is unavailable")?; + anyhow::ensure!( + app.initialized + && app.version == status.installed + && !status.phase.active() + && status.phase != super::Phase::FailedManualIntervention, + "committed application mismatch" + ); + self.original_hash = + bootstrap::publish_original(self.layout, &status.installed, &self.original_hash)?; + super::native::publish_monitor( + self.layout, + &Monitor { + format: lifecycle::FORMAT, + boot: lifecycle::kernel_boot_id()?, + identity: self.identity, + endpoint: self.endpoint.clone(), + version: app.version.clone(), + program_sha256: self.original_hash.clone(), + }, + )?; + self.public = true; + Ok(()) + } + + /// Poll acknowledgment predicates on the sole spawner/reaper thread. + fn advance(&mut self) -> anyhow::Result<()> { + let Some(pending) = self.pending.take() else { + return Ok(()); + }; + if let Pending::Prepare { + id, + started, + socket: _, + } = &pending + { + if !self.actors.contains_key(id) || started.elapsed() >= DEADLINE { + if let Pending::Prepare { + mut socket, + id: _, + started: _, + } = pending + { + self.prepared = None; + control::send( + &mut socket, + &Reply { + view: None, + ready: false, + error: Some("full replacement controller could not initialize".into()), + }, + None, + )?; + return Ok(()); + } + } + } + let finished = match &pending { + Pending::Stop { started, socket: _ } => { + anyhow::ensure!( + started.elapsed() < DEADLINE, + "writer shutdown remains unproved" + ); + self.application.is_none() && self.quiescent().is_ok() + } + Pending::Prepare { + id, + started, + socket: _, + } => { + anyhow::ensure!( + started.elapsed() < DEADLINE && self.actors.contains_key(id), + "full controller initialization failed" + ); + self.actors.get(id).is_some_and(|actor| actor.initialized) + } + Pending::Commit { + started, + id: _, + socket: _, + } => { + anyhow::ensure!( + started.elapsed() < DEADLINE, + "new controller activation acknowledgment was lost" + ); + false + } + }; + if finished { + let mut socket = match pending { + Pending::Stop { socket, started: _ } + | Pending::Prepare { + socket, + started: _, + id: _, + } + | Pending::Commit { + socket, + started: _, + id: _, + } => socket, + }; + Self::reply(&mut socket, None, true)?; + } else { + self.pending = Some(pending); + } + Ok(()) + } + + /// Trust normal completion only with the guardian's durable all-descendant proof. + fn reap(&mut self) -> anyhow::Result<()> { + let mut completed = Vec::new(); + for (id, actor) in &mut self.actors { + if let Some(status) = actor.child.try_wait()? { + let record = self.layout.lifetime(*id)?; + anyhow::ensure!( + record.phase == LifePhase::Stopped && record.parent == self.identity, + "guardian was lost without descendant-exit proof" + ); + completed.push((*id, status.success())); + } + } + for (id, success) in completed { + let _actor = self.actors.remove(&id); + if self.application == Some(id) { + self.application = None; + self.public = false; + if success + && self.pending.is_none() + && !bootstrap::engine(self.layout)?.status()?.phase.active() + { + self.shutdown()?; + } + } + if self.controller == Some(id) && !self.exit_requested { + anyhow::bail!("active full controller exited"); + } + } + Ok(()) + } + + /// Stop only owned unreaped guard PIDs; their guards prove complete subtree completion. + fn shutdown(&mut self) -> anyhow::Result<()> { + self.public = false; + self.stopping = true; + self.exit_requested = true; + for actor in self.actors.values() { + super::native::signal(actor.guardian)?; + } + Ok(()) + } + + /// Return only selected initialized actors bound to actual registered child identities. + fn view(&self) -> anyhow::Result { + let controller = self + .controller + .and_then(|id| self.actors.get(&id)) + .filter(|actor| actor.initialized) + .map(|actor| { + let identity = self + .layout + .lifetime(actor.lifetime.id)? + .child + .context("controller primary missing")?; + Ok::<_, anyhow::Error>(Controller { + id: actor.lifetime.id, + identity, + endpoint: format!("rustchan-controller-{}", actor.lifetime.id), + version: actor.version.clone(), + }) + }) + .transpose()?; + let application = self + .application + .and_then(|id| self.actors.get(&id)) + .map(|actor| { + let identity = self.layout.lifetime(actor.lifetime.id)?.child; + Ok::<_, anyhow::Error>(identity.map(|identity| Application { + identity, + version: actor.version.clone(), + initialized: actor.initialized, + })) + }) + .transpose()? + .flatten(); + Ok(View { + controller, + application, + public: self.public, + }) + } + + /// Send a bounded no-descriptor response while retaining every root lease. + fn reply(socket: &mut UnixStream, view: Option, ready: bool) -> anyhow::Result<()> { + control::send( + socket, + &Reply { + view, + ready, + error: None, + }, + None, + ) + } +} + +/// Root calls are authenticated to the creating monitor and never select their endpoint via HTTP. +pub(super) fn call(context: &Context, request: Request) -> anyhow::Result { + let layout = Layout::prepare(&context.data)?; + let mut socket = control::connect(&context.endpoint, layout.uid, context.monitor)?; + control::send(&mut socket, &Message::Core { request }, None)?; + let (reply, _unexpected): (Reply, _) = + control::receive_with_timeout(&mut socket, false, Duration::from_secs(120))?; + anyhow::ensure!( + reply.error.is_none() && reply.ready, + "native core denied lifecycle operation" + ); + Ok(reply) +} diff --git a/src/updates/daemon.rs b/src/updates/daemon.rs index d9042829..7d31d723 100644 --- a/src/updates/daemon.rs +++ b/src/updates/daemon.rs @@ -1,5 +1,5 @@ #[cfg(unix)] -use super::transaction::native::{Config, Engine, Service}; +use super::transaction::native::{Config, Engine, Ownership, Service}; #[cfg(unix)] use anyhow::Context as _; use std::path::Path; @@ -70,13 +70,17 @@ mod linux { ); let config: Config = toml::from_str(&fs::read_to_string(config_path)?) .context("invalid updater configuration")?; - super::super::transaction::native::protected_ancestors(&config.public_key, config.web_uid)?; - let key_metadata = fs::symlink_metadata(&config.public_key)?; + let key_path = config + .public_key + .as_ref() + .context("legacy updater requires its trusted public key path")?; + super::super::transaction::native::protected_ancestors(key_path, config.web_uid)?; + let key_metadata = fs::symlink_metadata(key_path)?; anyhow::ensure!( key_metadata.is_file() && key_metadata.uid() == 0 && key_metadata.mode() & 0o022 == 0, "release trust key must be root owned and non-writable" ); - let key_text = fs::read_to_string(&config.public_key)?; + let key_text = fs::read_to_string(key_path)?; let key_text = key_text.trim(); anyhow::ensure!( key_text.len() == 64 && key_text.is_ascii(), @@ -99,7 +103,11 @@ mod linux { "managed installation/state must belong to the updater identity" ); } - let engine = Engine { config, key }; + let engine = Engine { + config, + key, + ownership: Ownership::Separated, + }; engine.validate()?; super::super::transaction::native::protected_ancestors( Path::new(SOCKET) diff --git a/src/updates/guardian.rs b/src/updates/guardian.rs new file mode 100644 index 00000000..bda838eb --- /dev/null +++ b/src/updates/guardian.rs @@ -0,0 +1,341 @@ +//! Ordinary-account descendant ownership for the one delivered executable. +//! +//! A guardian runs on a dedicated, long-lived main thread. It retains its writer +//! lease even if the application closes every descriptor, daemonizes, or creates +//! another session. Subreaping, rather than process-group membership, proves exit. +//! Forced guardian loss deliberately leaves its durable lifetime uncertain. + +use super::lifecycle::{Layout, LifePhase, ProcessIdentity}; +use anyhow::Context as _; +use rustix::process::{Pid, Signal, WaitOptions}; +use std::fs::File; +use std::process::Command; +use std::time::{Duration, Instant}; +use uuid::Uuid; + +/// Permit ordinary descendants time to finish before escalating their signals. +const DRAIN_TIMEOUT: Duration = Duration::from_secs(10); +/// Reaping is bounded per iteration so signals and newly adopted children progress. +const POLL_INTERVAL: Duration = Duration::from_millis(25); + +/// Arm and immediately re-check the actual spawning parent before any data mutation. +/// The caller installs its signal listener before requesting a catchable death signal. +pub(super) fn arm_parent(parent: ProcessIdentity, signal: Signal) -> anyhow::Result<()> { + anyhow::ensure!( + ordinary_account()?, + "internal writers require an ordinary account without elevated capabilities" + ); + rustix::thread::set_no_new_privs(true)?; + rustix::process::set_parent_process_death_signal(Some(signal))?; + anyhow::ensure!( + rustix::process::getppid().is_some_and(|pid| pid.as_raw_nonzero().get() == parent.pid) + && ProcessIdentity::read(parent.pid)? == parent, + "spawning parent exited before writer admission" + ); + Ok(()) +} + +/// Inspect the current identity before lowering privileges or allowing internal actors. +pub(super) fn ordinary_account() -> anyhow::Result { + if rustix::process::getuid().is_root() + || rustix::process::getuid() != rustix::process::geteuid() + || rustix::process::getgid() != rustix::process::getegid() + { + return Ok(false); + } + let status = std::fs::read_to_string("/proc/self/status")?; + for name in ["CapEff:", "CapPrm:"] { + let value = status + .lines() + .find_map(|line| line.strip_prefix(name)) + .context("kernel privilege information unavailable")?; + if u64::from_str_radix(value.trim(), 16)? != 0 { + return Ok(false); + } + } + Ok(true) +} + +/// Spawn only after durable intent, then reap the application and every descendant. +/// The fixed application/controller supplies the command; this is not an IPC command API. +pub(super) fn run( + layout: &Layout, + id: Uuid, + parent: ProcessIdentity, + lease: File, + command: &mut Command, +) -> anyhow::Result { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + runtime.block_on(async { + let terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?; + let interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?; + arm_parent(parent, Signal::TERM)?; + layout.validate_lease(&lease)?; + rustix::process::set_child_subreaper(Some(Pid::INIT))?; + anyhow::ensure!( + rustix::process::child_subreaper()?.is_some(), + "subreaper was not armed" + ); + let mut lifetime = layout.lifetime(id)?; + anyhow::ensure!( + lifetime.phase == LifePhase::Launching && lifetime.parent == parent, + "guardian launch does not match durable intent" + ); + lifetime.guardian = Some(ProcessIdentity::current()?); + lifetime.phase = LifePhase::Running; + layout.save_lifetime(&lifetime)?; + // No fallible cleanup may publish Stopped before the kernel proves that + // the subreaper has no children. An exec failure also follows this path. + let spawned = command.spawn(); + let primary = spawned.as_ref().ok().map(std::process::Child::id); + let registered = (|| { + if let Some(pid) = primary { + lifetime.child = Some(ProcessIdentity::read(i32::try_from(pid)?)?); + layout.save_lifetime(&lifetime)?; + } + Ok::<_, anyhow::Error>(()) + })(); + let result = reap_descendants( + primary, + spawned.is_err() || registered.is_err(), + terminate, + interrupt, + ) + .await?; + // ECHILD proves every ordinary descendant was reaped before the durable + // completion record or any lease release can permit another writer. + lifetime.phase = LifePhase::Stopped; + layout.save_lifetime(&lifetime)?; + drop(lease); + registered?; + Ok(result) + }) +} + +/// Keep the sole-spawner thread alive through normal shutdown and subreaping. +async fn reap_descendants( + primary: Option, + spawn_failed: bool, + mut terminate: tokio::signal::unix::Signal, + mut interrupt: tokio::signal::unix::Signal, +) -> anyhow::Result { + let mut exit = None; + let mut draining = if spawn_failed { + Some(Instant::now()) + } else { + None + }; + loop { + if let Some(start) = draining { + signal_children(if start.elapsed() < DRAIN_TIMEOUT { + Signal::TERM + } else { + Signal::KILL + })?; + } + loop { + // waitpid(None) means the caller's process group, not every child; + // a setsid descendant must remain covered by wait(-1). + match rustix::process::wait(WaitOptions::NOHANG) { + Ok(Some((pid, status))) => { + if primary == Some(u32::try_from(pid.as_raw_nonzero().get())?) { + exit = Some(match status.exit_status() { + Some(code) => code, + None => 128_i32 + .checked_add(status.terminating_signal().unwrap_or(0_i32)) + .context("exit signal overflow")?, + }); + let _started = draining.get_or_insert_with(Instant::now); + } + } + Ok(None) => break, + Err(rustix::io::Errno::CHILD) => { + return Ok(exit.unwrap_or(1)); + } + Err(rustix::io::Errno::INTR) => {} + Err(error) => return Err(error.into()), + } + } + tokio::select! { + _signal = terminate.recv() => { let _started = draining.get_or_insert_with(Instant::now); } + _signal = interrupt.recv() => { let _started = draining.get_or_insert_with(Instant::now); } + () = tokio::time::sleep(POLL_INTERVAL) => {} + } + } +} + +/// A surviving sole-spawner monitor may prove its complete owned subtree exited. +/// Missing PID/lease evidence alone is never substituted for this kernel wait. +pub(super) async fn drain_remaining( + terminate: tokio::signal::unix::Signal, + interrupt: tokio::signal::unix::Signal, +) -> anyhow::Result<()> { + let _exit = reap_descendants(None, true, terminate, interrupt).await?; + Ok(()) +} + +/// Signal only this sole-reaper thread's direct/adopted children. Unreaped child +/// PIDs cannot be reused, and no other thread in a guardian waits for children. +fn signal_children(signal: Signal) -> anyhow::Result<()> { + // Subreaper adoption can attach children to the thread-group leader even + // when the dedicated long-lived spawner is another thread (as in libtest). + let mut children = std::collections::HashSet::new(); + for thread in std::fs::read_dir("/proc/self/task")? { + let path = thread?.path().join("children"); + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(error.into()), + }; + for value in text.split_ascii_whitespace() { + let raw: i32 = value.parse()?; + let pid = Pid::from_raw(raw).context("invalid adopted child")?; + let _new_child = children.insert(pid); + } + } + for pid in children { + match rustix::process::kill_process(pid, signal) { + Ok(()) | Err(rustix::io::Errno::SRCH) => {} + Err(error) => return Err(error.into()), + } + } + Ok(()) +} + +#[cfg(test)] +/// These subprocess tests exercise the actual Rust guardian under an ordinary Linux UID. +mod tests { + use super::super::{control, lifecycle::WriterRole}; + use super::*; + use std::io::Write as _; + use std::process::Stdio; + + #[derive(serde::Serialize, serde::Deserialize)] + #[serde(deny_unknown_fields)] + /// Parent-supplied, fixed test context. Never part of the production HTTP protocol. + struct TestContext { + /// Disposable canonical data root. + data: std::path::PathBuf, + /// Durable pre-spawn lifetime. + id: Uuid, + /// Actual spawning process identity. + parent: ProcessIdentity, + /// Parent-bound temporary abstract endpoint. + endpoint: String, + } + + /// Re-executed explicitly by the parent regression, never in the ordinary test suite. + #[test] + #[ignore = "internal guardian subprocess entry"] + fn child_entry() -> anyhow::Result<()> { + let bytes = std::env::var("RUSTCHAN_GUARDIAN_TEST")?; + let context: TestContext = serde_json::from_str(&bytes)?; + let layout = Layout::prepare(&context.data)?; + let mut socket = control::connect(&context.endpoint, layout.uid, context.parent)?; + let (id, lease): (Uuid, _) = control::receive(&mut socket, true)?; + anyhow::ensure!(id == context.id); + let mut command = Command::new("python3"); + let _arguments = command + .arg("-c") + .arg( + r" +import os, time +if os.fork() == 0: + os.setsid() + if os.fork() == 0: + os.closerange(0, 1024) + with open('writer.pid', 'w') as f: f.write(str(os.getpid())) + while True: + with open('heartbeat', 'ab') as f: f.write(b'x') + time.sleep(0.02) + os._exit(0) +while True: time.sleep(1) +", + ) + .current_dir(&context.data) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let _status = run( + &layout, + id, + context.parent, + lease.context("missing writer lease")?, + &mut command, + )?; + Ok(()) + } + + /// A new-session double-forked FD-closing writer remains covered by its guardian. + #[test] + fn double_forked_writer_is_reaped_before_lifetime_clears() -> anyhow::Result<()> { + if rustix::process::getuid().is_root() { + return Ok(()); + } + let root = tempfile::tempdir()?; + let layout = Layout::prepare(root.path())?; + let _admission = layout.admission()?; + let lease = layout.shared_writer()?; + let lifetime = layout.launch(WriterRole::Server)?; + let endpoint = format!("rustchan-guardian-test-{}", Uuid::new_v4()); + let listener = control::listen(&endpoint)?; + let context = TestContext { + data: layout.data.clone(), + id: lifetime.id, + parent: ProcessIdentity::current()?, + endpoint, + }; + let mut child = Command::new(std::env::current_exe()?) + .args([ + "--exact", + "updates::guardian::tests::child_entry", + "--ignored", + "--nocapture", + ]) + .env("RUSTCHAN_GUARDIAN_TEST", serde_json::to_string(&context)?) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .spawn()?; + let (mut socket, _address) = listener.accept()?; + let identity = ProcessIdentity::read(i32::try_from(child.id())?)?; + control::authorize(&socket, layout.uid, identity)?; + control::send(&mut socket, &lifetime.id, Some(&lease))?; + drop(lease); + let started = Instant::now(); + while !layout.data.join("writer.pid").try_exists()? + || !layout.data.join("heartbeat").try_exists()? + { + anyhow::ensure!( + started.elapsed() < Duration::from_secs(10), + "descendant did not start" + ); + std::thread::sleep(POLL_INTERVAL); + } + let writer: i32 = std::fs::read_to_string(layout.data.join("writer.pid"))?.parse()?; + anyhow::ensure!(layout.exclusive_quiescent().is_err()); + rustix::process::kill_process( + Pid::from_raw(identity.pid).context("guardian PID")?, + Signal::TERM, + )?; + anyhow::ensure!(child.wait()?.success(), "guardian cleanup failed"); + anyhow::ensure!( + ProcessIdentity::read(writer).is_err(), + "orphan writer survived proven cleanup" + ); + anyhow::ensure!(layout.lifetime(lifetime.id)?.phase == LifePhase::Stopped); + drop(layout.exclusive_quiescent()?); + let size = std::fs::metadata(layout.data.join("heartbeat"))?.len(); + std::thread::sleep(Duration::from_millis(100)); + anyhow::ensure!(std::fs::metadata(layout.data.join("heartbeat"))?.len() == size); + let mut evidence = File::create(layout.data.join("verified.txt"))?; + writeln!( + evidence, + "ordinary UID {}; double-forked writer {writer} reaped; lifetime stopped", + layout.uid + )?; + Ok(()) + } +} diff --git a/src/updates/lifecycle.rs b/src/updates/lifecycle.rs new file mode 100644 index 00000000..e61e0ee7 --- /dev/null +++ b/src/updates/lifecycle.rs @@ -0,0 +1,680 @@ +//! Same-account writer admission. A free advisory lock never proves an orphan exited. +//! +//! Lifetime intent is durable before spawn; completion is durable only after every +//! ordinary descendant was reaped. Uncertain records block recovery on the same +//! kernel boot, even if no lease holder remains. Lock inodes are never restored. + +use anyhow::Context as _; +use serde::{Deserialize, Serialize}; +use std::fs::{self, File}; +use std::io::{Read as _, Write as _}; +use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _}; +use std::path::{Path, PathBuf}; +use uuid::Uuid; + +/// Version of the small persisted monitor/lifetime contract. +pub(super) const FORMAT: u32 = 1; +/// Bound the number of records consulted before touching application data. +const MAX_RECORDS: usize = 16_384; +/// Maximum encoded bytes in one lifetime record. +const MAX_RECORD_BYTES: u64 = 4096; + +#[derive(Debug, Clone)] +/// Fixed internal state roots, separate from restored boards/runtime data. +pub(super) struct Layout { + /// Canonical application data directory selected only by startup arguments. + pub data: PathBuf, + /// Private internal state containing stable lock inodes and writer records. + pub state: PathBuf, + /// Immutable same-binary controller copies and bounded active-version link. + pub install: PathBuf, + /// Only account authorized to operate this same-account layout. + pub uid: u32, +} + +impl Layout { + /// Validate same-account authority and prepare private updater resources. + pub(super) fn prepare(data: &Path) -> anyhow::Result { + let uid = rustix::process::getuid().as_raw(); + anyhow::ensure!(uid != 0, "automatic updates require an ordinary account"); + crate::utils::fs_security::reject_symlink_components(data)?; + let data = data.canonicalize()?; + owned_ancestors(&data, uid)?; + anyhow::ensure!( + fs::metadata(&data)?.uid() == uid, + "application data has a different owner" + ); + let state = data.join(".software-updates"); + private_directory(&state, uid)?; + let install = state.join("installation"); + private_directory(&install, uid)?; + for path in [ + state.join("writers"), + state.join("journal-staging"), + state.join("backups"), + install.join("versions"), + ] { + private_directory(&path, uid)?; + } + Ok(Self { + data, + state, + install, + uid, + }) + } + + /// Stable single-controller admission inode; hold through every lock conversion. + pub(super) fn admission(&self) -> anyhow::Result { + let file = private_file(&self.state.join("admission.lock"), self.uid)?; + file.try_lock() + .context("RustChan already has an active controller")?; + Ok(file) + } + + /// Fresh independent descriptor for the fixed data-writer inode. + pub(super) fn writer_file(&self) -> anyhow::Result { + private_file(&self.state.join("data-writers.lock"), self.uid) + } + + /// Acquire independent ordinary-writer admission before application side effects. + pub(super) fn shared_writer(&self) -> anyhow::Result { + let file = self.writer_file()?; + file.try_lock_shared() + .context("RustChan data is being upgraded or recovered")?; + // Re-check uncertainty after the lock, so an exclusive recovery cannot + // race this check. New valid lifetimes on the current boot are permitted + // only under the active monitor; independent CLI admission is separate. + Ok(file) + } + + /// A transferred lease must reference the fixed inode and stay close-on-exec. + pub(super) fn validate_lease(&self, lease: &File) -> anyhow::Result<()> { + let expected = self.writer_file()?.metadata()?; + let received = lease.metadata()?; + anyhow::ensure!( + received.is_file() + && received.uid() == self.uid + && received.nlink() == 1 + && received.mode().trailing_zeros() >= 6 + && received.dev() == expected.dev() + && received.ino() == expected.ino() + && rustix::io::fcntl_getfd(lease)?.contains(rustix::io::FdFlags::CLOEXEC), + "invalid transferred data-writer lease" + ); + Ok(()) + } + + /// Require both exclusive writer exclusion and durable descendant-exit proof. + pub(super) fn exclusive_quiescent(&self) -> anyhow::Result { + let file = self.writer_file()?; + file.try_lock() + .context("RustChan still has active data writers")?; + self.prove_quiescence(kernel_boot_id()?)?; + Ok(file) + } + + /// Refuse same-boot uncertainty without treating a missing PID as proof. + pub(super) fn prove_quiescence(&self, boot: Uuid) -> anyhow::Result<()> { + self.prove_quiescence_except(boot, &[]) + } + + /// Only current root-owned controller guards can retain the root's converted OFD lease. + pub(super) fn prove_quiescence_except( + &self, + boot: Uuid, + controllers: &[Uuid], + ) -> anyhow::Result<()> { + for (count, item) in fs::read_dir(self.state.join("writers"))?.enumerate() { + anyhow::ensure!( + count < MAX_RECORDS, + "writer lifetime inventory exceeds safety bounds" + ); + let path = item?.path(); + let name = path + .file_stem() + .and_then(std::ffi::OsStr::to_str) + .context("invalid writer lifetime name")?; + let id = Uuid::parse_str(name)?; + anyhow::ensure!( + path.file_name() == Some(std::ffi::OsStr::new(&format!("{id}.json"))), + "invalid writer lifetime layout" + ); + let record: Lifetime = + serde_json::from_slice(&read_private(&path, self.uid, MAX_RECORD_BYTES)?)?; + record.validate(id)?; + if controllers.contains(&id) { + anyhow::ensure!( + record.role == WriterRole::Controller + && record.parent == ProcessIdentity::current()?, + "controller exclusion has a different owner" + ); + continue; + } + anyhow::ensure!(record.phase == LifePhase::Stopped || record.boot != boot, + "Writer exit could not be proved. RustChan remains stopped to protect data; restart the host before recovery."); + } + Ok(()) + } + + /// Persist conservative intent before permitting one same-binary guardian to spawn. + pub(super) fn launch(&self, role: WriterRole) -> anyhow::Result { + let identity = ProcessIdentity::current()?; + let record = Lifetime { + format: FORMAT, + id: Uuid::new_v4(), + boot: kernel_boot_id()?, + role, + phase: LifePhase::Launching, + parent: identity, + guardian: None, + child: None, + }; + self.save_lifetime(&record)?; + Ok(record) + } + + /// Fsync exact lifetime bytes and the containing directory before acknowledgment. + pub(super) fn save_lifetime(&self, record: &Lifetime) -> anyhow::Result<()> { + self.save_lifetime_with(record, |_| Ok(())) + } + + /// Keep fault injection at the real fsync/rename boundaries used in production. + fn save_lifetime_with( + &self, + record: &Lifetime, + checkpoint: impl Fn(SaveStep) -> anyhow::Result<()>, + ) -> anyhow::Result<()> { + let path = self + .state + .join("writers") + .join(format!("{}.json", record.id)); + if path.try_exists()? { + drop(read_private(&path, self.uid, MAX_RECORD_BYTES)?); + } + let staging = self.state.join("journal-staging"); + let mut temporary = tempfile::NamedTempFile::new_in(&staging)?; + checkpoint(SaveStep::Write)?; + temporary.write_all(&serde_json::to_vec(record)?)?; + checkpoint(SaveStep::FileSync)?; + temporary.as_file().sync_all()?; + checkpoint(SaveStep::Publish)?; + drop(temporary.persist(&path).map_err(|error| error.error)?); + checkpoint(SaveStep::StagingSync)?; + File::open(&staging)?.sync_all()?; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?; + checkpoint(SaveStep::PublishedSync)?; + File::open(&path)?.sync_all()?; + checkpoint(SaveStep::DirectorySync)?; + File::open(path.parent().context("missing lifetime directory")?)?.sync_all()?; + Ok(()) + } + + /// Validate the fixed launch record before a guardian opens application data. + pub(super) fn lifetime(&self, id: Uuid) -> anyhow::Result { + let path = self.state.join("writers").join(format!("{id}.json")); + let record: Lifetime = + serde_json::from_slice(&read_private(&path, self.uid, MAX_RECORD_BYTES)?)?; + record.validate(id)?; + anyhow::ensure!( + record.boot == kernel_boot_id()?, + "stale or incompatible writer launch" + ); + Ok(record) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Real durable lifetime publication boundaries, with test-only injected errors. +enum SaveStep { + /// Failure while creating new exact record bytes. + Write, + /// Failure persisting the staged file contents. + FileSync, + /// Failure atomically replacing the lifetime path. + Publish, + /// Failure persisting removal from the separate staging directory. + StagingSync, + /// Failure persisting final private file metadata/contents. + PublishedSync, + /// Failure persisting the destination lifetime-directory entry. + DirectorySync, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +/// Only fixed classes of writers may obtain a lifetime record. +pub(super) enum WriterRole { + /// A full same-executable update controller whose guardian retains the root's lease. + Controller, + /// The application server, including in-process workers and migrations. + Server, + /// One administration CLI action. + Administrator, + /// One supervised external media-tool invocation. + Media, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +/// Clear completion is published only after all descendants are reaped. +pub(super) enum LifePhase { + /// Intent was fsynced before the guardian could exist. + Launching, + /// A fully initialized guardian may have spawned data writers. + Running, + /// All admitted descendants exited and the completion record was fsynced. + Stopped, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +/// PID alone is insufficient because it can be reused within the same boot. +pub(super) struct ProcessIdentity { + /// Kernel process identifier. + pub pid: i32, + /// Kernel start tick from the same process's proc stat record. + pub start_tick: u64, +} + +impl ProcessIdentity { + /// Capture the calling process identity on the calling long-lived spawner thread. + pub(super) fn current() -> anyhow::Result { + Self::read(rustix::process::getpid().as_raw_nonzero().get()) + } + + /// Read one explicit PID and parse stat after the final command-name parenthesis. + pub(super) fn read(pid: i32) -> anyhow::Result { + anyhow::ensure!(pid > 0_i32, "invalid process identity"); + let stat = fs::read_to_string(format!("/proc/{pid}/stat"))?; + let tail = stat.rsplit_once(')').context("malformed process stat")?.1; + let start_tick = tail + .split_whitespace() + .nth(19) + .context("missing process start tick")? + .parse()?; + Ok(Self { pid, start_tick }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +/// Versioned durable evidence used together with the separate data-writer lease. +pub(super) struct Lifetime { + /// Persisted format compatibility, distinct from the package version. + pub format: u32, + /// Unique admitted invocation, never a browser-selected identifier. + pub id: Uuid, + /// A real changed kernel boot is required to clear uncertain old invocations. + pub boot: Uuid, + /// Fixed kind of possible data writer. + pub role: WriterRole, + /// Whether writer creation may have happened, or exit was proved. + pub phase: LifePhase, + /// Expected creating process and its start identity. + pub parent: ProcessIdentity, + /// Guardian identity, absent while the durable pre-spawn record is pending. + pub guardian: Option, + /// Exact primary child, published before that child may initialize the application. + #[serde(default)] + pub child: Option, +} + +impl Lifetime { + /// Reject malformed changed-boot claims and incompatible identities before admission. + fn validate(&self, id: Uuid) -> anyhow::Result<()> { + anyhow::ensure!( + self.format == FORMAT + && self.id == id + && !id.is_nil() + && !self.boot.is_nil() + && self.parent.pid > 0_i32 + && self.guardian.is_none_or(|guardian| guardian.pid > 0_i32) + && self.child.is_none_or(|child| child.pid > 0_i32) + && (self.phase != LifePhase::Running || self.guardian.is_some()), + "invalid or incompatible writer lifetime record" + ); + Ok(()) + } +} + +/// Read and validate the actual local kernel boot ID; missing information denies recovery. +pub(super) fn kernel_boot_id() -> anyhow::Result { + let bytes = fs::read_to_string("/proc/sys/kernel/random/boot_id")?; + let id = Uuid::parse_str(bytes.trim()).context("kernel boot identity unavailable")?; + anyhow::ensure!(!id.is_nil(), "kernel boot identity is invalid"); + Ok(id) +} + +/// Root-owned sticky shared temp ancestors are safe; all other foreign writes are refused. +pub(super) fn owned_ancestors(path: &Path, uid: u32) -> anyhow::Result<()> { + crate::utils::fs_security::reject_symlink_components(path)?; + for ancestor in path.ancestors() { + let metadata = fs::symlink_metadata(ancestor)?; + let sticky_root = metadata.is_dir() && metadata.uid() == 0 && metadata.mode() & 0o1000 != 0; + anyhow::ensure!( + (metadata.uid() == uid || metadata.uid() == 0) + && (metadata.mode() & 0o022 == 0 || sticky_root), + "same-account path has a foreign or writable ancestor" + ); + } + Ok(()) +} + +/// Create only same-account private directories, rejecting links and foreign ownership. +fn private_directory(path: &Path, uid: u32) -> anyhow::Result<()> { + if !path.try_exists()? { + fs::create_dir(path)?; + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + File::open(path)?.sync_all()?; + File::open(path.parent().context("private directory needs a parent")?)?.sync_all()?; + } + owned_ancestors(path, uid)?; + let metadata = fs::symlink_metadata(path)?; + anyhow::ensure!( + metadata.is_dir() && metadata.uid() == uid && metadata.mode().trailing_zeros() >= 6, + "updater state must be private to its owning account" + ); + Ok(()) +} + +/// Stable close-only lease file; never explicit-unlock an inherited duplicate. +fn private_file(path: &Path, uid: u32) -> anyhow::Result { + let file = File::options() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .custom_flags(i32::try_from(rustix::fs::OFlags::NOFOLLOW.bits())?) + .open(path)?; + let metadata = file.metadata()?; + anyhow::ensure!( + metadata.is_file() + && metadata.uid() == uid + && metadata.nlink() == 1 + && metadata.mode().trailing_zeros() >= 6, + "invalid private writer lease" + ); + File::open(path.parent().context("lease needs a parent")?)?.sync_all()?; + Ok(file) +} + +/// Bounded no-follow reads cannot turn a journal into a link or pipe operation. +pub(super) fn read_private(path: &Path, uid: u32, limit: u64) -> anyhow::Result> { + for _attempt in 0_u8..4_u8 { + if let Some(bytes) = read_private_once(path, uid, limit)? { + return Ok(bytes); + } + } + anyhow::bail!("private writer record changed repeatedly during validation") +} + +/// An atomic rename can unlink the opened previous inode before its metadata is read. +/// Retry that observation; linked, foreign or permissive files remain immediate failures. +fn read_private_once(path: &Path, uid: u32, limit: u64) -> anyhow::Result>> { + let file = File::options() + .read(true) + .custom_flags(i32::try_from( + (rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::NONBLOCK).bits(), + )?) + .open(path)?; + let metadata = file.metadata()?; + anyhow::ensure!( + metadata.is_file() + && metadata.uid() == uid + && metadata.nlink() <= 1 + && metadata.mode().trailing_zeros() >= 6 + && metadata.len() <= limit, + "invalid private writer record" + ); + if metadata.nlink() == 0 { + return Ok(None); + } + let mut bytes = Vec::new(); + let _bytes_read = file + .take( + limit + .checked_add(1) + .context("writer record bound overflow")?, + ) + .read_to_end(&mut bytes)?; + anyhow::ensure!( + u64::try_from(bytes.len())? <= limit, + "writer record exceeds bound" + ); + Ok(Some(bytes)) +} + +#[cfg(test)] +/// Kernel-independent journal tests and real Linux close-only descriptor checks. +mod tests { + use super::*; + + /// Prepare disposable roots using the ordinary invoking test account. + fn fixture() -> anyhow::Result<(tempfile::TempDir, Layout)> { + let root = tempfile::tempdir()?; + let layout = Layout::prepare(&root.path().canonicalize()?)?; + Ok((root, layout)) + } + + /// Pending/running records require a real changed boot or durable stopped proof. + #[test] + fn lifetime_uncertainty_never_uses_pid_absence_as_recovery_proof() -> anyhow::Result<()> { + if rustix::process::getuid().is_root() { + let root = tempfile::tempdir()?; + anyhow::ensure!(Layout::prepare(root.path()).is_err()); + return Ok(()); + } + let (_root, layout) = fixture()?; + anyhow::ensure!(layout.data.is_dir() && layout.install.is_dir()); + let boot = kernel_boot_id()?; + let _admission = layout.admission()?; + anyhow::ensure!(layout.admission().is_err()); + for role in [ + WriterRole::Server, + WriterRole::Administrator, + WriterRole::Media, + ] { + let mut record = layout.launch(role)?; + anyhow::ensure!(layout.lifetime(record.id)?.phase == LifePhase::Launching); + anyhow::ensure!(layout.prove_quiescence(boot).is_err()); + record.phase = LifePhase::Running; + record.guardian = Some(ProcessIdentity { + pid: i32::MAX, + start_tick: u64::MAX, + }); + layout.save_lifetime(&record)?; + anyhow::ensure!( + layout.exclusive_quiescent().is_err(), + "missing PID must not prove descendant exit" + ); + layout.prove_quiescence(Uuid::new_v4())?; + record.phase = LifePhase::Stopped; + layout.save_lifetime(&record)?; + drop(layout.exclusive_quiescent()?); + } + Ok(()) + } + + /// Closing one inherited duplicate preserves exclusion; the separate gate survives conversion. + #[test] + fn duplicate_close_preserves_lock_and_admission_survives_conversion() -> anyhow::Result<()> { + if rustix::process::getuid().is_root() { + return Ok(()); + } + let (_root, layout) = fixture()?; + let _admission = layout.admission()?; + let exclusive = layout.exclusive_quiescent()?; + let inode = exclusive.metadata()?.ino(); + drop(exclusive.try_clone()?); + anyhow::ensure!(layout.shared_writer().is_err()); + anyhow::ensure!(layout.exclusive_quiescent().is_err()); + for _ in 0_u16..100_u16 { + exclusive.lock_shared()?; + let independent = layout.shared_writer()?; + anyhow::ensure!(layout.admission().is_err()); + anyhow::ensure!(layout.exclusive_quiescent().is_err()); + drop(independent); + exclusive.lock()?; + anyhow::ensure!(layout.admission().is_err()); + } + anyhow::ensure!(exclusive.metadata()?.ino() == inode); + drop(exclusive); + drop(layout.exclusive_quiescent()?); + Ok(()) + } + + /// Corrupt, foreign-format and linked lifetime data cannot authorize recovery. + #[test] + fn malformed_or_linked_lifetime_fails_closed() -> anyhow::Result<()> { + if rustix::process::getuid().is_root() { + return Ok(()); + } + let (_root, layout) = fixture()?; + let mut record = layout.launch(WriterRole::Server)?; + record.phase = LifePhase::Stopped; + record.format += 1; + layout.save_lifetime(&record)?; + anyhow::ensure!(layout.prove_quiescence(kernel_boot_id()?).is_err()); + record.format = FORMAT; + layout.save_lifetime(&record)?; + let path = layout + .state + .join("writers") + .join(format!("{}.json", record.id)); + let original = layout.state.join("original.json"); + fs::rename(&path, &original)?; + std::os::unix::fs::symlink(&original, &path)?; + anyhow::ensure!(layout.lifetime(record.id).is_err()); + anyhow::ensure!(layout.prove_quiescence(Uuid::new_v4()).is_err()); + Ok(()) + } + + /// ENOSPC/rename/fsync failures cannot turn launched/running uncertainty into admission. + #[test] + fn failed_lifetime_publication_never_clears_uncertainty() -> anyhow::Result<()> { + if rustix::process::getuid().is_root() { + return Ok(()); + } + for failed in [ + SaveStep::Write, + SaveStep::FileSync, + SaveStep::Publish, + SaveStep::StagingSync, + SaveStep::PublishedSync, + SaveStep::DirectorySync, + ] { + let (_root, layout) = fixture()?; + let mut record = layout.launch(WriterRole::Server)?; + record.phase = LifePhase::Running; + record.guardian = Some(ProcessIdentity::current()?); + let result = layout.save_lifetime_with(&record, |step| { + anyhow::ensure!(step != failed, std::io::Error::from_raw_os_error(28)); + Ok(()) + }); + anyhow::ensure!(result.is_err(), "injected publication error was missed"); + anyhow::ensure!( + layout.exclusive_quiescent().is_err(), + "failed publication allowed recovery" + ); + let observed = layout.lifetime(record.id)?; + anyhow::ensure!(matches!( + observed.phase, + LifePhase::Launching | LifePhase::Running + )); + record.phase = LifePhase::Stopped; + layout.save_lifetime(&record)?; + drop(layout.exclusive_quiescent()?); + } + let (_root, layout) = fixture()?; + let mut record = layout.launch(WriterRole::Server)?; + record.boot = Uuid::nil(); + record.phase = LifePhase::Stopped; + layout.save_lifetime(&record)?; + anyhow::ensure!( + layout.prove_quiescence(Uuid::new_v4()).is_err(), + "malformed boot identity allowed recovery" + ); + Ok(()) + } + + /// Disposable competing process; it performs no database/application writes. + #[test] + #[ignore = "internal concurrent-admission subprocess entry"] + fn admission_competitor_entry() -> anyhow::Result<()> { + let layout = Layout::prepare(Path::new(&std::env::var("RUSTCHAN_ADMISSION_TEST")?))?; + fs::write(layout.state.join("competitor-ready"), b"ready")?; + let deadline = std::time::Instant::now(); + while !layout.state.join("competitor-start").try_exists()? { + anyhow::ensure!( + deadline.elapsed() < std::time::Duration::from_secs(10), + "admission fixture did not start" + ); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + for _ in 0_u16..200_u16 { + anyhow::ensure!( + layout.admission().is_err(), + "competing process entered during lock conversion" + ); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + fs::write( + layout.state.join("competitor-result"), + b"200 attempts; 0 admissions", + )?; + Ok(()) + } + + /// Real concurrent process startup cannot enter while the parent converts its shared OFD. + #[test] + fn independent_process_is_excluded_through_writer_lock_conversions() -> anyhow::Result<()> { + if rustix::process::getuid().is_root() { + return Ok(()); + } + let (_root, layout) = fixture()?; + let _admission = layout.admission()?; + let writer = layout.exclusive_quiescent()?; + let inode = writer.metadata()?.ino(); + let mut competitor = std::process::Command::new(std::env::current_exe()?) + .args([ + "--exact", + "updates::lifecycle::tests::admission_competitor_entry", + "--ignored", + "--nocapture", + ]) + .env("RUSTCHAN_ADMISSION_TEST", &layout.data) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::inherit()) + .spawn()?; + let deadline = std::time::Instant::now(); + while !layout.state.join("competitor-ready").try_exists()? { + anyhow::ensure!( + deadline.elapsed() < std::time::Duration::from_secs(10), + "competing process did not become ready" + ); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + fs::write(layout.state.join("competitor-start"), b"start")?; + for _ in 0_u16..100_u16 { + writer.lock_shared()?; + std::thread::sleep(std::time::Duration::from_millis(1)); + writer.lock()?; + std::thread::sleep(std::time::Duration::from_millis(1)); + } + anyhow::ensure!( + competitor.wait()?.success(), + "competing admission process failed" + ); + anyhow::ensure!( + fs::read(layout.state.join("competitor-result"))? == b"200 attempts; 0 admissions" + ); + anyhow::ensure!( + writer.metadata()?.ino() == inode, + "writer lease inode was replaced" + ); + Ok(()) + } +} diff --git a/src/updates/mod.rs b/src/updates/mod.rs index 3bb33294..5fe84b21 100644 --- a/src/updates/mod.rs +++ b/src/updates/mod.rs @@ -1,8 +1,29 @@ //! Stable release discovery and the deliberately small local updater protocol. //! //! No request can specify a URL, path, command, or service name. +#[cfg(target_os = "linux")] +/// Retained source-controller preparation before any candidate can be selected. +mod bootstrap; +#[cfg(target_os = "linux")] +/// Bounded kernel-authenticated control frames and close-on-exec lease transfers. +mod control; +#[cfg(target_os = "linux")] +/// Selected same-executable full update controller. +mod controller; +#[cfg(target_os = "linux")] +/// Dedicated main-thread process monitor for the one installed executable. +mod coordinator; /// Restricted local IPC and fixed native service control. mod daemon; +#[cfg(target_os = "linux")] +/// Same-binary ordinary-account process-tree ownership. +mod guardian; +#[cfg(target_os = "linux")] +/// Durable same-account writer admission and process-lifetime recovery. +mod lifecycle; +#[cfg(target_os = "linux")] +/// Ordinary foreground same-binary writer admission. +mod native; /// Official stable release discovery and cryptographic verification. mod release; #[cfg(unix)] @@ -11,11 +32,28 @@ mod restart; #[cfg(unix)] mod snapshot; mod transaction; +/// Fixed official public verification identity included in source builds. +mod trust; pub use daemon::run; pub use release::{discover, platform_target, Discovery, Release}; pub use transaction::{BackupInfo, Operation, Phase, Status}; +/// Verify native Linux release discovery with the embedded official public key. +/// Other platforms retain their platform-specific check-only discovery. +#[must_use] +pub fn discover_official(current: &str) -> Discovery { + if !cfg!(target_os = "linux") { + return discover(current, None); + } + match trust::official_public_key() { + Ok(key) => discover(current, Some(&key)), + Err(error) => { + Discovery::UnableToCheck(format!("Invalid embedded release identity: {error}")) + } + } +} + use serde::{Deserialize, Serialize}; use std::time::Duration; @@ -24,7 +62,94 @@ pub const SOCKET: &str = "/run/rustchan-updater/control.sock"; /// Version of this updater/application build. pub const VERSION: &str = env!("CARGO_PKG_VERSION"); -#[derive(Debug, Serialize, Deserialize)] +/// Handle a kernel-parent authenticated internal Linux role before configuration side effects. +/// +/// # Errors +/// Rejects malformed, stale or unauthorized internal invocation context. +#[cfg_attr( + not(target_os = "linux"), + expect( + clippy::missing_const_for_fn, + reason = "the shared entry API invokes non-const kernel operations on Linux" + ) +)] +pub fn native_entry() -> anyhow::Result { + #[cfg(target_os = "linux")] + { + native::entry() + } + #[cfg(not(target_os = "linux"))] + { + Ok(false) + } +} + +/// Preserve the admitted data binding when executing a retained same-binary copy. +#[must_use] +#[cfg_attr( + not(target_os = "linux"), + expect( + clippy::missing_const_for_fn, + reason = "the shared data binding is populated by non-const Linux admission" + ) +)] +pub fn admitted_data_dir() -> Option<&'static std::path::Path> { + #[cfg(target_os = "linux")] + { + native::data_dir() + } + #[cfg(not(target_os = "linux"))] + { + None + } +} + +/// Admit ordinary Linux application/CLI writers before database or filesystem initialization. +/// +/// # Errors +/// Rejects concurrent recovery, uncertain descendant lifetime or unsafe data ownership. +#[cfg_attr( + not(target_os = "linux"), + expect( + clippy::missing_const_for_fn, + reason = "the shared launcher API performs non-const kernel admission on Linux" + ) +)] +pub fn supervise_native(administrator: bool) -> anyhow::Result { + #[cfg(target_os = "linux")] + { + native::before_startup(administrator) + } + #[cfg(not(target_os = "linux"))] + { + let _: bool = administrator; + Ok(false) + } +} + +/// Bind normal application initialization to its admitted writer guardian. +/// +/// # Errors +/// Rejects an unavailable or mismatched admitted parent. +#[cfg_attr( + not(target_os = "linux"), + expect( + clippy::unused_async, + reason = "the shared startup API awaits an authenticated parent acknowledgment on Linux" + ) +)] +pub async fn writer_initialized() -> anyhow::Result<()> { + #[cfg(target_os = "linux")] + { + native::initialized().await + } + #[cfg(not(target_os = "linux"))] + { + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields, tag = "operation", rename_all = "snake_case")] /// Closed operations accepted from the authorized local web identity. pub enum Request { @@ -76,6 +201,10 @@ pub struct Reply { /// cannot select arbitrary socket endpoints. #[must_use] pub fn managed() -> bool { + #[cfg(target_os = "linux")] + if native::automatic() { + return true; + } cfg!(target_os = "linux") && std::env::var("RUSTCHAN_MANAGED").is_ok_and(|value| value == "1") && !container_managed() @@ -94,6 +223,10 @@ pub fn container_managed() -> bool { /// # Errors /// Rejects unmanaged deployments, unavailable IPC, oversized replies or malformed protocol data. pub async fn request(request: &Request) -> anyhow::Result { + #[cfg(target_os = "linux")] + if native::automatic() { + return controller::request(request).await; + } anyhow::ensure!( managed(), "installation is managed by the deployment environment" @@ -101,6 +234,25 @@ pub async fn request(request: &Request) -> anyhow::Result { request_to(std::path::Path::new(SOCKET), request).await } +/// All external requests stay closed during source trials, including GET side effects. +pub async fn requests_allowed() -> bool { + #[cfg(target_os = "linux")] + if native::automatic() { + let Ok(context) = native::application_context() else { + return false; + }; + return tokio::task::spawn_blocking(move || { + coordinator::call(&context, coordinator::Request::PublicAdmission).is_ok() + }) + .await + .is_ok_and(|allowed| allowed); + } + if managed() { + return mutations_allowed(Some(std::path::Path::new(SOCKET))).await; + } + true +} + /// The application constructor supplies only the fixed deployment socket. /// /// Kept internal so temporary IPC sockets can test authorization without diff --git a/src/updates/native.rs b/src/updates/native.rs new file mode 100644 index 00000000..072c30e6 --- /dev/null +++ b/src/updates/native.rs @@ -0,0 +1,599 @@ +//! Same-executable Linux writer entry and ordinary foreground supervision. +//! +//! Internal roles are kernel-parent authenticated before any application side +//! effects. A parent keeps stdin/stdout/stderr intact and transfers only the +//! fixed close-on-exec writer lease over private abstract IPC. + +use super::{ + control, guardian, + lifecycle::{self, Layout, LifePhase, ProcessIdentity, WriterRole}, +}; +use anyhow::Context as _; +use serde::{Deserialize, Serialize}; +use std::{ + fs::{self, File}, + io::Write as _, + os::unix::fs::DirBuilderExt as _, + os::unix::net::{UnixListener, UnixStream}, + process::Command, + sync::OnceLock, + time::{Duration, Instant}, +}; +use uuid::Uuid; + +/// Startup-only context; no browser operation can construct an internal role. +pub(super) const INTERNAL: &str = "RUSTCHAN_INTERNAL_WRITER"; +/// Bound environment/context and private monitor metadata independently of application data. +const MAX_CONTEXT: u64 = 16 * 1024; +/// Internal spawn/handshake deadline. +const START_TIMEOUT: Duration = Duration::from_secs(5); +/// Keep the signal/spawn thread alive while checking IPC and owned process completion. +const POLL_INTERVAL: Duration = Duration::from_millis(25); +/// Installed only after authenticating an admitted application process. +static APPLICATION: OnceLock = OnceLock::new(); + +/// An authenticated internal application retains its original fixed data root. +pub(super) fn data_dir() -> Option<&'static std::path::Path> { + APPLICATION.get().map(|context| context.data.as_path()) +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +/// Closed same-binary execution roles. +pub(super) enum Role { + /// Own and reap the entire admitted writer subtree. + Guardian, + /// Execute the original fixed server/administration arguments. + Application, + /// Execute a selected full update controller without parsing public CLI arguments. + Controller, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +/// Parent-created context bound to one durable launch, never an HTTP-selected path. +pub(super) struct Context { + /// Fixed internal role. + pub role: Role, + /// Canonical data directory selected by the original launcher. + pub data: std::path::PathBuf, + /// Durable lifetime identifier and private handshake binding. + pub id: Uuid, + /// Actual creating process identity. + pub parent: ProcessIdentity, + /// The long-lived monitor identity. + pub monitor: ProcessIdentity, + /// Private abstract endpoint created before spawn. + pub endpoint: String, + /// A source-owned root provides built-in control; protected manual invocations do not. + pub automatic: bool, + /// Startup-only launcher port override, never browser-selected. + pub port: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +/// Private cooperative admission metadata for independent administration CLI callers. +pub(super) struct Monitor { + /// Version of the persisted small monitor contract. + pub format: u32, + /// Real kernel boot identity, never an environment override. + pub boot: Uuid, + /// Exact owning process start identity. + pub identity: ProcessIdentity, + /// Private abstract endpoint, authenticated against identity above. + pub endpoint: String, + /// Running package compatibility for independent CLI operations. + pub version: String, + /// Detect accidental same-version source rebuilds before CLI writer admission. + pub program_sha256: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields, tag = "operation", rename_all = "snake_case")] +/// This writer-admission protocol cannot supply commands or arbitrary paths. +pub(super) enum Message { + /// Closed operations of the process monitor, authenticated to exact primaries. + Core { + /// Fixed lifecycle operation, never an arbitrary execution request. + request: super::coordinator::Request, + }, + /// Guardian requests the one already-approved lifetime lease. + Guardian { + /// Parent-issued durable invocation identifier. + id: Uuid, + }, + /// The exact child reports completion of normal application initialization. + Initialized { + /// Parent-issued durable invocation identifier. + id: Uuid, + }, + /// An independent CLI requests a shared lease before spawning its own guardian. + Administrator { + /// Caller-owned durable pre-spawn intent. + id: Uuid, + /// Exact running package compatibility for the CLI. + version: String, + /// Exact invoking CLI executable bytes, bound to the running application. + program_sha256: String, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +/// Closed acknowledgment, with a lease only on successful writer admission. +pub(super) struct Granted { + /// Bind the reply to the exact launch intent. + pub id: Uuid, +} + +/// Register catchable termination before publishing any launch intent. +pub(super) struct Signals { + /// Current-thread runtime keeps the dedicated spawner alive. + pub runtime: tokio::runtime::Runtime, + /// Normal service shutdown. + pub terminate: tokio::signal::unix::Signal, + /// Foreground keyboard shutdown. + pub interrupt: tokio::signal::unix::Signal, +} + +impl Signals { + /// Install listeners before launch/fsync/spawn, closing the normal startup-stop gap. + fn prepare() -> anyhow::Result { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + let entered = runtime.enter(); + let terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?; + let interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?; + drop(entered); + Ok(Self { + runtime, + terminate, + interrupt, + }) + } +} + +/// Handle internal roles before CLI/configuration initialization. +pub(super) fn entry() -> anyhow::Result { + let Some(bytes) = std::env::var_os(INTERNAL) else { + return Ok(false); + }; + let text = bytes + .to_str() + .context("invalid internal context encoding")?; + anyhow::ensure!( + u64::try_from(text.len())? <= MAX_CONTEXT, + "internal context exceeds bound" + ); + let context: Context = serde_json::from_str(text)?; + anyhow::ensure!(!context.id.is_nil(), "invalid internal launch identity"); + // A noncatchable signal covers the pre-handshake interval. A guardian + // installs its catchable TERM listener before replacing this death signal. + guardian::arm_parent(context.parent, rustix::process::Signal::KILL)?; + let layout = Layout::prepare(&context.data)?; + match context.role { + Role::Guardian => { + let mut socket = control::connect(&context.endpoint, layout.uid, context.monitor)?; + control::send(&mut socket, &Message::Guardian { id: context.id }, None)?; + let (granted, lease): (Granted, _) = control::receive(&mut socket, true)?; + anyhow::ensure!( + granted.id == context.id, + "guardian lease belongs to a different launch" + ); + let mut child_context = context.clone(); + child_context.role = if layout.lifetime(context.id)?.role == WriterRole::Controller { + Role::Controller + } else { + Role::Application + }; + child_context.parent = ProcessIdentity::current()?; + let mut command = Command::new(std::env::current_exe()?); + let _arguments = command + .args(std::env::args_os().skip(1)) + .env(INTERNAL, serde_json::to_string(&child_context)?) + .env("RUSTCHAN_SPAWNED", "1"); + let code = guardian::run( + &layout, + context.id, + context.parent, + lease.context("missing guardian lease")?, + &mut command, + )?; + anyhow::ensure!(code == 0, "admitted writer exited unsuccessfully ({code})"); + Ok(true) + } + Role::Application | Role::Controller => { + let started = Instant::now(); + loop { + let record = layout.lifetime(context.id)?; + anyhow::ensure!( + record.phase == LifePhase::Running + && record.guardian == Some(context.parent) + && record.parent == context.monitor, + "application is not owned by its admitted guardian" + ); + if record.child == Some(ProcessIdentity::current()?) { + break; + } + anyhow::ensure!( + started.elapsed() < START_TIMEOUT, + "guardian did not register this application" + ); + std::thread::sleep(POLL_INTERVAL); + } + if matches!(context.role, Role::Controller) { + super::controller::run(&context)?; + return Ok(true); + } + APPLICATION.set(context).map_err(|_rejected_context| { + anyhow::anyhow!("application admission was already registered") + })?; + Ok(false) + } + } +} + +/// Launch a durable guardian before logs, configuration generation, migration or CLI writes. +#[expect( + clippy::too_many_lines, + reason = "cooperative admission, cancellation and bootstrap must share the pre-write authorization boundary" +)] +pub(super) fn before_startup(administrator: bool) -> anyhow::Result { + if let Some(context) = APPLICATION.get() { + anyhow::ensure!( + crate::config::data_dir().canonicalize()? == context.data, + "admitted application data directory changed" + ); + return Ok(false); + } + if super::container_managed() || super::managed() || rustix::process::getuid().is_root() { + return Ok(false); + } + anyhow::ensure!( + guardian::ordinary_account()?, + "native supervision requires an ordinary account without elevated capabilities" + ); + anyhow::ensure!( + rustix::thread::gettid() == rustix::process::getpid(), + "native supervision must run on the long-lived main spawner thread" + ); + rustix::thread::set_no_new_privs(true)?; + let signals = Signals::prepare()?; + let data = crate::config::data_dir(); + if !data.try_exists()? { + let mut ancestor = data.as_path(); + while !ancestor.try_exists()? { + ancestor = ancestor.parent().context("data has no existing parent")?; + } + lifecycle::owned_ancestors(ancestor, rustix::process::getuid().as_raw())?; + fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(&data)?; + } + let layout = Layout::prepare(&data)?; + let role = if administrator { + WriterRole::Administrator + } else { + WriterRole::Server + }; + let admission = layout.admission(); + let (admission, lease) = match admission { + Ok(admission) => (Some(admission), layout.exclusive_quiescent()?), + Err(error) if role == WriterRole::Administrator => { + let monitor: Monitor = serde_json::from_slice(&lifecycle::read_private( + &layout.state.join("monitor.json"), + layout.uid, + MAX_CONTEXT, + )?)?; + anyhow::ensure!( + monitor.format == lifecycle::FORMAT + && monitor.boot == lifecycle::kernel_boot_id()? + && monitor.version == super::VERSION, + "active monitor is incompatible; stop RustChan before using this CLI" + ); + let program_sha256 = super::bootstrap::digest(&std::env::current_exe()?)?; + anyhow::ensure!(program_sha256 == monitor.program_sha256, + "administration CLI differs from the running source build; use its matching executable"); + let mut socket = control::connect(&monitor.endpoint, layout.uid, monitor.identity) + .with_context(|| format!("active writer admission is unavailable: {error}"))?; + let record = layout.launch(role)?; + let granted = (|| { + control::send( + &mut socket, + &Message::Administrator { + id: record.id, + version: super::VERSION.to_owned(), + program_sha256, + }, + None, + )?; + let (reply, lease): (Granted, _) = control::receive(&mut socket, true)?; + anyhow::ensure!( + reply.id == record.id, + "administration lease belongs to another launch" + ); + lease.context("missing administration lease") + })(); + match granted { + Ok(lease) => return supervise(&layout, record, None, lease, signals), + Err(grant_error) => { + // This process has not called spawn. No descendant could + // have been admitted, even if the grant acknowledgment was lost. + let mut cancelled = record; + cancelled.phase = LifePhase::Stopped; + layout.save_lifetime(&cancelled)?; + return Err(grant_error); + } + } + } + Err(error) => return Err(error), + }; + if role == WriterRole::Server { + // Retain the exact local executable before permitting application-side + // changes. This preflight alone does not open update installation. + if super::bootstrap::prepare(&layout)?.is_some() { + return super::coordinator::run( + &layout, + admission.context("source root must own admission")?, + lease, + signals, + ); + } + } else { + super::bootstrap::admit_cli(&layout)?; + } + let record = layout.launch(role)?; + supervise(&layout, record, admission, lease, signals) +} + +/// Own one foreground invocation on the same long-lived signal/spawn thread. +fn supervise( + layout: &Layout, + mut record: lifecycle::Lifetime, + admission: Option, + lease: File, + signals: Signals, +) -> anyhow::Result { + let endpoint = format!("rustchan-writer-{}", record.id); + let listener = control::listen(&endpoint)?; + listener.set_nonblocking(true)?; + let context = Context { + role: Role::Guardian, + data: layout.data.clone(), + id: record.id, + parent: ProcessIdentity::current()?, + monitor: ProcessIdentity::current()?, + endpoint: endpoint.clone(), + automatic: false, + port: crate::config::launcher_port(), + }; + rustix::process::set_child_subreaper(Some(rustix::process::Pid::INIT))?; + let mut command = Command::new(std::env::current_exe()?); + let _arguments = command + .args(std::env::args_os().skip(1)) + .env(INTERNAL, serde_json::to_string(&context)?) + .env("RUSTCHAN_SPAWNED", "1"); + let spawned = command.spawn(); + let mut child = match spawned { + Ok(child) => child, + Err(error) => { + record.phase = LifePhase::Stopped; + layout.save_lifetime(&record)?; + return Err(error.into()); + } + }; + let identity = ProcessIdentity::read(i32::try_from(child.id())?)?; + let owns_admission = admission.is_some(); + if owns_admission { + publish_monitor( + layout, + &Monitor { + format: lifecycle::FORMAT, + boot: lifecycle::kernel_boot_id()?, + identity: context.monitor, + endpoint, + version: super::VERSION.to_owned(), + program_sha256: super::bootstrap::digest(&std::env::current_exe()?)?, + }, + )?; + } + let Signals { + runtime, + mut terminate, + mut interrupt, + } = signals; + let outcome = runtime.block_on(async { + let mut ready = false; + let mut stopping = false; + loop { + if let Some(status) = child.try_wait()? { + // The monitor is itself a subreaper. If its guardian was lost, + // reap all its owned/adopted descendants before clearing intent. + guardian::drain_remaining(terminate, interrupt).await?; + let mut stopped = layout.lifetime(record.id)?; + anyhow::ensure!( + stopped.parent == context.monitor, + "writer launch parent changed" + ); + stopped.phase = LifePhase::Stopped; + layout.save_lifetime(&stopped)?; + anyhow::ensure!( + status.success(), + "RustChan writer guardian exited unsuccessfully" + ); + return Ok::<_, anyhow::Error>(()); + } + let accepted = accept(&listener)?; + if let Some(mut socket) = accepted { + let handled = handle( + layout, + &record, + identity, + &lease, + &mut socket, + &mut ready, + stopping, + ); + if let Err(error) = handled { + tracing::warn!(%error, "writer admission rejected"); + } + } + tokio::select! { + _signal = terminate.recv() => { stopping = true; signal(identity)?; } + _signal = interrupt.recv() => { stopping = true; signal(identity)?; } + () = tokio::time::sleep(POLL_INTERVAL) => {} + } + } + }); + if owns_admission { + fs::remove_file(layout.state.join("monitor.json"))?; + File::open(&layout.state)?.sync_all()?; + } + drop(lease); + drop(admission); + outcome?; + Ok(true) +} + +/// Nonblocking accept keeps the main spawner responsive to lifecycle signals. +pub(super) fn accept(listener: &UnixListener) -> anyhow::Result> { + match listener.accept() { + Ok((socket, _address)) => Ok(Some(socket)), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => Ok(None), + Err(error) => Err(error.into()), + } +} + +/// Inspect only bounded typed messages after authenticating the kernel peer UID. +fn handle( + layout: &Layout, + record: &lifecycle::Lifetime, + guardian: ProcessIdentity, + lease: &File, + socket: &mut UnixStream, + ready: &mut bool, + stopping: bool, +) -> anyhow::Result<()> { + let peer = rustix::net::sockopt::socket_peercred(&*socket)?; + anyhow::ensure!( + peer.uid.as_raw() == layout.uid, + "writer admission has a different owner" + ); + let identity = ProcessIdentity::read(peer.pid.as_raw_nonzero().get())?; + let (message, _unexpected): (Message, _) = control::receive(socket, false)?; + match message { + Message::Core { request: _ } => { + anyhow::bail!("automatic control is unavailable in manual mode") + } + Message::Guardian { id } => { + control::authorize(socket, layout.uid, guardian)?; + anyhow::ensure!( + id == record.id && !stopping, + "guardian launch is no longer permitted" + ); + control::send(socket, &Granted { id }, Some(lease)) + } + Message::Initialized { id } => { + let current = layout.lifetime(record.id)?; + anyhow::ensure!( + id == record.id + && current.child == Some(identity) + && current.guardian == Some(guardian) + && !stopping, + "initialization belongs to a different writer" + ); + lease.lock_shared()?; + *ready = record.role == WriterRole::Server; + control::send(socket, &Granted { id }, None) + } + Message::Administrator { + id, + version, + program_sha256, + } => { + anyhow::ensure!( + *ready && !stopping && version == super::VERSION, + "administration is unavailable during startup or shutdown" + ); + let monitor: Monitor = serde_json::from_slice(&lifecycle::read_private( + &layout.state.join("monitor.json"), + layout.uid, + MAX_CONTEXT, + )?)?; + anyhow::ensure!( + monitor.program_sha256 == program_sha256, + "administration CLI bytes differ from the admitted source build" + ); + let intent = layout.lifetime(id)?; + anyhow::ensure!( + intent.role == WriterRole::Administrator + && intent.phase == LifePhase::Launching + && intent.parent == identity + && intent.guardian.is_none() + && intent.child.is_none(), + "invalid administration launch intent" + ); + let shared = layout.shared_writer()?; + control::send(socket, &Granted { id }, Some(&shared)) + } + } +} + +/// Signal only the still-owned, unreaped guardian PID. +pub(super) fn signal(identity: ProcessIdentity) -> anyhow::Result<()> { + let pid = rustix::process::Pid::from_raw(identity.pid).context("invalid guardian PID")?; + match rustix::process::kill_process(pid, rustix::process::Signal::TERM) { + Ok(()) | Err(rustix::io::Errno::SRCH) => Ok(()), + Err(error) => Err(error.into()), + } +} + +/// Persist private monitor metadata without replacing any lock inode. +pub(super) fn publish_monitor(layout: &Layout, monitor: &Monitor) -> anyhow::Result<()> { + let path = layout.state.join("monitor.json"); + if path.try_exists()? { + drop(lifecycle::read_private(&path, layout.uid, MAX_CONTEXT)?); + } + let mut temporary = tempfile::NamedTempFile::new_in(layout.state.join("journal-staging"))?; + temporary.write_all(&serde_json::to_vec(monitor)?)?; + temporary.as_file().sync_all()?; + drop(temporary.persist(path).map_err(|error| error.error)?); + File::open(layout.state.join("journal-staging"))?.sync_all()?; + File::open(&layout.state)?.sync_all()?; + Ok(()) +} + +/// Report real listener/database initialization through the admitted parent channel. +pub(super) async fn initialized() -> anyhow::Result<()> { + let Some(context) = APPLICATION.get().cloned() else { + return Ok(()); + }; + tokio::task::spawn_blocking(move || { + let layout = Layout::prepare(&context.data)?; + let mut socket = control::connect(&context.endpoint, layout.uid, context.monitor)?; + control::send(&mut socket, &Message::Initialized { id: context.id }, None)?; + let (granted, _unexpected): (Granted, _) = control::receive(&mut socket, false)?; + anyhow::ensure!( + granted.id == context.id, + "initialization acknowledgment belongs to another writer" + ); + Ok::<_, anyhow::Error>(()) + }) + .await? +} + +/// Only an admitted application can use built-in source control. +pub(super) fn automatic() -> bool { + APPLICATION.get().is_some_and(|context| context.automatic) +} + +/// Get the authenticated fixed context for application/controller IPC. +pub(super) fn application_context() -> anyhow::Result { + APPLICATION + .get() + .cloned() + .context("application has no admitted source root") +} diff --git a/src/updates/restart.rs b/src/updates/restart.rs index de6b456c..0608fa87 100644 --- a/src/updates/restart.rs +++ b/src/updates/restart.rs @@ -112,6 +112,7 @@ impl Engine { self.save(status, Phase::FailedManualIntervention, "RustChan did not stop cleanly. Configuration was not replaced; operator recovery is required.")?; return Ok(()); } + let mut committed = false; let trial = (|| { self.save( status, @@ -135,14 +136,22 @@ impl Engine { running.instance != previous && running.digest.as_bytes() == expected, "replacement did not initialize the requested settings" ); + service.prepare_commit(&status.installed)?; self.save( status, Phase::Succeeded, "RustChan restarted; saved settings passed readiness verification.", )?; - store.observe(running.instance, &running.digest, true) + committed = true; + store.observe(running.instance, &running.digest, true)?; + service.commit() })(); if let Err(error) = trial { + if committed { + // Terminal commit selects the healthy replacement. Losing a + // final acknowledgment cannot authorize restoration beneath it. + return Err(error); + } tracing::error!(%error, "settings restart failed; recovering known-good configuration"); self.recover_settings_restart(status, service)?; } @@ -184,6 +193,7 @@ impl Engine { running.digest == expected, "recovery configuration did not initialize" ); + service.prepare_commit(&status.installed)?; self.save( status, Phase::RolledBack, @@ -193,7 +203,8 @@ impl Engine { if let Err(error) = recovery { tracing::error!(%error, "settings recovery failed"); self.save(status, Phase::FailedManualIntervention, "Settings restart and recovery failed. Last healthy configuration is retained; operator recovery is required.")?; + return Ok(()); } - Ok(()) + service.commit() } } diff --git a/src/updates/transaction.rs b/src/updates/transaction.rs index 536f5acf..7c522de4 100644 --- a/src/updates/transaction.rs +++ b/src/updates/transaction.rs @@ -236,7 +236,7 @@ pub(super) mod native { /// Only local UID authorized to send web operations. pub web_uid: u32, /// Root-owned independently trusted `Ed25519` public key file. - pub public_key: PathBuf, + pub public_key: Option, /// Bounded retained pre-upgrade snapshot count, at least two. pub retention: usize, } @@ -248,6 +248,34 @@ pub(super) mod native { pub config: Config, /// Trusted 32-byte `Ed25519` verification key. pub key: Vec, + /// Explicit authority model; same-account operation adds no privilege boundary. + pub ownership: Ownership, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + /// Keep protected system installations distinct from cooperative source ownership. + pub(in crate::updates) enum Ownership { + /// Existing protected installation/state belong to a different account. + Separated, + /// The ordinary invoking account already owns executable, state and data. + #[cfg(target_os = "linux")] + SameAccount, + } + + /// Same-account mode is Linux-only and cannot acquire another user's authority. + #[cfg(target_os = "linux")] + fn same_account_path(path: &Path, uid: u32) -> anyhow::Result<()> { + use std::os::unix::fs::MetadataExt as _; + anyhow::ensure!( + uid != 0 && rustix::process::getuid().as_raw() == uid, + "automatic source updates require the existing ordinary account" + ); + super::super::lifecycle::owned_ancestors(path, uid)?; + anyhow::ensure!( + fs::symlink_metadata(path)?.uid() == uid, + "source installation/state has a different owner" + ); + Ok(()) } /// Bounded artifact fetch seam for offline transaction fault tests. @@ -282,10 +310,20 @@ pub(super) mod native { fn health_instance(&self, version: &str, _previous: Uuid) -> anyhow::Result<()> { self.health(version, crate::db::baseline_schema_version()) } + /// Initialize the selected full controller before a durable software/data commit. + fn prepare_commit(&self, _version: &str) -> anyhow::Result<()> { + Ok(()) + } + /// Open public admission only after the complete terminal journal is durable. + /// An error may mean the acknowledgment was lost after admission opened; + /// callers must preserve committed state instead of rolling it back. + fn commit(&self) -> anyhow::Result<()> { + Ok(()) + } } /// Publish exact bytes via same-filesystem rename after file and directory fsync. - pub(super) fn atomic_write(path: &Path, bytes: &[u8]) -> anyhow::Result<()> { + pub(in crate::updates) fn atomic_write(path: &Path, bytes: &[u8]) -> anyhow::Result<()> { let parent = path.parent().context("missing managed parent directory")?; let mut staged = tempfile::NamedTempFile::new_in(parent)?; staged.write_all(bytes)?; @@ -354,15 +392,23 @@ pub(super) mod native { impl Engine { /// Verify managed layout, permissions and rollback retention constraints. pub(in crate::updates) fn validate(&self) -> anyhow::Result<()> { + self.validate_layout()?; + self.current_version().map(|_operation_summary| ())?; + Ok(()) + } + /// Recovery validates authority independently of a possibly broken trial pointer. + pub(in crate::updates) fn validate_layout(&self) -> anyhow::Result<()> { for path in [ &self.config.install_dir, &self.config.state_dir, &self.config.data_dir, &self.config.settings_path, - &self.config.public_key, ] { no_symlink_ancestors(path)?; } + if let Some(path) = self.config.public_key.as_ref() { + no_symlink_ancestors(path)?; + } anyhow::ensure!( self.config.retention >= 2 && self.config.retention <= 20 @@ -374,16 +420,46 @@ pub(super) mod native { { use std::os::unix::fs::MetadataExt as _; for path in [&self.config.install_dir, &self.config.state_dir] { - protected_ancestors(path, self.config.web_uid)?; let metadata = fs::metadata(path)?; - anyhow::ensure!( - metadata.uid() != self.config.web_uid && metadata.mode() & 0o022 == 0, - "web account must not own or write updater installation/state" - ); + match self.ownership { + Ownership::Separated => { + protected_ancestors(path, self.config.web_uid)?; + anyhow::ensure!( + metadata.uid() != self.config.web_uid + && metadata.mode() & 0o022 == 0, + "web account must not own or write updater installation/state" + ); + anyhow::ensure!( + self.config.public_key.is_some(), + "separated deployment requires its trusted key file" + ); + } + #[cfg(target_os = "linux")] + Ownership::SameAccount => { + same_account_path(path, self.config.web_uid)?; + anyhow::ensure!( + metadata.mode() & 0o022 == 0, + "source update resources must not be writable by other accounts" + ); + anyhow::ensure!(self.config.public_key.is_none() + && self.key == super::super::trust::official_public_key()?, + "source updates require the embedded official verification identity"); + } + } } } plain(&self.config.install_dir.join("versions"), true)?; - protected_ancestors(&self.config.state_dir.join("backups"), self.config.web_uid)?; + match self.ownership { + Ownership::Separated => protected_ancestors( + &self.config.state_dir.join("backups"), + self.config.web_uid, + )?, + #[cfg(target_os = "linux")] + Ownership::SameAccount => { + same_account_path(&self.config.state_dir.join("backups"), self.config.web_uid)?; + same_account_path(&self.config.data_dir, self.config.web_uid)?; + } + } anyhow::ensure!( self.config.web_uid != 0, "the RustChan web account must not be root" @@ -392,7 +468,6 @@ pub(super) mod native { self.config.settings_path == self.config.data_dir.join("settings.toml"), "managed configuration must be data/settings.toml" ); - self.current_version().map(|_operation_summary| ())?; Ok(()) } /// Validate the relative active link and immutable executable ownership. @@ -430,10 +505,17 @@ pub(super) mod native { ] { let metadata = fs::symlink_metadata(path)?; anyhow::ensure!( - metadata.uid() != self.config.web_uid - && (metadata.uid() == 0 || metadata.uid() == owner) + (match self.ownership { + Ownership::Separated => metadata.uid() != self.config.web_uid, + #[cfg(target_os = "linux")] + Ownership::SameAccount => metadata.uid() == self.config.web_uid, + }) && (metadata.uid() == 0 || metadata.uid() == owner) && metadata.mode() & 0o7022 == 0 - && metadata.mode() & 0o005 == 0o005 + && match self.ownership { + Ownership::Separated => metadata.mode() & 0o005 == 0o005, + #[cfg(target_os = "linux")] + Ownership::SameAccount => metadata.mode() & 0o500 == 0o500, + } && (metadata.is_dir() || metadata.nlink() == 1), "versioned program ownership or access permissions are unsafe" ); @@ -617,8 +699,11 @@ pub(super) mod native { ) -> anyhow::Result<()> { let settings = crate::config::admin::settings_lease(&self.config.settings_path); let coordination = self.coordination_lock(); + let mut committed = false; let outcome = match (&settings, &coordination) { - (Ok(_), Ok(_)) => self.install_inner(status, manifest, service, source), + (Ok(_), Ok(_)) => { + self.install_inner(status, manifest, service, source, &mut committed) + } (Err(error), _) => Err(anyhow::anyhow!( "configuration coordination failed: {error}" )), @@ -626,6 +711,11 @@ pub(super) mod native { }; if let Err(error) = outcome { tracing::error!(error = %error, "update failed"); + if committed { + // The terminal journal already commits new software/data. + // A missing IPC acknowledgment cannot undo accepted writes. + return Err(error); + } if status.phase.needs_restore() || status.phase == Phase::Succeeded { self.rollback( status, @@ -660,7 +750,9 @@ pub(super) mod native { return self.save(status, Phase::FailedManualIntervention, "Preparation failed and the previous service could not restart. Operator intervention required."); } } + service.prepare_commit(&status.installed)?; self.save(status, Phase::Failed, public_failure(&error))?; + service.commit()?; self.retain_terminal(status); } Ok(()) @@ -672,6 +764,7 @@ pub(super) mod native { manifest: &Manifest, service: &impl Service, source: &impl ArtifactSource, + committed: &mut bool, ) -> anyhow::Result<()> { self.preflight(manifest)?; service.preflight()?; @@ -717,7 +810,7 @@ pub(super) mod native { // A previous failed attempt may already have fully staged the // identical signed program. Reuse only its exact layout/hash, // allowing a normal administrator to retry without host access. - verify_staged_install(&version_dir, manifest, self.config.web_uid)?; + verify_staged_install(&version_dir, manifest, self.config.web_uid, self.ownership)?; } else { let stage_path = stage.keep(); fs::rename(&stage_path, &version_dir)?; @@ -749,17 +842,20 @@ pub(super) mod native { "installed database schema mismatch" ); snapshot::estimated_bytes(&self.config.data_dir).map(|_operation_summary| ())?; + service.prepare_commit(&manifest.version)?; status.installed.clone_from(&manifest.version); self.save( status, Phase::Succeeded, "RustChan updated successfully after health verification.", )?; + *committed = true; let store = self.restart_store(); if store.directory.join("running.json").try_exists()? { let running = store.running()?; store.observe(running.instance, &running.digest, true)?; } + service.commit()?; self.retain_terminal(status); Ok(()) } @@ -772,7 +868,7 @@ pub(super) mod native { .join("versions") .join(&manifest.version); if existing.exists() { - verify_staged_install(&existing, manifest, self.config.web_uid)?; + verify_staged_install(&existing, manifest, self.config.web_uid, self.ownership)?; } for path in [ @@ -1039,10 +1135,19 @@ pub(super) mod native { "restored schema mismatch" ); status.installed = previous; + service.prepare_commit(&status.installed)?; + let store = self.restart_store(); + if store.directory.join("running.json").try_exists()? { + let running = store.running()?; + store.observe(running.instance, &running.digest, true)?; + } Ok::<_, anyhow::Error>(()) })(); match result { - Ok(()) => self.save(status, Phase::RolledBack, message), + Ok(()) => { + self.save(status, Phase::RolledBack, message)?; + service.commit() + } Err(error) => { tracing::error!(error = %error, "automatic rollback failed"); self.save(status, Phase::FailedManualIntervention, "Upgrade and automatic rollback failed. RustChan is stopped or unhealthy; operator intervention required. The rollback backup is retained.") @@ -1327,6 +1432,7 @@ pub(super) mod native { directory: &Path, manifest: &Manifest, web_uid: u32, + ownership: Ownership, ) -> anyhow::Result<()> { no_symlink_ancestors(directory)?; let entries = fs::read_dir(directory)?.collect::, _>>()?; @@ -1350,8 +1456,11 @@ pub(super) mod native { for path in [directory, binary.as_path()] { let metadata = fs::metadata(path)?; anyhow::ensure!( - metadata.uid() != web_uid - && (metadata.uid() == 0 || metadata.uid() == owner) + (match ownership { + Ownership::Separated => metadata.uid() != web_uid, + #[cfg(target_os = "linux")] + Ownership::SameAccount => metadata.uid() == web_uid, + }) && (metadata.uid() == 0 || metadata.uid() == owner) && metadata.mode() & 0o7022 == 0 && metadata.mode() & 0o005 == 0o005 && (metadata.is_dir() || metadata.nlink() == 1), diff --git a/src/updates/transaction_tests.rs b/src/updates/transaction_tests.rs index 24503d63..55a15898 100644 --- a/src/updates/transaction_tests.rs +++ b/src/updates/transaction_tests.rs @@ -133,10 +133,11 @@ fn fixture() -> anyhow::Result { settings_path, health_port: 8080, web_uid, - public_key, + public_key: Some(public_key), retention: 2, }, key: vec![0; 32], + ownership: Ownership::Separated, }; Ok(Fixture { _temp: temp, @@ -171,6 +172,10 @@ enum Fault { Startup, /// New application does not become ready. Health, + /// The candidate full controller cannot initialize before commit. + Controller, + /// Admission opened after commit, but its acknowledgment was lost. + CommitAcknowledgment, } /// Service simulation; no host process or systemd operations occur. @@ -222,6 +227,29 @@ impl Service for FakeService<'_> { ); Ok(()) } + fn prepare_commit(&self, version: &str) -> anyhow::Result<()> { + anyhow::ensure!( + version != "1.6.0" || !matches!(self.fault, Fault::Controller), + "candidate controller could not initialize" + ); + Ok(()) + } + fn commit(&self) -> anyhow::Result<()> { + if matches!(self.fault, Fault::CommitAcknowledgment) { + anyhow::ensure!( + self.engine.status()?.phase == Phase::Succeeded, + "admission preceded durable commit" + ); + let conn = Connection::open(self.engine.config.data_dir.join("chan.db"))?; + conn.execute( + "INSERT INTO posts(body) VALUES ('acknowledged after commit')", + [], + ) + .map(|_affected_rows| ())?; + anyhow::bail!("commit acknowledgment lost after accepted write"); + } + Ok(()) + } } /// Bounded fixture artifact source avoids GitHub and never executes synthetic ELF files. @@ -307,7 +335,12 @@ fn successful_install_requires_complete_backup_and_health() -> anyhow::Result<() /// Exercise every critical activation failure with complete state restoration. #[test] fn migration_startup_and_health_failures_restore_every_component() -> anyhow::Result<()> { - for fault in [Fault::Migration, Fault::Startup, Fault::Health] { + for fault in [ + Fault::Migration, + Fault::Startup, + Fault::Health, + Fault::Controller, + ] { let f = fixture()?; let mut status = job(&f.engine)?; let service = FakeService { @@ -322,6 +355,41 @@ fn migration_startup_and_health_failures_restore_every_component() -> anyhow::Re Ok(()) } +/// A lost admission acknowledgment must never restore a snapshot over accepted new writes. +#[test] +fn lost_commit_acknowledgment_preserves_committed_software_and_data() -> anyhow::Result<()> { + let f = fixture()?; + let mut status = job(&f.engine)?; + let service = FakeService { + engine: &f.engine, + fault: Fault::CommitAcknowledgment, + running: Cell::new(true), + }; + anyhow::ensure!( + f.engine + .install_with_source(&mut status, &f.manifest, &service, &Source(&f.archive)) + .is_err(), + "lost acknowledgment must be reported" + ); + anyhow::ensure!( + f.engine.status()?.phase == Phase::Succeeded && f.engine.current_version()? == "1.6.0", + "committed software must remain selected" + ); + let conn = Connection::open(f.engine.config.data_dir.join("chan.db"))?; + let body: String = conn.query_row("SELECT body FROM posts", [], |row| row.get(0))?; + anyhow::ensure!( + body == "acknowledged after commit", + "rollback erased an acknowledged new write" + ); + anyhow::ensure!(verify_database(&f.engine.config.data_dir.join("chan.db"))? == "1.6.0"); + f.engine.recover(&service)?; + anyhow::ensure!( + f.engine.status()?.phase == Phase::Succeeded, + "cold recovery cannot revert a terminal committed install" + ); + Ok(()) +} + /// Corrupt backup settings must fail before changing the current version. #[test] fn backup_verification_failure_prevents_activation() -> anyhow::Result<()> { @@ -675,6 +743,71 @@ fn pending_restart(engine: &Engine) -> anyhow::Result { Ok(instance) } +/// Delegate initialization while losing the acknowledgment after a terminal settings commit. +struct LostRestartAcknowledgment<'a>(RestartService<'a>); +impl Service for LostRestartAcknowledgment<'_> { + fn stop(&self) -> anyhow::Result<()> { + self.0.stop() + } + fn start(&self) -> anyhow::Result<()> { + self.0.start() + } + fn health(&self, version: &str, schema: &str) -> anyhow::Result<()> { + self.0.health(version, schema) + } + fn health_instance(&self, version: &str, previous: Uuid) -> anyhow::Result<()> { + self.0.health_instance(version, previous) + } + fn commit(&self) -> anyhow::Result<()> { + anyhow::ensure!( + self.0.engine.status()?.phase == Phase::Succeeded, + "settings admission preceded durable commit" + ); + let mut settings = fs::OpenOptions::new() + .append(true) + .open(&self.0.engine.config.settings_path)?; + settings.write_all(b"\n# accepted after commit\n")?; + settings.sync_all()?; + anyhow::bail!("settings commit acknowledgment lost"); + } +} + +/// Neither live nor cold recovery may revert healthy settings after a lost commit acknowledgment. +#[test] +fn lost_settings_commit_acknowledgment_cannot_restore_old_configuration() -> anyhow::Result<()> { + let f = fixture()?; + let previous = pending_restart(&f.engine)?; + let service = LostRestartAcknowledgment(RestartService { + engine: &f.engine, + starts: Cell::new(0), + fail_start: false, + fail_health: false, + old_instance: None, + }); + let (mut status, update, settings) = f.engine.approve_restart(previous, 1, &service)?; + anyhow::ensure!( + f.engine.restart_settings(&mut status, &service).is_err(), + "lost restart acknowledgment must be reported" + ); + anyhow::ensure!( + f.engine.status()?.phase == Phase::Succeeded && service.0.starts.get() == 1, + "healthy replacement must not be reverted" + ); + let candidate = fs::read(&f.engine.config.settings_path)?; + anyhow::ensure!( + std::str::from_utf8(&candidate)?.contains("accepted after commit") + && std::str::from_utf8(&candidate)?.contains("12345") + ); + drop(settings); + drop(update); + f.engine.recover(&service)?; + anyhow::ensure!( + fs::read(&f.engine.config.settings_path)? == candidate, + "cold recovery replaced committed settings" + ); + Ok(()) +} + /// Pending settings survive until replacement readiness; both OS leases exclude all races. #[test] fn settings_restart_health_commit_and_update_mutual_exclusion() -> anyhow::Result<()> { @@ -930,3 +1063,33 @@ fn concurrent_settings_restarts_accept_exactly_one_request() -> anyhow::Result<( ); Ok(()) } + +#[cfg(target_os = "linux")] +/// Same-account mode never weakens separated-UID validation or gains root authority. +#[test] +fn same_account_ownership_is_explicit_and_cannot_borrow_root_authority() -> anyhow::Result<()> { + let mut fixture = fixture()?; + let owner = rustix::process::getuid().as_raw(); + fixture.engine.config.web_uid = owner; + fixture.engine.config.public_key = None; + anyhow::ensure!( + fixture.engine.validate().is_err(), + "separated mode cannot accept a web-owned installation" + ); + fixture.engine.ownership = Ownership::SameAccount; + fixture.engine.key = crate::updates::trust::official_public_key()?; + if owner == 0 { + anyhow::ensure!( + fixture.engine.validate().is_err(), + "source mode must not authorize root operation" + ); + } else { + fixture.engine.validate()?; + fixture.engine.config.web_uid = owner.checked_add(1).context("fixture UID overflow")?; + anyhow::ensure!( + fixture.engine.validate().is_err(), + "source mode cannot use another account's authority" + ); + } + Ok(()) +} diff --git a/src/updates/trust.rs b/src/updates/trust.rs new file mode 100644 index 00000000..1ff57bf1 --- /dev/null +++ b/src/updates/trust.rs @@ -0,0 +1,51 @@ +//! Built-in official release verification identity; never contains signing secrets. +//! +//! Reviewed from the official v1.6.0 and v1.6.5 release public-key assets. The +//! keys match, and the v1.6.5 manifest signature verifies with the earlier key. +//! Rotation requires a reviewed source/release transition, never a download. + +/// `Ed25519` public key for official `RustChan` native release manifests. +const OFFICIAL_PUBLIC_KEY: &str = + "9c003b7595894937addf316cc16f9aef5672903ba91acc309d2af05033988a7c"; + +/// Decode the fixed release verification key embedded in every source build. +pub(super) fn official_public_key() -> anyhow::Result> { + let key = hex::decode(OFFICIAL_PUBLIC_KEY)?; + anyhow::ensure!(key.len() == 32, "invalid embedded release verification key"); + Ok(key) +} + +#[cfg(test)] +/// Verification fixtures from the published v1.6.5 Linux ARM64 release. +mod tests { + /// A real release signed before this source change establishes key provenance; + /// modified manifest bytes and a modified identity must fail verification. + #[test] + fn embedded_identity_verifies_published_release_and_rejects_tampering() -> anyhow::Result<()> { + let manifest = br#"{"executable_sha256":"866307892b59e3173e4aede618018e81d77618acb55bc1a5d580425d52d05029","executable_size":32438312,"filename":"rustchan-update-aarch64-unknown-linux-gnu.tar.gz","format":1,"minimum_schema":"1.5.0","minimum_updater":"1.6.0","release_id":402674971,"schema":"1.6.5","sha256":"9d8b838fbcf960f6892c3d8f0fff84ac6d10420f825c10d2b552d72a9991cff0","size":14098404,"target":"aarch64-unknown-linux-gnu","version":"1.6.5"}"#; + let signature = hex::decode("aaa8317848280ac49ee6cc90f2c4c518c0aa78b81503d4ec88202016932b3f2b766cfd5780f2de4e208a589c84d38d9c9d71aac82e7c500dbd1e52e00ca31d08")?; + let key = super::official_public_key()?; + let verify = |bytes: &[u8], identity: &[u8]| { + super::super::release::verify_manifest( + bytes, + &signature, + identity, + "1.6.5", + 402_674_971, + "aarch64-unknown-linux-gnu", + ) + }; + anyhow::ensure!(verify(manifest, &key)?.version == "1.6.5"); + let mut changed = manifest.to_vec(); + *changed + .get_mut(1) + .ok_or_else(|| anyhow::anyhow!("empty release fixture"))? ^= 1; + anyhow::ensure!(verify(&changed, &key).is_err()); + let mut wrong_key = key; + *wrong_key + .first_mut() + .ok_or_else(|| anyhow::anyhow!("empty verification key"))? ^= 1; + anyhow::ensure!(verify(manifest, &wrong_key).is_err()); + Ok(()) + } +} diff --git a/src/utils/crypto.rs b/src/utils/crypto.rs index 54dd38be..c2170422 100644 --- a/src/utils/crypto.rs +++ b/src/utils/crypto.rs @@ -22,10 +22,7 @@ use anyhow::Result; use argon2::{ - password_hash::{ - rand_core::OsRng as PasswordOsRng, PasswordHash, PasswordHasher as _, - PasswordVerifier as _, SaltString, - }, + password_hash::{phc::PasswordHash, PasswordHasher as _, PasswordVerifier as _}, Algorithm, Argon2, Params, Version, }; use getrandom::SysRng; @@ -51,12 +48,15 @@ const PASSWORD_HASH_MAX_PARALLELISM: u32 = 4; /// # Errors /// Returns an error if Argon2 parameter construction or hashing fails. pub fn hash_password(password: &str) -> Result { - let salt = SaltString::generate(&mut PasswordOsRng); + let mut salt = [0u8; 16]; + SysRng + .try_fill_bytes(&mut salt) + .map_err(|error| anyhow::anyhow!("Password salt generation failed: {error}"))?; let params = Params::new(65536, 2, 2, None).map_err(|e| anyhow::anyhow!("Argon2 params error: {e}"))?; let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); let hash = argon2 - .hash_password(password.as_bytes(), &salt) + .hash_password_with_salt(password.as_bytes(), &salt) .map_err(|e| anyhow::anyhow!("Password hashing failed: {e}"))? .to_string(); Ok(hash) @@ -75,7 +75,7 @@ pub fn verify_password(password: &str, hash: &str) -> Result { anyhow::bail!("Invalid password hash: salt and output are required"); } - let algorithm = Algorithm::try_from(parsed.algorithm) + let algorithm = Algorithm::try_from(parsed.algorithm.as_str()) .map_err(|error| anyhow::anyhow!("Invalid password hash algorithm: {error}"))?; let version = parsed .version @@ -100,7 +100,7 @@ pub fn verify_password(password: &str, hash: &str) -> Result { let verifier = Argon2::new(algorithm, version, params); match verifier.verify_password(password.as_bytes(), &parsed) { Ok(()) => Ok(true), - Err(argon2::password_hash::Error::Password) => Ok(false), + Err(argon2::password_hash::Error::PasswordInvalid) => Ok(false), Err(error) => Err(anyhow::anyhow!("Password verification failed: {error}")), } } @@ -263,6 +263,22 @@ mod tests { Ok(()) } + #[test] + fn verify_password_accepts_existing_argon2_05_hash() -> Result<()> { + // Generated by Argon2 0.5.3 with RustChan 1.6.5's exact parameters. + // A fixed test-only salt makes this independent of the upgraded hasher. + let stored = "$argon2id$v=19$m=65536,t=2,p=2$cnVzdGNoYW4tdjEuNi41ISE$9bLCu60YvVwNCStcJ+Uv2kvViRlaP/Rm/dg64RcLFjI"; + anyhow::ensure!( + verify_password("legacy-admin-password", stored)?, + "existing admin hashes must verify after the Argon2 upgrade" + ); + anyhow::ensure!( + !verify_password("incorrect-password", stored)?, + "legacy hashes must continue rejecting incorrect passwords" + ); + Ok(()) + } + #[test] fn password_validation_counts_characters_not_bytes() -> Result<()> { // Three 3-byte characters are nine bytes but only three characters. diff --git a/tools/test_update_package.py b/tools/test_update_package.py index b71b05a3..7ebb2d87 100644 --- a/tools/test_update_package.py +++ b/tools/test_update_package.py @@ -38,6 +38,8 @@ def test_deterministic_package(self): again, second = self.package() self.assertEqual(info, again) self.assertEqual(artifact, second) + # Earlier controllers lack the same-binary handoff/startup protocol. + self.assertEqual(info['minimum_updater'], '1.6.6') tool.validate(info, artifact) def test_signatures_and_both_architectures(self): diff --git a/tools/update_package.py b/tools/update_package.py index 7fb3e4e1..4b35868b 100644 --- a/tools/update_package.py +++ b/tools/update_package.py @@ -53,7 +53,7 @@ def package(binary, target, version, output): (output / name).write_bytes(artifact) info = dict(version=version, target=target, filename=name, size=len(artifact), sha256=digest(artifact), executable_size=len(data), executable_sha256=digest(data), schema=version, - minimum_schema='1.5.0', minimum_updater='1.6.0', format=1) + minimum_schema='1.5.0', minimum_updater='1.6.6', format=1) (output / f'build-info-{target}.json').write_text(json.dumps(info, sort_keys=True)) return info diff --git a/vendor/symphonia-codec-aac/Cargo.toml b/vendor/symphonia-codec-aac/Cargo.toml index 55613b26..6167984a 100644 --- a/vendor/symphonia-codec-aac/Cargo.toml +++ b/vendor/symphonia-codec-aac/Cargo.toml @@ -52,7 +52,7 @@ name = "tests" path = "tests/tests.rs" [dependencies.lazy_static] -version = "1.4.0" +version = "1.5.1" [dependencies.log] version = "0.4" diff --git a/vendor/symphonia-codec-aac/RUSTCHAN-PATCH.md b/vendor/symphonia-codec-aac/RUSTCHAN-PATCH.md index 9193ddb4..5f1d64c3 100644 --- a/vendor/symphonia-codec-aac/RUSTCHAN-PATCH.md +++ b/vendor/symphonia-codec-aac/RUSTCHAN-PATCH.md @@ -2,6 +2,6 @@ Source: the crates.io `symphonia-codec-aac` 0.6.1 release, from [Symphonia](https://github.com/pdeljanov/Symphonia), licensed MPL-2.0. The upstream package archive checksum is `f5bf8e39552d34a3c4c98333370e62f48c92456d2e814f273b5c3ad7c4a5f45c`. Original source, manifest, tests and license are preserved. No additional dependencies, native code or build script are introduced. -The only source change is in `src/aac/mod.rs`: after parsing a complete raw-data block, return `Unsupported("aac: spectral band replication is unsupported")` when the existing parser has detected an in-band SBR fill element. Upstream otherwise synthesizes only AAC-LC core samples, silently changing HE-AAC rate, duration and bandwidth. RustChan recognizes this exact error as an explicit compatibility requirement. Truncated payloads keep their existing parse errors. +The only source change is in `src/aac/mod.rs`: after parsing a complete raw-data block, return `Unsupported("aac: spectral band replication is unsupported")` when the existing parser has detected an in-band SBR fill element. Upstream otherwise synthesizes only AAC-LC core samples, silently changing HE-AAC rate, duration and bandwidth. RustChan recognizes this exact error as an explicit compatibility requirement. Truncated payloads keep their existing parse errors. The manifest's `lazy_static` lower bound was updated to the current stable 1.5.1 release on 2026-10-03; the decoder patch is unchanged. This patch detects a missing decoder capability; it does not implement SBR or parametric stereo. Remove the patch after an upstream release provides correct reconstruction or this same explicit error. The upstream crate requires Rust 1.85; RustChan still requires 1.99. From 627543993fd8055523689a7ebd6b1c4d3a4dc616 Mon Sep 17 00:00:00 2001 From: csd113 Date: Sat, 3 Oct 2026 23:07:43 -0700 Subject: [PATCH 2/2] Fix native Linux startup and HTTPS readiness --- CHANGELOG.md | 2 ++ Cargo.toml | 6 ++++++ docs/dependency-upgrade-1.6.6.md | 22 +++++++++++++++++++++ src/updates/bootstrap.rs | 14 +++++++++---- src/updates/controller.rs | 16 +++++++++++---- src/updates/native.rs | 34 +++++++++++++++++++------------- tests/cli_runtime.rs | 13 ++++++++++++ 7 files changed, 85 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9228833..56303d44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to RustChan will be documented in this file. - Documented the tested offline preparation required for 1.6.0 → the already published 1.6.5 release and stopped manual adoption of earlier source installations. - Integrated the Linux controller and process monitor into the application executable, with embedded official release verification, full data rollback and public admission after durable commit. Exceptional loss of writer-exit proof can require a host reboot. New update packages require the 1.6.6 controller generation. - Gave the first database connection a bounded 30-second initialization deadline while retaining one-second request checkout waits, asynchronous spare initialization and full SQLite durability. +- Completed executable verification before starting the Linux guardian handshake, preventing large source/debug builds from timing out during administrator startup. +- Fixed native readiness checks for direct HTTPS installations so public admission opens after the configured secure listener is ready. - Updated all direct Rust dependencies to the latest compatible stable releases, including Arti 0.47, SQLite and Argon2 0.6. Existing Argon2 password hashes remain supported with unchanged cost limits. - Updated pinned GitHub Actions and audit tools, and moved container images to Debian 13. Retained Rust 1.99 and the Rust-only TLS provider policy. - Removed local agent instructions and the complete Playwright harness from Git tracking, restored their ignore rules and added a CI guard against accidental reintroduction. Local copies remain available for development. diff --git a/Cargo.toml b/Cargo.toml index b1038800..23fb1421 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,12 @@ codegen-units = 1 strip = true panic = "abort" +# Native startup verifies complete executable copies even in source/debug builds. +# Keep the hashing backend fast without changing application debug checks or the +# bounded lifecycle/test deadlines. The test profile inherits this override. +[profile.dev.package."sha2:0.11.0"] +opt-level = 3 + [[bin]] name = "rustchan-cli" path = "src/main.rs" diff --git a/docs/dependency-upgrade-1.6.6.md b/docs/dependency-upgrade-1.6.6.md index 742b729f..3504ff6c 100644 --- a/docs/dependency-upgrade-1.6.6.md +++ b/docs/dependency-upgrade-1.6.6.md @@ -249,3 +249,25 @@ fixtures do not establish real boot or power-loss behavior. Local instructions and the complete browser harness remain ignored and untracked. Their preserved copies and regression evidence are local artifacts; they are not added to release commits or container contexts. + +The first PR #120 CI run exposed a Linux guardian admission timeout: the parent +hashed the complete executable after spawning a guardian with a five-second +handshake deadline. A copied executable with a large non-runtime tail reproduced +the failure before database creation; moving verification before spawn admitted +an executable exceeding 2 GB without changing the deadline or lease checks. + +Native CLI validation also exposed an HTTPS-only startup bug: the controller +probed the disabled plaintext listener. Readiness now follows the resolved TLS +listener, uses fixed loopback with no proxy or redirects, and retains bounded +response, running-instance and version checks. Local self-signed/public-host +certificates are accepted only by that loopback health client. Debug/test hashing +keeps an optimized SHA-256 backend. Linux black-box CLI fixtures remove debug-only +sections from their copied executable; the original retains debug information. +An ELF audit verified all 28 allocated sections remain identical. Test assertions, +parallel execution and deadlines remain unchanged. + +The corrected ordinary-account Linux CLI suite passed all five tests in +16.28 seconds, including HTTPS-only startup, secure cookies and graceful +shutdown. Strict all-target/all-feature Linux Clippy passed. The corrected +executables and test harness are preserved outside the build cache alongside +the failed-run evidence in `output/merge-release-1.6.6/ci-fix/`. diff --git a/src/updates/bootstrap.rs b/src/updates/bootstrap.rs index cde239ad..9254d95b 100644 --- a/src/updates/bootstrap.rs +++ b/src/updates/bootstrap.rs @@ -157,18 +157,24 @@ pub(super) fn admit_cli(layout: &Layout) -> anyhow::Result<()> { Ok(()) } -/// Build the fixed same-account layout with the embedded public verification key. -pub(super) fn engine(layout: &Layout) -> anyhow::Result { +/// Resolve the same settings and environment as the admitted application. +pub(super) fn configuration(layout: &Layout) -> anyhow::Result { let path = layout.data.join("settings.toml"); let bytes = if path.try_exists()? { crate::restart::Store::read(&path, 4 * 1024 * 1024)? } else { Vec::new() }; - let config = crate::config::admin::resolve_file( + crate::config::admin::resolve_file( std::str::from_utf8(&bytes)?, &crate::config::Environment::Process, - )?; + ) +} + +/// Build the fixed same-account layout with the embedded public verification key. +pub(super) fn engine(layout: &Layout) -> anyhow::Result { + let path = layout.data.join("settings.toml"); + let config = configuration(layout)?; Ok(Engine { config: Config { install_dir: layout.install.clone(), diff --git a/src/updates/controller.rs b/src/updates/controller.rs index 68442a54..faf59cf9 100644 --- a/src/updates/controller.rs +++ b/src/updates/controller.rs @@ -343,7 +343,12 @@ impl NativeService { fn probe(&self, version: &str, previous: Option) -> anyhow::Result<()> { let layout = Layout::prepare(&self.context.data)?; let client = reqwest::blocking::Client::builder() + .no_proxy() .redirect(reqwest::redirect::Policy::none()) + // Only fixed loopback readiness is queried. Locally generated and + // public-host certificates need not authenticate 127.0.0.1; the + // admitted process's private instance/version still bind the reply. + .danger_accept_invalid_certs(true) .timeout(Duration::from_secs(2)) .build()?; let started = Instant::now(); @@ -356,11 +361,14 @@ impl NativeService { "application is not initialized" ); let engine = bootstrap::engine(&layout)?; + let configuration = bootstrap::configuration(&layout)?; + let (scheme, port) = if configuration.tls.enabled { + ("https", configuration.tls.port) + } else { + ("http", engine.config.health_port) + }; let response = client - .get(format!( - "http://127.0.0.1:{}/readyz", - engine.config.health_port - )) + .get(format!("{scheme}://127.0.0.1:{port}/readyz")) .send()? .error_for_status()?; let instance = response diff --git a/src/updates/native.rs b/src/updates/native.rs index 072c30e6..9b4f7dc3 100644 --- a/src/updates/native.rs +++ b/src/updates/native.rs @@ -371,7 +371,24 @@ fn supervise( port: crate::config::launcher_port(), }; rustix::process::set_child_subreaper(Some(rustix::process::Pid::INIT))?; - let mut command = Command::new(std::env::current_exe()?); + let program = std::env::current_exe()?; + let owns_admission = admission.is_some(); + // Hashing a large source/debug executable may take longer than the private + // handshake deadline. Finish it before spawning a guardian that must wait + // for this main thread to grant its lease. + let monitor = if owns_admission { + Some(Monitor { + format: lifecycle::FORMAT, + boot: lifecycle::kernel_boot_id()?, + identity: context.monitor, + endpoint, + version: super::VERSION.to_owned(), + program_sha256: super::bootstrap::digest(&program)?, + }) + } else { + None + }; + let mut command = Command::new(program); let _arguments = command .args(std::env::args_os().skip(1)) .env(INTERNAL, serde_json::to_string(&context)?) @@ -386,19 +403,8 @@ fn supervise( } }; let identity = ProcessIdentity::read(i32::try_from(child.id())?)?; - let owns_admission = admission.is_some(); - if owns_admission { - publish_monitor( - layout, - &Monitor { - format: lifecycle::FORMAT, - boot: lifecycle::kernel_boot_id()?, - identity: context.monitor, - endpoint, - version: super::VERSION.to_owned(), - program_sha256: super::bootstrap::digest(&std::env::current_exe()?)?, - }, - )?; + if let Some(monitor) = monitor { + publish_monitor(layout, &monitor)?; } let Signals { runtime, diff --git a/tests/cli_runtime.rs b/tests/cli_runtime.rs index a714b3fa..2ebd023e 100644 --- a/tests/cli_runtime.rs +++ b/tests/cli_runtime.rs @@ -66,6 +66,19 @@ mod tests { .context("compiled CLI path has no filename")?; let destination = bin_dir.join(file_name); let _bytes_copied = std::fs::copy(source, &destination).context("copy rustchan-cli")?; + #[cfg(target_os = "linux")] + { + // Black-box fixtures need runtime sections, not hundreds of MB of + // debug metadata hashed repeatedly by native source verification. + // The original compiled executable keeps its full debug information. + let output = + command_output(Command::new("strip").arg("--strip-debug").arg(&destination))?; + anyhow::ensure!( + output.status.success(), + "strip copied CLI debug sections: {}", + String::from_utf8_lossy(&output.stderr) + ); + } Ok(destination) }