diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..d523125
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,124 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+# Least privilege by default: read-only token for all jobs. The one job that
+# needs more (signed-roundtrip, which mints an OIDC id-token) overrides this.
+permissions:
+ contents: read
+
+env:
+ CARGO_TERM_COLOR: always
+ RUST_BACKTRACE: 1
+
+jobs:
+ # Build, test, and lint on each supported OS.
+ test:
+ name: test (${{ matrix.os }})
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest]
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ components: rustfmt, clippy
+
+ - name: Cache cargo registry and build
+ uses: Swatinem/rust-cache@v2
+
+ - name: Format check
+ run: cargo fmt --all --check
+
+ - name: Clippy
+ run: cargo clippy --all-targets -- -D warnings
+
+ - name: Build
+ run: cargo build --release --locked
+
+ - name: Test
+ run: cargo test --locked
+
+ # The full offline attack demo: publish (unsigned), serve, tamper mid-stream,
+ # confirm blacklight aborts. No Sigstore/OIDC needed here, so it runs on PRs.
+ attack-demo:
+ name: attack demo (verified streaming)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+ - name: Run the tampering-proxy demo
+ run: bash demo/run_demo.sh 32 16000000
+
+ # Exercises the piece that cannot be driven locally without a browser: the
+ # full SIGNED round-trip. GitHub Actions has an ambient OIDC identity, so
+ # `blacklight publish` signs keyless against Sigstore (no browser), and
+ # `blacklight fetch` then verifies the Rekor-anchored bundle and streams the
+ # artifact with the signer identity pinned to this workflow.
+ signed-roundtrip:
+ name: signed round-trip (Sigstore staging)
+ runs-on: ubuntu-latest
+ # Only when we have an ambient identity: pushes/dispatch on this repo, not
+ # fork PRs (which cannot mint id-tokens).
+ if: github.event_name != 'pull_request'
+ permissions:
+ contents: read
+ id-token: write # required for keyless Sigstore signing
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+
+ - name: Build
+ run: cargo build --release --locked
+
+ - name: Publish + sign against Sigstore staging (ambient OIDC)
+ run: |
+ set -euo pipefail
+ head -c 4000000 /dev/urandom > artifact.bin
+ ./target/release/blacklight publish artifact.bin --out dist
+ echo "--- produced sidecars ---"
+ ls -l dist/
+ test -f dist/artifact.bin.blacklight.json.sigstore.json \
+ || { echo "no Sigstore bundle was produced"; exit 1; }
+
+ - name: Serve the published artifact locally
+ run: |
+ cp artifact.bin dist/artifact.bin
+ ( cd dist && python3 -m http.server 8080 --bind 127.0.0.1 &>/tmp/serve.log & )
+ # Wait for the server to accept connections.
+ for i in $(seq 1 20); do
+ curl -sf -o /dev/null "http://127.0.0.1:8080/artifact.bin.blacklight.json" && break
+ sleep 0.5
+ done
+
+ - name: Fetch + verify the signed bundle, pinning this workflow's identity
+ run: |
+ set -euo pipefail
+ # In GitHub Actions the Fulcio cert's SAN is the workflow ref.
+ IDENTITY="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/.github/workflows/ci.yml@${GITHUB_REF}"
+ ISSUER="https://token.actions.githubusercontent.com"
+ echo "expecting identity: $IDENTITY"
+ echo "expecting issuer: $ISSUER"
+ ./target/release/blacklight fetch \
+ "http://127.0.0.1:8080/artifact.bin.blacklight.json" \
+ --expect-identity "$IDENTITY" \
+ --expect-issuer "$ISSUER" \
+ -o fetched.bin
+ cmp artifact.bin fetched.bin
+ echo "signed round-trip verified end-to-end."
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ea8c4bf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..8c933e9
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,3182 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "ambient-id"
+version = "0.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1daa54020e05aa0b163ee10434fff35a0f18d28a1cafa142bd1290e1abe630e"
+dependencies = [
+ "astral-reqwest-middleware",
+ "reqwest",
+ "secrecy",
+ "serde",
+ "serde_json",
+ "thiserror",
+]
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
+
+[[package]]
+name = "arrayref"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
+
+[[package]]
+name = "astral-reqwest-middleware"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "98e1c6be25cfbf1bb4fea1a9da51bc05d3259a9062df4e53f54e5607895e33c9"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "http",
+ "reqwest",
+ "serde",
+ "thiserror",
+ "tower-service",
+]
+
+[[package]]
+name = "async-compression"
+version = "0.4.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"
+dependencies = [
+ "compression-codecs",
+ "compression-core",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "async-trait"
+version = "0.1.89"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "aws-lc-rs"
+version = "1.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad"
+dependencies = [
+ "aws-lc-sys",
+ "untrusted 0.7.1",
+ "zeroize",
+]
+
+[[package]]
+name = "aws-lc-sys"
+version = "0.42.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444"
+dependencies = [
+ "cc",
+ "cmake",
+ "dunce",
+ "fs_extra",
+ "pkg-config",
+]
+
+[[package]]
+name = "bao-tree"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06384416b1825e6e04fde63262fda2dc408f5b64c02d04e0d8b70ae72c17a52b"
+dependencies = [
+ "blake3",
+ "bytes",
+ "futures-lite",
+ "genawaiter",
+ "iroh-io",
+ "positioned-io",
+ "range-collections",
+ "self_cell",
+ "serde",
+ "smallvec",
+]
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
+[[package]]
+name = "binary-merge"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "597bb81c80a54b6a4381b23faba8d7774b144c94cbd1d6fe3f1329bd776554ab"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
+
+[[package]]
+name = "blacklight"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "bao-tree",
+ "blake3",
+ "clap",
+ "futures-util",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "sigstore-oidc",
+ "sigstore-sign",
+ "sigstore-trust-root",
+ "sigstore-types",
+ "sigstore-verify",
+ "thiserror",
+ "tokio",
+]
+
+[[package]]
+name = "blake3"
+version = "1.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "cc",
+ "cfg-if",
+ "constant_time_eq",
+ "cpufeatures 0.3.0",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bstr"
+version = "1.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79"
+dependencies = [
+ "memchr",
+ "serde_core",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytes"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "cc"
+version = "1.2.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
+dependencies = [
+ "find-msvc-tools",
+ "jobserver",
+ "libc",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+
+[[package]]
+name = "clap"
+version = "4.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+
+[[package]]
+name = "cmake"
+version = "0.1.58"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "cmpv2"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "961b955a666e25ee5a1091d219128d6e6401e3dab84efb1a2bf6b4035d797b39"
+dependencies = [
+ "crmf",
+ "der",
+ "spki",
+ "x509-cert",
+]
+
+[[package]]
+name = "cms"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730"
+dependencies = [
+ "const-oid",
+ "der",
+ "spki",
+ "x509-cert",
+]
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
+[[package]]
+name = "compression-codecs"
+version = "0.4.38"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
+dependencies = [
+ "compression-core",
+ "flate2",
+ "memchr",
+]
+
+[[package]]
+name = "compression-core"
+version = "0.4.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
+
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
+[[package]]
+name = "constant_time_eq"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
+
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crmf"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36fe21b96d5b87f5de4b5b7202ec41c00110ac817ce6728fe75fb2fe5962ed92"
+dependencies = [
+ "cms",
+ "der",
+ "spki",
+ "x509-cert",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "defmt"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
+dependencies = [
+ "bitflags 1.3.2",
+ "defmt-macros",
+]
+
+[[package]]
+name = "defmt-macros"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
+dependencies = [
+ "defmt-parser",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "defmt-parser"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
+dependencies = [
+ "thiserror",
+]
+
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid",
+ "der_derive",
+ "flagset",
+ "pem-rfc7468",
+ "zeroize",
+]
+
+[[package]]
+name = "der_derive"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "directories"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "dunce"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "flagset"
+version = "0.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "fs_extra"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "futures-macro"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "genawaiter"
+version = "0.99.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c86bd0361bcbde39b13475e6e36cb24c329964aa2611be285289d1e4b751c1a0"
+dependencies = [
+ "futures-core",
+ "genawaiter-macro",
+ "genawaiter-proc-macro",
+ "proc-macro-hack",
+]
+
+[[package]]
+name = "genawaiter-macro"
+version = "0.99.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b32dfe1fdfc0bbde1f22a5da25355514b5e450c33a6af6770884c8750aedfbc"
+
+[[package]]
+name = "genawaiter-proc-macro"
+version = "0.99.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "784f84eebc366e15251c4a8c3acee82a6a6f427949776ecb88377362a9621738"
+dependencies = [
+ "proc-macro-error",
+ "proc-macro-hack",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 6.0.0",
+]
+
+[[package]]
+name = "globset"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3"
+dependencies = [
+ "aho-corasick",
+ "bstr",
+ "log",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "h2"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "http",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "http"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "hyper"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "h2",
+ "http",
+ "http-body",
+ "httparse",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "inplace-vec-builder"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf64c2edc8226891a71f127587a2861b132d2b942310843814d5001d99a1d307"
+dependencies = [
+ "smallvec",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
+
+[[package]]
+name = "iroh-io"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0a5feb781017b983ff1b155cd1faf8174da2acafd807aa482876da2d7e6577a"
+dependencies = [
+ "bytes",
+ "futures-lite",
+ "pin-project",
+]
+
+[[package]]
+name = "is-docker"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "is-wsl"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
+dependencies = [
+ "is-docker",
+ "once_cell",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "jiff"
+version = "0.2.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccfe6121cbe750cf81efa362d85c0bde7ea298ec43092d3a193baca59cdbd634"
+dependencies = [
+ "defmt",
+ "jiff-static",
+ "jiff-tzdb-platform",
+ "log",
+ "portable-atomic",
+ "portable-atomic-util",
+ "serde_core",
+ "windows-link",
+]
+
+[[package]]
+name = "jiff-static"
+version = "0.2.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e165e897f662d428f3cd3828a919dbe067c2d42bb1031eede74ef9d27ecdedd2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "jiff-tzdb"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6142247df1a93c2b3587402a19710be3e6e942f1581a1702e76408f2c21d6590"
+
+[[package]]
+name = "jiff-tzdb-platform"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
+dependencies = [
+ "jiff-tzdb",
+]
+
+[[package]]
+name = "jni"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
+dependencies = [
+ "cfg-if",
+ "combine",
+ "jni-macros",
+ "jni-sys",
+ "log",
+ "simd_cesu8",
+ "thiserror",
+ "walkdir",
+ "windows-link",
+]
+
+[[package]]
+name = "jni-macros"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "simd_cesu8",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
+dependencies = [
+ "jni-sys-macros",
+]
+
+[[package]]
+name = "jni-sys-macros"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
+dependencies = [
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "jobserver"
+version = "0.1.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
+dependencies = [
+ "getrandom 0.3.4",
+ "libc",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "libredox"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb"
+dependencies = [
+ "bitflags 2.13.0",
+ "libc",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "lru-slab"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+
+[[package]]
+name = "memchr"
+version = "2.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "open"
+version = "5.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd8d3b65c44123a56e0133d2cd06ce4361bd3ca99d41198b2f25e3c3db9b8b4a"
+dependencies = [
+ "is-wsl",
+ "libc",
+]
+
+[[package]]
+name = "openssl-probe"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "pem"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
+dependencies = [
+ "base64",
+ "serde_core",
+]
+
+[[package]]
+name = "pem-rfc7468"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
+dependencies = [
+ "base64ct",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "portable-atomic"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "positioned-io"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4ec4b80060f033312b99b6874025d9503d2af87aef2dd4c516e253fbfcdada7"
+dependencies = [
+ "libc",
+ "winapi",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "proc-macro-error"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18f33027081eba0a6d8aba6d1b1c3a3be58cbb12106341c2d5759fcd9b5277e7"
+dependencies = [
+ "proc-macro-error-attr",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro-error-attr"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a5b4b77fdb63c1eca72173d68d24501c54ab1269409f6b672c85deb18af69de"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+ "syn-mid",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro-hack"
+version = "0.5.20+deprecated"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quinn"
+version = "0.11.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
+dependencies = [
+ "aws-lc-rs",
+ "bytes",
+ "getrandom 0.3.4",
+ "lru-slab",
+ "rand",
+ "ring",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
+dependencies = [
+ "rand_chacha",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
+name = "range-collections"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "861706ea9c4aded7584c5cd1d241cec2ea7f5f50999f236c22b65409a1f1a0d0"
+dependencies = [
+ "binary-merge",
+ "inplace-vec-builder",
+ "ref-cast",
+ "smallvec",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.13.0",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
+dependencies = [
+ "getrandom 0.2.17",
+ "libredox",
+ "thiserror",
+]
+
+[[package]]
+name = "ref-cast"
+version = "1.0.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
+dependencies = [
+ "ref-cast-impl",
+]
+
+[[package]]
+name = "ref-cast-impl"
+version = "1.0.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "reqwest"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "h2",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "rustls-platform-verifier",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tokio-util",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-streams",
+ "web-sys",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted 0.9.0",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags 2.13.0",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.41"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
+dependencies = [
+ "aws-lc-rs",
+ "once_cell",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-native-certs"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
+dependencies = [
+ "openssl-probe",
+ "rustls-pki-types",
+ "schannel",
+ "security-framework",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
+dependencies = [
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-platform-verifier"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
+dependencies = [
+ "core-foundation",
+ "core-foundation-sys",
+ "jni",
+ "log",
+ "once_cell",
+ "rustls",
+ "rustls-native-certs",
+ "rustls-platform-verifier-android",
+ "rustls-webpki",
+ "security-framework",
+ "security-framework-sys",
+ "webpki-root-certs",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls-platform-verifier-android"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
+dependencies = [
+ "aws-lc-rs",
+ "ring",
+ "rustls-pki-types",
+ "untrusted 0.9.0",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "ryu-js"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd29631678d6fb0903b69223673e122c32e9ae559d0960a38d574695ebc0ea15"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "schannel"
+version = "0.1.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "secrecy"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
+name = "security-framework"
+version = "3.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
+dependencies = [
+ "bitflags 2.13.0",
+ "core-foundation",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "self_cell"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89"
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_json_canonicalizer"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2"
+dependencies = [
+ "ryu-js",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "sha1"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "sigstore-bundle"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18b51b097b7e9d898efad51e1031c855ce4008b4866d0c38ac8b0fac5745a84c"
+dependencies = [
+ "base64",
+ "hex",
+ "serde",
+ "serde_json",
+ "sigstore-crypto",
+ "sigstore-rekor",
+ "sigstore-tsa",
+ "sigstore-types",
+ "thiserror",
+]
+
+[[package]]
+name = "sigstore-crypto"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1210ea5dd0dd07b1466449a6c5c79000f8ca10ac30b4e8f64031c3bed4c5c662"
+dependencies = [
+ "aws-lc-rs",
+ "base64",
+ "const-oid",
+ "der",
+ "digest",
+ "pem",
+ "rand_core 0.9.5",
+ "sha2",
+ "signature",
+ "sigstore-types",
+ "spki",
+ "thiserror",
+ "tracing",
+ "x509-cert",
+]
+
+[[package]]
+name = "sigstore-fulcio"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1804a3030159d6c165f7af4fb753a780d4d114b4d16320c0ea2acc1b5af8fea"
+dependencies = [
+ "base64",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "sigstore-crypto",
+ "sigstore-oidc",
+ "sigstore-types",
+ "thiserror",
+ "url",
+]
+
+[[package]]
+name = "sigstore-merkle"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae98f8a270885497dbe84e6dae10fa29066df0388f62980923728e38d844716"
+dependencies = [
+ "base64",
+ "hex",
+ "sigstore-crypto",
+ "sigstore-types",
+ "thiserror",
+]
+
+[[package]]
+name = "sigstore-oidc"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84e65508b946df747b804f770bb83243ecfcfd61150adc9b27c5dca59d74a117"
+dependencies = [
+ "ambient-id",
+ "base64",
+ "open",
+ "rand",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "sigstore-crypto",
+ "sigstore-types",
+ "thiserror",
+ "tokio",
+ "url",
+]
+
+[[package]]
+name = "sigstore-rekor"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e1c153f4f89f7570d0f625f660cadcb1b2fba72583f378d2d2b308c752ac45f"
+dependencies = [
+ "base64",
+ "hex",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "sigstore-crypto",
+ "sigstore-merkle",
+ "sigstore-types",
+ "thiserror",
+ "url",
+]
+
+[[package]]
+name = "sigstore-sign"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62ec818202811f9440abf6f7714d187b0248268a5472ea481d3c1d33f51f1fd5"
+dependencies = [
+ "base64",
+ "serde_json",
+ "sigstore-bundle",
+ "sigstore-crypto",
+ "sigstore-fulcio",
+ "sigstore-oidc",
+ "sigstore-rekor",
+ "sigstore-trust-root",
+ "sigstore-tsa",
+ "sigstore-types",
+ "thiserror",
+ "x509-cert",
+]
+
+[[package]]
+name = "sigstore-trust-root"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ded6afc37295a10dba6569e033dd15f85f04f00980bfcf2c64009bc8ed279985"
+dependencies = [
+ "base64",
+ "directories",
+ "hex",
+ "jiff",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "sigstore-crypto",
+ "sigstore-tuf",
+ "sigstore-types",
+ "thiserror",
+ "tokio",
+ "tracing",
+ "x509-cert",
+]
+
+[[package]]
+name = "sigstore-tsa"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a88505b71600a791e52bed3777ed9cfeefd4b03dee6575d38c5542ae80e2bbdc"
+dependencies = [
+ "aws-lc-rs",
+ "base64",
+ "cmpv2",
+ "cms",
+ "const-oid",
+ "der",
+ "hex",
+ "jiff",
+ "rand",
+ "reqwest",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "sigstore-crypto",
+ "sigstore-types",
+ "thiserror",
+ "tracing",
+ "x509-cert",
+ "x509-tsp",
+]
+
+[[package]]
+name = "sigstore-tuf"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfc7cced094bd306ef561312cfffa3ea05ac7eea4a62cfa81680c07a10addbc0"
+dependencies = [
+ "globset",
+ "hex",
+ "jiff",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "sha2",
+ "sigstore-crypto",
+ "sigstore-types",
+ "tempfile",
+ "thiserror",
+ "tokio",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "sigstore-types"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56f034e86c3b8d91b32608c83f4a18939eed32c6ca08d17d2f52b88be365aac6"
+dependencies = [
+ "base64",
+ "hex",
+ "pem",
+ "serde",
+ "serde_json",
+ "thiserror",
+]
+
+[[package]]
+name = "sigstore-verify"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4720f300d093ecac6dcd75d4498fd1b2d9133600e679837bb9d944a4229eda7"
+dependencies = [
+ "base64",
+ "cms",
+ "const-oid",
+ "hex",
+ "jiff",
+ "pem",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "serde",
+ "serde_json",
+ "serde_json_canonicalizer",
+ "sigstore-bundle",
+ "sigstore-crypto",
+ "sigstore-merkle",
+ "sigstore-rekor",
+ "sigstore-trust-root",
+ "sigstore-tsa",
+ "sigstore-types",
+ "thiserror",
+ "tls_codec",
+ "tracing",
+ "x509-cert",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
+
+[[package]]
+name = "simd_cesu8"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33"
+dependencies = [
+ "rustc_version",
+ "simdutf8",
+]
+
+[[package]]
+name = "simdutf8"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "socket2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn-mid"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fea305d57546cc8cd04feb14b62ec84bf17f50e3f7b12560d7bfa9265f39d9ed"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.3",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tls_codec"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b"
+dependencies = [
+ "tls_codec_derive",
+ "zeroize",
+]
+
+[[package]]
+name = "tls_codec_derive"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "tokio"
+version = "1.52.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "async-compression",
+ "bitflags 2.13.0",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "untrusted"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.76"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasm-streams"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
+dependencies = [
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webpki-root-certs"
+version = "1.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm 0.52.6",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link",
+ "windows_aarch64_gnullvm 0.53.1",
+ "windows_aarch64_msvc 0.53.1",
+ "windows_i686_gnu 0.53.1",
+ "windows_i686_gnullvm 0.53.1",
+ "windows_i686_msvc 0.53.1",
+ "windows_x86_64_gnu 0.53.1",
+ "windows_x86_64_gnullvm 0.53.1",
+ "windows_x86_64_msvc 0.53.1",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "x509-cert"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94"
+dependencies = [
+ "const-oid",
+ "der",
+ "sha1",
+ "signature",
+ "spki",
+ "tls_codec",
+]
+
+[[package]]
+name = "x509-tsp"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f5ceece934a21607055b7ac5c25adb56a2ff559804b10705dc674d1d838c15e1"
+dependencies = [
+ "cmpv2",
+ "cms",
+ "der",
+]
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+ "synstructure",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.52"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.52"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+dependencies = [
+ "zeroize_derive",
+]
+
+[[package]]
+name = "zeroize_derive"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..2c2c0c6
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,27 @@
+[package]
+name = "blacklight"
+version = "0.1.0"
+edition = "2024"
+license = "MIT OR Apache-2.0"
+description = "Verified-streaming downloads anchored in the Sigstore transparency log — tamper is caught mid-transfer, at the first bad byte, by a publisher you can name."
+repository = "https://github.com/greenrobotllc/blacklight"
+readme = "README.md"
+keywords = ["sigstore", "transparency-log", "blake3", "integrity", "supply-chain"]
+categories = ["command-line-utilities", "cryptography"]
+
+[dependencies]
+anyhow = "1.0.103"
+bao-tree = "0.16.0"
+blake3 = "1.8.5"
+clap = { version = "4.6.1", features = ["derive"] }
+futures-util = "0.3.32"
+reqwest = { version = "0.13.4", default-features = false, features = ["stream", "http2", "rustls"] }
+serde = { version = "1.0.228", features = ["derive"] }
+serde_json = "1.0.150"
+sigstore-oidc = "0.10.0"
+sigstore-sign = "0.10.0"
+sigstore-trust-root = "0.10.0"
+sigstore-types = "0.10.0"
+sigstore-verify = "0.10.0"
+thiserror = "2.0.18"
+tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "fs", "io-util"] }
diff --git a/LICENSE-APACHE b/LICENSE-APACHE
new file mode 100644
index 0000000..3fe73c3
--- /dev/null
+++ b/LICENSE-APACHE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright 2026 Andy Triboletti
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/LICENSE-MIT b/LICENSE-MIT
new file mode 100644
index 0000000..a5c61cf
--- /dev/null
+++ b/LICENSE-MIT
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Andy Triboletti
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 509bf31..815dd1e 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,263 @@
-# blacklight
\ No newline at end of file
+# blacklight
+
+**Verified-streaming downloads anchored in the Sigstore transparency log —
+tamper is caught mid-transfer, at the first bad byte, by a publisher you can
+name.**
+
+## Why
+
+The folk instinct is right: *"check the hash and a hacker can't tamper with your
+download."* The usual ways of doing it are not.
+
+- **MD5 is collision-broken.** An attacker can build a malicious file that
+ matches a published MD5. Even SHA-256 doesn't save you if the checksum is
+ delivered the wrong way (next point).
+- **An in-band checksum is replaceable by the same MITM.** If the `SHA256SUMS`
+ file sits next to the artifact on the same mirror, whoever can rewrite the
+ artifact can rewrite the checksum too. Both ride the same untrusted channel;
+ there's no independent anchor.
+- **TLS blinds the network layer instead of securing the artifact.** TLS
+ authenticates *the server you reached* and encrypts the hop — but a malicious
+ or compromised CDN edge or mirror serves tampered bytes over perfectly valid
+ TLS, and TLS gives you no way to check what actually arrived.
+
+So integrity has to be **end-to-end** — verified at *your* machine against an
+out-of-band anchor — and the signature over the reference hash has to be
+**publicly auditable**, so you can name who published it and detect a
+compromised key after the fact.
+
+blacklight does exactly that: a Sigstore-transparency-logged signature covers a
+BLAKE3 Merkle root, and every 16 KiB chunk of the download is checked against
+that signed root **as it streams in**.
+
+## How it works
+
+- **Verified streaming.** The file's BLAKE3 Merkle tree is precomputed into a
+ tiny `.obao` sidecar (~0.4% overhead). On download, each 16 KiB chunk group is
+ hashed and checked against the signed root the instant it arrives — the bytes
+ from the untrusted mirror are never trusted, only verified.
+- **Transparency-logged signature.** The root lives in a small JSON manifest,
+ keyless-signed via Sigstore (OIDC → Fulcio short-lived cert → **Rekor public
+ transparency log**). The client verifies the signature, cert chain, and Rekor
+ inclusion proof **offline** against an embedded trusted root, and enforces the
+ exact signer identity + issuer you demand *before downloading a single byte*.
+- **Abort on the first bad byte.** On the first tampered chunk group, blacklight
+ aborts the transfer (exit code 3), deletes the partial file, and reports the
+ byte offset — having read at most one 16 KiB group past the tampering. A naive
+ `curl | sha256sum` must download the *entire* file before its hash can
+ mismatch.
+
+## Architecture
+
+### The big picture
+
+The publisher signs once; the four resulting files can be hosted anywhere —
+including hosts you assume are hostile — because the downloader verifies
+everything locally:
+
+```mermaid
+flowchart LR
+ subgraph PUB["Publisher (has an OIDC identity)"]
+ A["artifact"] --> P["blacklight publish"]
+ P --> OB[".obao\noutboard Merkle tree\n(~0.4% of file size)"]
+ P --> M["manifest\n.blacklight.json\n{name, size, blake3_root}"]
+ P --> B["Sigstore bundle\n.sigstore.json\n(sig + cert + log proof)"]
+ end
+
+ subgraph SIG["Sigstore infrastructure"]
+ F["Fulcio CA\n(short-lived cert\nfor OIDC identity)"]
+ R["Rekor\npublic transparency log"]
+ end
+ P -. "sign manifest keyless" .-> F
+ F -. "signature logged" .-> R
+ R -. "inclusion proof\nstapled into bundle" .-> B
+
+ subgraph HOST["Any host / mirror / CDN — UNTRUSTED"]
+ H1["artifact"]
+ H2[".obao"]
+ H3["manifest"]
+ H4["bundle"]
+ end
+ A --> H1
+ OB --> H2
+ M --> H3
+ B --> H4
+
+ subgraph CLI["Downloader"]
+ C["blacklight fetch\nverifies EVERYTHING locally;\ntrusts no byte from the host"]
+ end
+ H1 --> C
+ H2 --> C
+ H3 --> C
+ H4 --> C
+```
+
+### The trust chain
+
+Every link is verified before the next is trusted, from a human-meaningful
+identity all the way down to each 16 KiB group of bytes:
+
+```mermaid
+flowchart TD
+ I["OIDC identity\n(e.g. you@example.com,\nor a CI workflow)"]
+ C["Fulcio short-lived certificate\nbinds identity to an ephemeral key"]
+ S["Signature over the exact\nmanifest bytes"]
+ R["Rekor inclusion proof\nsignature is in the public,\nappend-only transparency log"]
+ RT["blake3_root\nthe file's BLAKE3 Merkle root,\ninside the signed manifest"]
+ OBV["Outboard tree (.obao)\nchecked to hash up to the\nsigned root before streaming"]
+ L["Per-group leaf hashes\none per 16 KiB chunk group"]
+ G["Each streamed 16 KiB group\nverified the instant it arrives\nfirst mismatch = abort, exit 3"]
+
+ I -->|"issuer + SAN pinned by\n--expect-identity / --expect-issuer"| C
+ C --> S
+ S --> R
+ R --> RT
+ RT --> OBV
+ OBV --> L
+ L --> G
+```
+
+### What `fetch` actually does
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant BL as blacklight fetch
+ participant M as Mirror / CDN (untrusted)
+ participant D as Local disk
+
+ BL->>M: GET manifest + bundle
+ M-->>BL: manifest bytes + Sigstore bundle
+ Note over BL: Verify bundle OFFLINE:
signature over manifest bytes,
Fulcio cert chain, Rekor inclusion
proof, pinned identity + issuer.
Any failure → stop before
downloading a single artifact byte.
+
+ BL->>M: GET .obao (outboard tree)
+ M-->>BL: outboard bytes
+ Note over BL: Check the outboard hashes up
to the signed blake3_root.
Tampered tree dies here.
+
+ BL->>M: GET artifact (streamed)
+ loop every 16 KiB chunk group
+ M-->>BL: group bytes
+ Note over BL: hash group (BLAKE3 chaining value),
compare against the verified tree
+ BL->>D: append verified group to temp file
+ end
+
+ alt a group does not match (tampering)
+ Note over BL,M: ABORT transfer immediately —
at most one 16 KiB group read past
the tampered byte
+ BL->>D: delete temp file
+ Note over BL: exit code 3, report byte offset
+ else all groups match + length equals signed size
+ BL->>D: atomic rename into place
+ Note over BL: exit 0 — every byte verified
+ end
+```
+
+## Install
+
+```sh
+cargo build --release
+# binary at ./target/release/blacklight
+```
+
+Rust edition 2024 (MSRV ~1.85+).
+
+## Quickstart
+
+### Publish
+
+Hash a file, build its outboard tree, write the manifest, and keyless-sign it:
+
+```sh
+# Signs via Sigstore STAGING by default (add --production for the public-good
+# instance). Signing is keyless: ambient CI OIDC if present, else a browser.
+blacklight publish demo.bin --url https://mirror.example.org/demo.bin
+```
+
+This produces four files to host — the artifact plus three sidecars:
+
+```text
+demo.bin # the artifact (unchanged)
+demo.bin.obao # outboard BLAKE3 Merkle tree
+demo.bin.blacklight.json # signed manifest {root, size, geometry}
+demo.bin.blacklight.json.sigstore.json # Sigstore v0.3 bundle
+```
+
+For local testing without signing: `blacklight publish demo.bin --unsigned`
+(emits only the `.obao` + manifest).
+
+### Fetch
+
+Download and verify, pinning *who* must have signed the root:
+
+```sh
+blacklight fetch https://mirror.example.org/demo.bin.blacklight.json \
+ --expect-identity you@example.com \
+ --expect-issuer https://accounts.google.com \
+ -o demo.bin
+```
+
+blacklight verifies the bundle offline, enforces the identity + issuer policy
+**before** touching the artifact, then streams and verifies every group. Use
+`--production` to verify against the Sigstore production trust root, and
+`--allow-unsigned` to skip signature verification entirely (**dangerous** — the
+download is still integrity-checked against the manifest root, but nothing proves
+*who* published that root).
+
+## See it catch an attack
+
+```sh
+bash demo/run_demo.sh
+```
+
+The demo publishes a file, serves it from an honest origin, and fetches it twice:
+once directly (succeeds, byte-identical) and once through a tampering MITM proxy
+that flips one byte mid-stream. blacklight aborts at the first bad 16 KiB group,
+leaving no output file — and it's contrasted against `curl | sha256sum`, which
+must read the whole file first. Sample metrics (32 MiB file, byte flipped at
+offset 16,000,000):
+
+```text
+================== DETECTION METRICS ==================
+tampered byte offset 16000000
+total artifact size (bytes) 33554432
+blacklight: bytes consumed before 16007168
+ detection (one 16 KiB group)
+curl+sha256: bytes consumed before 33554432
+ detection (whole file)
+data blacklight avoided reading 2.1x less
+```
+
+The tampered byte at offset 16,000,000 lands in **chunk group 976** (byte offset
+15,990,784); blacklight aborts there, having verified only ~16 MB, while
+`curl | sha256sum` reads all 32 MB before its hash mismatches. Measured outboard
+overhead: **0.39%**. The test suite (`cargo test`) is 13 tests — 9 unit plus 4
+integration that drive the real binary over a local HTTP server, including
+tampered-artifact, tampered-outboard, and truncated-stream attacks.
+
+## What it does NOT do yet
+
+- **No rollback/freshness protection.** A validly signed *older* version replays
+ cleanly — that's TUF's domain.
+- **No active log monitoring.** A compromised signing identity is *detectable*
+ (every signature is in Rekor) but blacklight doesn't watch the log for you.
+- **Keyless signing only.** sigstore-rust 0.10 has no self-managed-key path.
+- **No outboard redundancy.** A withheld `.obao` fails the fetch closed.
+
+## Design & background
+
+- [`docs/DESIGN.md`](docs/DESIGN.md) — full threat model, the end-to-end trust
+ chain, on-disk/on-wire formats, and the `fetch` state machine.
+- [`paper/PAPER.md`](paper/PAPER.md) — the write-up and motivation.
+
+## Honesty note
+
+Every cryptographic primitive here pre-exists and is battle-tested: BLAKE3 for
+hashing and tree math, `bao-tree` (n0-computer, the engine behind iroh-blobs) for
+the outboard tree, and Sigstore (Fulcio + Rekor, via sigstore-rust) for keyless
+transparency-logged signatures. **blacklight's contribution is the
+composition** — wiring a transparency-logged signature over a BLAKE3 root into a
+forward-only, fail-fast, abort-on-first-bad-byte streaming verifier.
+
+## License
+
+Dual-licensed under either [MIT](LICENSE-MIT) or
+[Apache-2.0](LICENSE-APACHE), at your option.
diff --git a/demo/evil_proxy.py b/demo/evil_proxy.py
new file mode 100755
index 0000000..5b57d0f
--- /dev/null
+++ b/demo/evil_proxy.py
@@ -0,0 +1,115 @@
+#!/usr/bin/env python3
+"""A deliberately malicious HTTP proxy — the man-in-the-middle for the demo.
+
+It forwards GET requests to an upstream origin but, for the target artifact,
+flips one byte at a configurable offset as the bytes stream through. This is
+exactly the tampering-in-transit that blacklight is designed to catch: the
+origin is honest, the network is not.
+
+Everything else (the manifest, the .obao tree, the Sigstore bundle) is passed
+through untouched, modelling an attacker who can rewrite payload bytes but
+cannot forge the publisher's signature over the manifest.
+
+Standard library only. Python 3.8+.
+
+ python3 evil_proxy.py --listen 8081 --origin http://127.0.0.1:8080 \\
+ --target /demo.bin --offset 5000000
+"""
+
+import argparse
+import http.server
+import sys
+import urllib.request
+
+ARGS = None
+
+
+class MitmHandler(http.server.BaseHTTPRequestHandler):
+ protocol_version = "HTTP/1.1"
+
+ def log_message(self, fmt, *a): # keep the demo output readable
+ sys.stderr.write("[evil_proxy] " + (fmt % a) + "\n")
+
+ def do_GET(self):
+ upstream = ARGS.origin.rstrip("/") + self.path
+ tamper = self.path == ARGS.target
+ # Once the status line + headers are on the wire we can no longer send an
+ # HTTP error response — doing so would emit a second status line into the
+ # same response. After that point, failures are logged and the
+ # connection is simply dropped.
+ headers_sent = False
+ try:
+ with urllib.request.urlopen(upstream, timeout=30) as resp:
+ body_len = resp.headers.get("Content-Length")
+ self.send_response(resp.status)
+ # Force plain streaming: known length, no transfer-encoding games.
+ for k, v in resp.headers.items():
+ if k.lower() in ("transfer-encoding", "connection", "content-length"):
+ continue
+ self.send_header(k, v)
+ if body_len is not None:
+ self.send_header("Content-Length", body_len)
+ self.send_header("Connection", "close")
+ self.end_headers()
+ headers_sent = True
+
+ if tamper:
+ sys.stderr.write(
+ f"[evil_proxy] TAMPERING with {self.path}: "
+ f"flipping byte at offset {ARGS.offset}\n"
+ )
+
+ pos = 0
+ while True:
+ chunk = resp.read(64 * 1024)
+ if not chunk:
+ break
+ if tamper and pos <= ARGS.offset < pos + len(chunk):
+ i = ARGS.offset - pos
+ chunk = bytearray(chunk)
+ chunk[i] ^= 0xFF # flip every bit of one byte
+ chunk = bytes(chunk)
+ try:
+ self.wfile.write(chunk)
+ except (BrokenPipeError, ConnectionResetError):
+ # The client aborted mid-stream — for blacklight under
+ # attack, this is the whole point.
+ sys.stderr.write(
+ f"[evil_proxy] client hung up after ~{pos + len(chunk)} bytes "
+ "(verified-streaming client aborting early)\n"
+ )
+ return
+ pos += len(chunk)
+ except Exception as e: # noqa: BLE001 - demo tool, surface anything
+ if headers_sent:
+ # A 200 response is already in flight; we can't turn it into an
+ # error. Log and let the connection close.
+ sys.stderr.write(f"[evil_proxy] upstream error mid-stream: {e}\n")
+ else:
+ self.send_error(502, f"upstream error: {e}")
+
+
+def main():
+ global ARGS
+ p = argparse.ArgumentParser(description="Tampering MITM proxy for the blacklight demo")
+ p.add_argument("--listen", type=int, default=8081)
+ p.add_argument("--origin", default="http://127.0.0.1:8080")
+ p.add_argument("--target", default="/demo.bin",
+ help="path to tamper with; everything else passes through")
+ p.add_argument("--offset", type=int, default=5_000_000,
+ help="byte offset within the target to corrupt")
+ ARGS = p.parse_args()
+
+ srv = http.server.ThreadingHTTPServer(("127.0.0.1", ARGS.listen), MitmHandler)
+ sys.stderr.write(
+ f"[evil_proxy] listening on :{ARGS.listen}, forwarding to {ARGS.origin}, "
+ f"tampering {ARGS.target}@{ARGS.offset}\n"
+ )
+ try:
+ srv.serve_forever()
+ except KeyboardInterrupt:
+ pass
+
+
+if __name__ == "__main__":
+ main()
diff --git a/demo/run_demo.sh b/demo/run_demo.sh
new file mode 100755
index 0000000..84da4b6
--- /dev/null
+++ b/demo/run_demo.sh
@@ -0,0 +1,106 @@
+#!/usr/bin/env bash
+# End-to-end attack demo for blacklight.
+#
+# Publishes a file, serves it from an honest origin, then fetches it two ways:
+# 1. directly from the honest origin -> succeeds, bytes match
+# 2. through a tampering MITM proxy -> blacklight aborts mid-stream at the
+# first bad 16 KiB chunk group, leaving no output file
+#
+# and contrasts blacklight with the naive `curl | sha256sum` baseline, which
+# must download the entire file before it can notice the tampering.
+#
+# Usage: demo/run_demo.sh [SIZE_MB] [TAMPER_OFFSET]
+set -euo pipefail
+
+SIZE_MB="${1:-64}"
+OFFSET="${2:-$(( SIZE_MB * 1024 * 1024 / 2 ))}" # default: tamper near the middle
+GROUP=16384
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+BL="$ROOT/target/release/blacklight"
+WORK="$(mktemp -d)"
+ORIGIN_PORT=8080
+PROXY_PORT=8081
+
+cleanup() { [ -n "${ORIGIN_PID:-}" ] && kill "$ORIGIN_PID" 2>/dev/null || true
+ [ -n "${PROXY_PID:-}" ] && kill "$PROXY_PID" 2>/dev/null || true
+ rm -rf "$WORK"; }
+trap cleanup EXIT
+
+echo "==> building release binary"
+( cd "$ROOT" && cargo build --release --quiet )
+
+echo "==> creating ${SIZE_MB} MiB test artifact"
+python3 - "$WORK/demo.bin" "$SIZE_MB" <<'PY'
+import sys
+path, mb = sys.argv[1], int(sys.argv[2])
+with open(path, "wb") as f:
+ buf = bytes((i * 7 + 3) % 256 for i in range(1024 * 1024))
+ for _ in range(mb):
+ f.write(buf)
+PY
+
+echo "==> blacklight publish (unsigned; signing is exercised separately against Sigstore)"
+"$BL" publish "$WORK/demo.bin" --unsigned
+
+echo "==> starting honest origin :$ORIGIN_PORT and tampering proxy :$PROXY_PORT (flip @ $OFFSET)"
+( cd "$WORK" && python3 -m http.server "$ORIGIN_PORT" --bind 127.0.0.1 >/dev/null 2>&1 ) &
+ORIGIN_PID=$!
+python3 "$ROOT/demo/evil_proxy.py" --listen "$PROXY_PORT" \
+ --origin "http://127.0.0.1:$ORIGIN_PORT" --target /demo.bin --offset "$OFFSET" \
+ >"$WORK/proxy.log" 2>&1 &
+PROXY_PID=$!
+sleep 1
+
+echo
+echo "################ SCENARIO 1: honest origin ################"
+if "$BL" fetch "http://127.0.0.1:$ORIGIN_PORT/demo.bin.blacklight.json" \
+ --allow-unsigned -o "$WORK/clean.out"; then
+ cmp -s "$WORK/clean.out" "$WORK/demo.bin" \
+ && echo ">> clean download verified and byte-identical to the original."
+else
+ echo ">> UNEXPECTED: clean download failed"; exit 1
+fi
+
+echo
+echo "################ SCENARIO 2: tampering MITM proxy ################"
+set +e
+"$BL" fetch "http://127.0.0.1:$PROXY_PORT/demo.bin.blacklight.json" \
+ --allow-unsigned -o "$WORK/tampered.out"
+BL_EXIT=$?
+set -e
+echo ">> blacklight exit code: $BL_EXIT (3 = integrity violation)"
+# This is the security assertion, not just a report: the tampered fetch MUST
+# fail with the integrity exit code and MUST NOT leave an output file behind.
+if [ "$BL_EXIT" -ne 3 ]; then
+ echo ">> FAIL: expected exit code 3 (integrity violation), got $BL_EXIT"; exit 1
+fi
+if [ -f "$WORK/tampered.out" ]; then
+ echo ">> FAIL: a partial/tampered output file was left behind"; exit 1
+fi
+echo ">> no output file was written — the tampered bytes never reached disk as 'good'."
+
+echo
+echo "################ BASELINE: curl | sha256sum ################"
+GOOD_SHA=$(sha256sum "$WORK/demo.bin" | cut -d' ' -f1)
+BYTES_BEFORE_DETECT=$(( (OFFSET / GROUP + 1) * GROUP ))
+BASELINE_BYTES=$(( SIZE_MB * 1024 * 1024 ))
+curl -s "http://127.0.0.1:$PROXY_PORT/demo.bin" -o "$WORK/curl.out"
+BAD_SHA=$(sha256sum "$WORK/curl.out" | cut -d' ' -f1)
+[ "$GOOD_SHA" != "$BAD_SHA" ] && echo ">> curl completed the FULL download; only then does the hash mismatch."
+
+echo
+echo "================== DETECTION METRICS =================="
+printf '%-38s %15s\n' "tampered byte offset" "$OFFSET"
+printf '%-38s %15s\n' "total artifact size (bytes)" "$BASELINE_BYTES"
+printf '%-38s %15s\n' "blacklight: bytes consumed before" "$BYTES_BEFORE_DETECT"
+printf '%-38s %15s\n' " detection (one 16 KiB group)" ""
+printf '%-38s %15s\n' "curl+sha256: bytes consumed before" "$BASELINE_BYTES"
+printf '%-38s %15s\n' " detection (whole file)" ""
+RATIO=$(python3 -c "print(f'{$BASELINE_BYTES/$BYTES_BEFORE_DETECT:.1f}x')")
+printf '%-38s %15s\n' "data blacklight avoided reading" "$RATIO less"
+echo
+echo "(Note: bytes *consumed and verified* by the client. Actual bytes on the"
+echo " wire can be higher due to TCP/HTTP read-ahead buffering — see proxy.log —"
+echo " but no unverified byte is ever accepted or written as good output.)"
+grep 'client hung up' "$WORK/proxy.log" || true
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
new file mode 100644
index 0000000..282a662
--- /dev/null
+++ b/docs/DESIGN.md
@@ -0,0 +1,419 @@
+# blacklight — Design
+
+Verified-streaming downloads anchored in the Sigstore transparency log.
+
+blacklight downloads a file from an **untrusted** network and mirror, verifying
+every 16 KiB chunk group against a signed BLAKE3 Merkle root *as the bytes
+arrive*, and aborting the transfer on the first tampered byte. The root is
+authorized by a keyless Sigstore signature that is recorded in the public Rekor
+transparency log, so the identity that published the root is provable and
+publicly auditable.
+
+This document is the engineering reference: goals, threat model, the end-to-end
+trust chain, the on-disk and on-wire formats, the `fetch` state machine, the
+end-to-end argument for why weaker schemes fail, and the limitations.
+
+Everything here is grounded in the actual code. Key modules:
+
+| Module | Responsibility |
+| --- | --- |
+| [`src/main.rs`](../src/main.rs) | CLI (`clap`), subcommand dispatch, exit-code mapping |
+| [`src/publish.rs`](../src/publish.rs) | hash file, build outboard, write manifest, sign |
+| [`src/fetch.rs`](../src/fetch.rs) | verify bundle, fetch outboard, stream + verify, finalize |
+| [`src/verify.rs`](../src/verify.rs) | forward-only fail-fast BLAKE3 group verifier (`GroupPlan`) |
+| [`src/manifest.rs`](../src/manifest.rs) | the signed manifest document |
+| [`src/sigstore.rs`](../src/sigstore.rs) | keyless signing + offline verification wrappers |
+
+---
+
+## 1. Goals and non-goals
+
+### Goals
+
+- **End-to-end integrity.** The client accepts artifact bytes only if they hash
+ up to a root that a named publisher signed. No hop in the delivery path (WiFi,
+ ISP, CDN, mirror) is trusted with byte integrity.
+- **Fail fast, mid-stream.** Detect tampering at the first bad 16 KiB group and
+ abort the transfer, rather than after downloading the whole file. At most one
+ chunk group (16 KiB) is fetched past the tampered byte.
+- **Publicly auditable provenance.** Every signature is a keyless Sigstore
+ signature recorded in Rekor, so *who* signed a given root is a matter of
+ public record, and a compromised signing identity is detectable after the fact.
+- **Offline verification.** `fetch` verifies the signature, the Fulcio
+ certificate chain, and the Rekor inclusion proof against an *embedded* trusted
+ root, with no network round-trip to Sigstore at fetch time.
+- **Policy before bytes.** The required signer identity and OIDC issuer are
+ enforced *before any artifact byte is downloaded*.
+- **Small overhead.** The outboard Merkle tree adds ~0.4% (measured 0.39% on the
+ demo artifact) to what the publisher hosts.
+
+### Non-goals (v1)
+
+- **Rollback / freshness.** An attacker who serves a *stale but validly signed*
+ older version is not stopped. Freshness is TUF's domain; blacklight has no
+ version/timestamp metadata that a client can require to be current.
+- **Availability of the outboard.** If the `.obao` sidecar is missing or
+ withheld, `fetch` fails closed (cannot build a plan) but blacklight does not
+ provide redundancy or a fallback.
+- **Active transparency-log monitoring.** blacklight verifies a Rekor *inclusion
+ proof* offline; it does not run a witness or continuously monitor the log for
+ rogue entries under a given identity. That is deferred to `rekor-monitor` /
+ witness infrastructure.
+- **Self-managed signing keys.** sigstore-rust 0.10 exposes no self-managed-key
+ path. Signing is **keyless only** (OIDC → Fulcio short-lived cert).
+- **Confidentiality.** blacklight is an integrity/provenance tool. It does not
+ encrypt artifacts.
+
+---
+
+## 2. Threat model
+
+The trust boundary: **the publisher's Sigstore identity is trusted; everything
+between the publisher and the client is not.** The WiFi link, the network, the
+ISP, the CDN, and the mirror can all read and rewrite any bytes in transit. What
+they cannot do is forge a Sigstore signature that verifies against the embedded
+trusted root under the identity + issuer the client demands.
+
+| Attacker capability | blacklight's defense |
+| --- | --- |
+| Flip / rewrite bytes of the **artifact** in transit (MITM, malicious CDN/mirror) | Each 16 KiB group is checked against the signed root the instant it completes ([`GroupPlan::check_group`](../src/verify.rs)); first mismatch → `TamperError`, exit 3, partial file deleted, byte offset reported |
+| **Truncate** the artifact stream (drop the tail) | Exact length check: `total != plan.size()` and `group_index != plan.group_count()` are `TamperError` ([`stream_verified_inner`](../src/fetch.rs)) |
+| **Pad / append** bytes to the artifact | Same length check fails; and any group past the signed count has no expected CV (`VerifyError::LongStream`) |
+| Tamper with the **`.obao` outboard** tree | The outboard is reconstructed top-down and its computed root compared to the signed root in `GroupPlan::new`; a mismatch is `OutboardRootMismatch` / `BadOutboard`, caught *before* streaming any artifact byte |
+| Tamper with the **manifest** JSON (change root, size, name, URLs) | The Sigstore bundle signs the exact manifest bytes; `sigstore::verify_manifest` rejects any change before the manifest is even parsed ([`fetch.rs`](../src/fetch.rs) verifies bytes, *then* `Manifest::from_bytes`) |
+| Add extra JSON fields to the manifest to smuggle data | `#[serde(deny_unknown_fields)]` on `Manifest` rejects unknown keys |
+| Serve a **different file** signed by someone else | Client passes `--expect-identity` + `--expect-issuer`; `VerificationPolicy::require_identity/require_issuer` enforces an exact SAN + issuer match |
+| Present a **valid signature by the wrong issuer** (e.g. attacker's own Google account) | Issuer is pinned by `--expect-issuer`; a signature from an unexpected OIDC issuer fails the policy |
+| Strip the signature and serve an **unsigned** manifest | Without `--allow-unsigned`, a missing/invalid bundle is a hard failure ("refusing to download"). `--allow-unsigned` is opt-in and loudly warned |
+| Forge a **Rekor inclusion proof** | The inclusion proof is checked against Rekor's key material in the embedded trusted root; a forged proof does not verify |
+| **Compromise the publisher's signing identity** and sign a malicious root | *Detectable, not prevented.* Every signature is in the public Rekor log, so a rogue signature under the publisher's identity is discoverable by log monitoring (out of scope for v1, see §7) |
+| Serve a **stale but validly signed** older version | **Out of scope (v1).** No freshness metadata; see non-goals |
+| Downgrade TLS / present a bad TLS cert | Irrelevant to integrity — blacklight does not rely on TLS for artifact integrity; the artifact URL may even be plain `http://`. TLS is orthogonal (see §6) |
+
+A network read error (connection reset, timeout) is explicitly **not**
+tampering: it is surfaced as an ordinary error (exit 1), kept distinct from an
+integrity violation (exit 3) so scripts and tests can tell them apart
+([`stream_verified_inner`](../src/fetch.rs) wraps stream errors with `network
+error reading …`).
+
+---
+
+## 3. Trust chain, end to end
+
+The chain runs from a human/CI OIDC identity all the way down to a single
+streamed 16 KiB group. Each link is verified; no link is assumed.
+
+```
+OIDC identity (email or workload URI, at a named issuer)
+ │ proven at signing time by the OIDC token
+ ▼
+Fulcio short-lived certificate (SAN = identity, extension = issuer)
+ │ chains to a CA in the embedded trusted root
+ ▼
+Signature over the exact manifest bytes
+ │ covers {v, name, size, blake3_root, chunk_group_log, urls}
+ ▼
+Rekor inclusion proof (this signature is in the public transparency log)
+ │ verified offline against Rekor key material in the trusted root
+ ▼
+blake3_root (the signed 32-byte BLAKE3 Merkle root of the file)
+ │ bound because it is a field of the signed manifest
+ ▼
+per-16-KiB-group leaf chaining values (reconstructed from the .obao,
+ │ and checked to hash back up to blake3_root in GroupPlan::new)
+ ▼
+each streamed 16 KiB group (its recomputed CV must equal the expected leaf CV)
+```
+
+Concretely:
+
+1. **Identity → certificate.** At publish time, `sigstore::sign_manifest`
+ obtains an OIDC identity token — ambient CI credentials via
+ `IdentityToken::detect_ambient()` if present, otherwise an interactive
+ browser flow via `get_identity_token`. Fulcio issues a short-lived
+ certificate whose Subject Alternative Name is the identity and which records
+ the OIDC issuer.
+
+2. **Certificate → signature → log.** The `SigningContext` signer signs the
+ manifest bytes; the signature and a Rekor inclusion proof are stapled into a
+ **Sigstore v0.3 bundle** (`bundle.to_json_pretty()`).
+
+3. **Signature → root (verification).** At fetch time, `sigstore::verify_manifest`
+ loads the *embedded* trusted root
+ (`SIGSTORE_STAGING_TRUSTED_ROOT` / `SIGSTORE_PRODUCTION_TRUSTED_ROOT`),
+ parses the bundle, and runs `sigstore_verify::verify` over the raw manifest
+ bytes with a `VerificationPolicy` that requires the exact identity + issuer.
+ This checks the signature, the Fulcio cert chain, and the Rekor inclusion
+ proof **offline**. Only after this succeeds does [`fetch.rs`](../src/fetch.rs)
+ call `Manifest::from_bytes` and trust `blake3_root`.
+
+4. **Root → leaf CVs.** `GroupPlan::new(root, size, outboard)` walks the
+ outboard tree top-down, reconstructing each group's expected leaf chaining
+ value, and verifies the whole tree hashes back up to `root`. A tampered
+ outboard cannot reach the signed root and is rejected here.
+
+5. **Leaf CV → streamed group.** As each 16 KiB group lands,
+ `GroupPlan::check_group` recomputes its BLAKE3 chaining value with the correct
+ input offset and compares it to the expected leaf CV. First mismatch aborts.
+
+Because the manifest is verified as **raw bytes before parsing**, and it fixes
+`chunk_group_log`, the *tree geometry itself* is part of the signed statement —
+an attacker cannot re-shape the tree to smuggle bytes.
+
+---
+
+## 4. On-disk and on-wire formats
+
+`publish` emits three sidecars next to the artifact. The publisher hosts four
+files total: the artifact, the `.obao`, the manifest, and the bundle.
+
+| File | Suffix | Contents |
+| --- | --- | --- |
+| Artifact | *(none)* | The original file, byte-for-byte |
+| Outboard | `.obao` | Pre-order BLAKE3 Merkle tree at 16 KiB groups |
+| Manifest | `.blacklight.json` | Signed JSON binding root, size, geometry |
+| Bundle | `.blacklight.json.sigstore.json` | Sigstore v0.3 bundle over the manifest bytes |
+
+Suffixes are defined once in [`src/manifest.rs`](../src/manifest.rs):
+`MANIFEST_SUFFIX = ".blacklight.json"`, `OUTBOARD_SUFFIX = ".obao"`,
+`BUNDLE_SUFFIX = ".sigstore.json"`.
+
+### 4.1 Manifest JSON
+
+The manifest is the small signed document. Schema (from the `Manifest` struct):
+
+| Field | Type | Meaning |
+| --- | --- | --- |
+| `v` | u32 | Format version. Must be `1`. |
+| `name` | string | Basename of the artifact (informational; default output name). |
+| `size` | u64 | Exact file length in bytes. |
+| `blake3_root` | string | 64-hex-char BLAKE3 root hash. Equals `blake3::hash(file)`. |
+| `chunk_group_log` | u8 | Log2 of chunk-group size in 1 KiB chunks. Fixed at `4` (16 KiB) for v1. |
+| `urls` | array of strings | Optional artifact URL hints. Omitted when empty. |
+
+It is serialized with `serde_json::to_vec_pretty` plus a trailing newline
+(`Manifest::to_bytes`). There is **deliberately no canonicalization**: the exact
+bytes the publisher signed are the exact bytes the client must present and
+verify. Parsing uses `#[serde(deny_unknown_fields)]`, and `from_bytes` rejects
+any `v != 1`, any `chunk_group_log != 4`, and any non-hex root.
+
+Real example (`demo.bin`, 32 MiB):
+
+```json
+{
+ "v": 1,
+ "name": "demo.bin",
+ "size": 33554432,
+ "blake3_root": "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262",
+ "chunk_group_log": 4,
+ "urls": [
+ "https://mirror.example.org/demo.bin"
+ ]
+}
+```
+
+(The `blake3_root` above is illustrative; the real value is printed by
+`publish` as `root `.)
+
+### 4.2 The `.obao` outboard tree
+
+The outboard is built by the `bao-tree` crate (`PreOrderOutboard::create` with
+`BlockSize::from_chunk_log(4)`). "Outboard" means the tree lives in a **separate
+file** from the artifact — the artifact stays byte-for-byte identical, and the
+tree is a ~0.4% sidecar.
+
+Layout: a **pre-order** sequence of 64-byte parent nodes, each node being a pair
+`(left CV, right CV)` of 32-byte BLAKE3 chaining values. Leaf chaining values are
+**not** stored — they are exactly the child CVs held in the lowest parents, and
+the client recomputes each leaf from the streamed data. Grouping chunks into
+16 KiB blocks reduces the number of parents ~16× versus per-1-KiB-chunk BLAKE3,
+which is where the low overhead comes from.
+
+Crucially, **the outboard's root equals `blake3::hash(file)`**. Chunk grouping
+changes only the *layout* of the outboard, not the hashing math: BLAKE3's tree
+is defined over 1 KiB chunks regardless, and a 16 KiB group is just a subtree of
+16 chunks. The unit test `plan_root_matches_blake3` in
+[`src/verify.rs`](../src/verify.rs) asserts `ob.root == blake3::hash(&data)`
+across many sizes, which is what lets a single BLAKE3 root in the manifest
+authorize the grouped tree.
+
+Edge cases the verifier encodes:
+
+- **Empty file (`size == 0`):** root is `blake3::hash(b"")`; zero groups.
+- **Single group (`size <= 16 KiB`):** the whole file is one leaf whose *root*
+ hash is the trusted root; the outboard is empty (no parents). `GroupPlan::new`
+ rejects a non-empty outboard here (`"single-group outboard must be empty"`).
+- **Trailing bytes / short outboard:** rejected as `BadOutboard`.
+
+### 4.3 The Sigstore bundle
+
+`.blacklight.json.sigstore.json` is a **Sigstore v0.3 bundle** (`sigstore-types`
+`Bundle`): the signature over the manifest bytes, the Fulcio signing
+certificate, and the Rekor inclusion proof, as JSON. It is self-contained enough
+for offline verification against the embedded trusted root — no call to
+Fulcio/Rekor at fetch time.
+
+---
+
+## 5. The `fetch` state machine
+
+From [`src/fetch.rs`](../src/fetch.rs). Each stage gates the next; nothing about
+the artifact is trusted until both the bundle and every group have verified.
+
+```
+ ┌─────────────────────────────┐
+ │ 1. LOAD manifest bytes │ load() — URL or local path
+ │ (raw, unparsed) │
+ └──────────────┬──────────────┘
+ │
+ --allow-unsigned? ─┴─ no ──► ┌──────────────────────────────────┐
+ │ yes │ 2. VERIFY BUNDLE (offline) │
+ │ (warn loudly) │ sigstore::verify_manifest │
+ │ │ - signature over manifest bytes │
+ │ │ - Fulcio cert chain │
+ │ │ - Rekor inclusion proof │
+ │ │ - require identity + issuer │
+ │ │ FAIL ► exit 1, no download │
+ └──────────┬───────────┴────────────┬─────────────────────┘
+ │ │ ok
+ ▼ ▼
+ ┌─────────────────────────────────────────────┐
+ │ 3. PARSE manifest (Manifest::from_bytes) │ now trusted
+ │ extract blake3_root, size │
+ └───────────────────────┬──────────────────────┘
+ ▼
+ ┌─────────────────────────────────────────────┐
+ │ 4. FETCH outboard (.obao, downloaded fully) │
+ └───────────────────────┬──────────────────────┘
+ ▼
+ ┌─────────────────────────────────────────────┐
+ │ 5. BUILD + SELF-CHECK GroupPlan │
+ │ GroupPlan::new(root, size, outboard) │
+ │ tampered .obao ► TamperError, no download │
+ └───────────────────────┬──────────────────────┘
+ ▼
+ ┌─────────────────────────────────────────────┐
+ │ 6. STREAM artifact into .blacklight- │
+ │ partial; buffer 16 KiB groups │
+ │ each full group ► GroupPlan::check_group │
+ │ mismatch ► TamperError (exit 3): │
+ │ delete partial, report byte offset │
+ │ final short group verified too │
+ │ length + group-count must match exactly │
+ └───────────────────────┬──────────────────────┘
+ ▼
+ ┌─────────────────────────────────────────────┐
+ │ 7. FINALIZE: flush, fsync, atomic rename into │
+ │ place. On ANY error the partial is removed. │
+ └─────────────────────────────────────────────┘
+```
+
+Notes:
+
+- **Streaming buffer.** `stream_verified_inner` pulls `reqwest` byte chunks and
+ re-buckets them into exactly `GROUP_LEN` (16 KiB) groups regardless of how the
+ transport framed them, verifying each complete group before writing it.
+- **No partial ever looks good.** The download goes to a
+ `.blacklight-partial` temp file; on any failure it is deleted, and only a
+ fully verified file is atomically `rename`d into place. This is why the
+ integration tests assert `!out.exists()` after a tampered fetch.
+- **URL resolution.** The artifact URL is: `--url` override, else the manifest
+ location with `.blacklight.json` stripped, else the first manifest `urls` hint
+ (`resolve_artifact_url`). Integrity never depends on which of these is used.
+- **Exit codes.** `TamperError` → exit **3** (mapped in
+ [`src/main.rs`](../src/main.rs)); any other error → exit **1**; success → **0**.
+
+---
+
+## 6. Why weaker schemes don't stop a MITM (the end-to-end argument)
+
+The folk instinct — "check the hash and hackers can't tamper with your download"
+— is correct in spirit but fails in every common instantiation, because of the
+classic end-to-end argument: an integrity check is only as strong as the trust
+you have in *where the reference value came from* and *when you check it*.
+
+- **In-band / same-channel checksum.** Hosting a `SHA256SUMS` file next to the
+ artifact protects nothing against a MITM: the same attacker who rewrites the
+ artifact also rewrites the checksum file. Both travel the same untrusted
+ channel. There is no independent anchor.
+
+- **A weak or broken hash (MD5).** MD5 is collision-broken; an attacker can craft
+ a malicious artifact that matches a published MD5. Even a strong hash doesn't
+ help if it's delivered in-band (previous point). blacklight uses BLAKE3 *and*
+ anchors the root in a signature.
+
+- **TLS "protects the download."** TLS authenticates the *server you connected
+ to* and encrypts the hop, but (a) it says nothing about whether that server —
+ a CDN edge, a mirror — is honest about the bytes, (b) a compromised or
+ malicious mirror serves tampered bytes over perfectly valid TLS, and (c) TLS
+ blinds *you* to the network layer, so you cannot inspect what arrived. TLS is
+ transport security, not artifact provenance. blacklight deliberately does not
+ depend on it: the artifact URL can be plain `http://` and integrity is
+ unchanged.
+
+- **Network-level / middlebox integrity checking.** Any check performed by the
+ network (a proxy, a middlebox, an "integrity-verifying CDN") is performed by a
+ party inside the untrusted region. It can lie about the result.
+
+The end-to-end fix, which blacklight implements: the integrity reference (the
+BLAKE3 root) must be **bound to a publisher identity by a signature**, that
+signature must be **publicly auditable** (Rekor), the reference must be verified
+**at the client** against an **out-of-band anchor** (the embedded trusted root),
+and the artifact bytes must be checked against it **as they arrive** — not by a
+hop in the middle, and not after the whole (possibly malicious) file has already
+been consumed.
+
+---
+
+## 7. Limitations and future work
+
+- **Rollback / freshness (biggest gap).** A validly signed *older* version
+ replays cleanly. Mitigation is TUF-style metadata (version, timestamp,
+ expiry) that the client can require to be current. Not in v1.
+- **Log monitoring.** blacklight verifies a Rekor inclusion proof but does not
+ monitor the log for rogue entries under a publisher's identity. A compromised
+ signing identity is *detectable* only if someone is watching the log
+ (`rekor-monitor` / witnesses). Making key-compromise *auditable in practice*
+ needs that external piece.
+- **Keyless only.** No self-managed-key signing path exists in sigstore-rust
+ 0.10, so air-gapped or offline signing is not supported.
+- **Outboard availability.** A withheld `.obao` fails the fetch closed but there
+ is no redundancy/fallback.
+- **Fixed geometry.** `chunk_group_log` is pinned at 4 (16 KiB) for format v1.
+ Larger artifacts might prefer larger groups (fewer parents, coarser abort
+ granularity); this would be a v2 format bump.
+- **Single artifact URL at a time.** No multi-source / range-parallel fetching;
+ the verifier is forward-only by design (see below), which trades random-access
+ speed for strict abort-on-first-bad-byte semantics.
+
+### Why the verifier is hand-rolled
+
+`bao-tree` ships a verifier (`valid_ranges`), but it needs *seekable* (random
+access) data and it signals corruption by *omitting* a range from its result
+rather than raising an error — the opposite of the "stop the instant a byte is
+wrong, forward-only, over a stream" behavior that is the entire point here. So
+[`src/verify.rs`](../src/verify.rs) implements a forward-only, fail-fast verifier
+directly on the public `blake3::hazmat` chaining-value API (the same primitives
+`bao-tree` uses internally): it reconstructs the expected per-group leaf CVs from
+the outboard once (checking the tree up to the trusted root), then stream-checks
+each 16 KiB group's recomputed CV as it lands. It is unit-tested for round-trip
+equivalence with `bao-tree`'s own outboard format (`plan_root_matches_blake3`,
+`detects_single_byte_flip_in_each_group`, `detects_tampered_outboard`,
+`detects_wrong_root`).
+
+---
+
+## Appendix: primitives and versions
+
+All cryptographic primitives pre-exist; blacklight's contribution is the
+*composition*.
+
+| Component | Crate / version | Role |
+| --- | --- | --- |
+| Content hash + tree math | `blake3` 1.8.5 (hazmat API) | leaf CVs, subtree merges, root |
+| Outboard tree engine | `bao-tree` 0.16 (n0-computer; the engine behind iroh-blobs) | pre-order 16 KiB-group outboard |
+| Keyless signing/verify | `sigstore-{sign,verify,oidc,trust-root,types}` 0.10 (prefix-dev sigstore-rust; aws-lc-rs backend) | OIDC → Fulcio → Rekor; offline verify |
+| Transport | `reqwest` 0.13 + `tokio` 1.52 | streaming HTTP |
+| CLI | `clap` 4 | argument parsing |
+
+Rust edition 2024, MSRV ~1.85+.
diff --git a/paper/PAPER.md b/paper/PAPER.md
new file mode 100644
index 0000000..f3a9cb3
--- /dev/null
+++ b/paper/PAPER.md
@@ -0,0 +1,501 @@
+# Blacklight: Verified-Streaming Downloads Anchored in a Public Transparency Log
+
+**Andy Triboletti**
+*Draft — iteration one. 2026.*
+
+## Abstract
+
+Software is routinely downloaded over networks that the downloader has no
+reason to trust: café WiFi, third-party mirrors, content-delivery networks, and
+caching proxies can all rewrite bytes in transit. The folk defense — "publish a
+checksum of the file so people can verify what they downloaded" — is correct in
+spirit but fails in three specific ways that are worth naming precisely: MD5 (the
+checksum most commonly published) is collision-broken and offers no protection
+against a deliberate attacker; a checksum delivered over the same channel as the
+file can be replaced by the same man-in-the-middle that replaces the file; and
+performing the check "at the network level" is impossible for TLS-wrapped
+transfers and, more fundamentally, asks the untrusted middle to vouch for the
+data it is relaying. The correct place for integrity is the endpoint, and the
+correct binding is a *signature* whose authenticity is *publicly auditable*.
+
+We present **blacklight**, a download tool that composes two mature but
+previously-uncombined mechanisms into a single trust chain. First, a publisher's
+release is bound to a **BLAKE3 Merkle root** and signed with **Sigstore**, so
+the signature is keyless (tied to an OIDC identity through a short-lived
+certificate) and recorded in the **Rekor public transparency log**. Second, the
+client verifies the signed Merkle root *offline*, then **streams** the artifact
+and checks every 16 KiB chunk group against that root *as the bytes arrive*,
+aborting the transfer at the first byte that does not match. The result is
+integrity that is end-to-end (the network is never trusted), first-byte-to-last
+(tampering is caught mid-transfer, not after a full download), and publicly
+accountable (a compromised publisher key leaves a permanent, monitorable trail
+in Rekor).
+
+To our knowledge no existing tool combines chunk-granular verified streaming
+with transparency-log-anchored signatures in one verification path. We make the
+composition, its trust-chain design, and a working Rust implementation the
+contribution; all of the underlying cryptographic primitives pre-exist and are
+cited. A reproducible attack demonstration shows blacklight aborting a tampered
+32 MiB download after verifying ~16 MB, where the conventional
+`curl | sha256sum` pipeline must read the entire file before it can notice.
+
+## 1. Introduction
+
+### 1.1 A real question, and three tempting wrong answers
+
+The motivating question is one that many practitioners have asked in some form:
+*"I keep hearing about man-in-the-middle attacks and tampered downloads. Why
+isn't the download's checksum just verified automatically, at the network level,
+so bad bytes are rejected before they ever reach me?"* It is a good instinct.
+The reason it does not already work as imagined is instructive, because each
+plausible-sounding fix fails for a different, nameable reason.
+
+**Wrong answer 1: "Use the MD5 the project publishes."** MD5 collisions have
+been practical since 2004, and *chosen-prefix* collisions — where an attacker
+crafts a malicious file sharing the MD5 of a benign one — are well within reach.
+The Flame malware (2012) exploited exactly this class of weakness to forge a
+Microsoft code-signing certificate. A defense whose security rests on MD5 gives
+zero assurance against the deliberate attacker it is meant to stop. Any serious
+design uses a collision-resistant hash; blacklight uses BLAKE3.
+
+**Wrong answer 2: "Deliver the checksum next to the file."** If the attacker can
+rewrite the file in transit, it can rewrite the checksum sitting beside it. The
+client then dutifully verifies the attacker's file against the attacker's hash
+and reports success. This is not a hashing problem; it is a *bootstrapping* (or
+trust-anchoring) problem. RFC 9530's `Content-Digest`/`Repr-Digest` headers, for
+example, detect accidental corruption but — being in-band and unauthenticated —
+do nothing against a MITM unless the digest is independently signed and the
+signing key is anchored in something the client already trusts. The fix is a
+**digital signature** verifiable against a public key the device trusts a
+priori, not a better checksum.
+
+**Wrong answer 3: "Check it at the network level."** Two obstacles. Operationally,
+almost all downloads ride inside TLS; the access point or router sees ciphertext
+and cannot hash the plaintext artifact without terminating TLS — which is itself
+a man-in-the-middle. Architecturally, the deeper problem is that in a MITM
+scenario the network *is* the adversary, so putting the integrity check in the
+network asks the suspect to vouch for the evidence. This is precisely the
+situation the **end-to-end argument** [Saltzer, Reed & Clark 1984] addresses:
+a function that can only be completely and correctly implemented with knowledge
+held at the endpoints (here, the trusted public key and the expected root hash)
+should live at the endpoints. The link layer is not idle in a good design —
+WPA3-SAE with protected management frames (802.11w) closes the classic
+evil-twin/deauth attacks — but link security is orthogonal to *artifact*
+integrity and cannot substitute for it.
+
+**Wrong answer 4 (the ambitious one): "Put it on a blockchain."** The instinct
+here is that we want a record an attacker cannot quietly alter. That instinct is
+right; the mechanism is over-specified. Blockchain consensus exists so that
+mutually distrusting parties can agree on an ordering *without any authority*.
+But software has a natural authority — the publisher is the only party entitled
+to say what their release should hash to — so the consensus machinery solves a
+problem we do not have. What we actually want from "blockchain" is its
+*tamper-evident, append-only, publicly auditable* character, and that is exactly
+a **Merkle transparency log** (Section 2.2), which delivers non-equivocation and
+public auditability without mining, tokens, or global consensus.
+
+### 1.2 What survives, and what we build
+
+Strip the three tempting-but-wrong mechanisms away and three sound requirements
+remain, each mapping cleanly to an existing primitive:
+
+1. *Verification should be automatic and enforced, not a manual step users skip.*
+ → A signature over a collision-resistant hash, checked by the client before
+ the bytes are accepted.
+2. *There should be a record of what was published that no one can silently
+ rewrite.* → A public append-only Merkle transparency log (Sigstore/Rekor).
+3. *Bad bytes should be rejected as early as possible — ideally the tool should
+ "stop processing bits" the moment something is wrong.* → **Verified
+ streaming**: a signed Merkle tree over the file lets the client verify each
+ chunk as it arrives and abort on the first bad one, instead of hashing the
+ whole file after the fact.
+
+Blacklight is the tool that puts requirements 2 and 3 into a single trust chain.
+The remainder of this paper describes the design (Section 3), the
+implementation (Section 4), an evaluation including a reproducible attack demo
+(Section 5), how it relates to a substantial body of prior work (Section 6), and
+its honest limitations (Section 7).
+
+### 1.3 Contributions
+
+- **A composed trust chain** in which a transparency-logged, keyless signature
+ covers a BLAKE3 Merkle *root* that in turn authorizes per-chunk verification
+ *during* transfer — closing the gap between "we know who signed this whole
+ file" and "we can reject a bad byte before downloading the rest."
+- **A working, tested Rust implementation** (`publish`/`fetch`, ~1–2k LOC) built
+ on maintained crates (blake3, bao-tree, sigstore-rust) that signs against and
+ verifies against the real Sigstore infrastructure.
+- **A forward-only, fail-fast streaming verifier** that reproduces BLAKE3's tree
+ semantics using the public `blake3::hazmat` API, because the obvious library
+ routine (`bao_tree::valid_ranges`) requires seekable data and reports
+ corruption as an *omitted range* rather than an error — the wrong shape for
+ abort-on-first-bad-byte.
+- **A reproducible adversarial demonstration** (a tampering MITM proxy) with
+ measured detection latency, contrasted against the conventional
+ download-then-hash baseline.
+
+## 2. Background
+
+### 2.1 BLAKE3, Merkle trees, and verified streaming
+
+BLAKE3 [O'Connor, Aumasson, Neves & Wilcox-O'Hearn 2020] is internally a Merkle
+tree over 1 KiB input chunks: chunk chaining values are pairwise combined up to a
+single root. Because the hash is a tree, a verifier who holds the trusted root
+and the interior node hashes can validate any individual chunk or contiguous
+range *without* the rest of the file — the property BLAKE3's specification calls
+**verified streaming** (§6.4). **Bao** is the reference construction for this,
+defining a combined encoding (data interleaved with tree nodes) and an
+*outboard* encoding (the tree stored separately from the data). The idea of
+verifying subranges of a large file against a signed tree root before the whole
+file is present predates BLAKE3: the **Tree Hash EXchange** format (THEX, 2003,
+an expired Internet-Draft) used Tiger tree hashes for exactly this in Gnutella
+and DC++, and **BitTorrent v2** (BEP 52, shipped 2020) verifies each 16 KiB block
+against a per-file SHA-256 Merkle tree during transfer. What all of these share —
+and what blacklight adds to — is that the trust root is a bare, unauthenticated
+hash: they tell you the bytes match *a* root, not *whose* root.
+
+### 2.2 Transparency logs and Sigstore
+
+A transparency log is a public, append-only Merkle tree of entries that supports
+compact *inclusion proofs* ("entry X is in the log") and *consistency proofs*
+("the log only grew; nothing was rewritten"), so that even a misbehaving log
+operator cannot show different histories to different clients without detection.
+The design originates with **Certificate Transparency** [Laurie, Langley & Kasper,
+RFC 6962; updated as RFC 9162] and was given a general formal treatment by
+**Transparency Overlays** [Chase & Meiklejohn 2016]. Russ Cox's
+*Transparent Logs for Skeptical Clients* (2019) and the resulting Go checksum
+database (`sum.golang.org`) applied it to software packages at scale.
+
+**Sigstore** [Newman, Meyers & Torres-Arias 2022] combines three services into a
+turnkey signing system: an OIDC-federated certificate authority (**Fulcio**) that
+issues short-lived certificates binding an ephemeral key to a human/workload
+identity, and a transparency log (**Rekor**) that records every signature so it
+can be publicly monitored. The output is a self-describing *bundle* containing
+the signature, the certificate, and a stapled Rekor inclusion proof — which means
+a verifier can check everything **offline** once it holds the trust root.
+Blacklight signs the manifest bytes with Sigstore and verifies the resulting
+bundle offline against an embedded trust root.
+
+## 3. Design
+
+### 3.1 Threat model
+
+The adversary controls the network path end to end: the WiFi link, the router,
+the ISP, the CDN, and any mirror or caching proxy. It can read, drop, reorder,
+and — crucially — *rewrite* any bytes of any transfer. It can serve a completely
+different file, corrupt selected bytes, truncate, or pad. What it **cannot** do is
+forge the publisher's Sigstore signature over the manifest, because that would
+require either the publisher's OIDC identity or a Fulcio/Rekor compromise that
+would itself be publicly evident.
+
+A separate, weaker adversary is a **compromised publisher signing identity**.
+Blacklight does not *prevent* this — no signing scheme can — but because every
+signature is recorded in Rekor, a malicious release is publicly detectable by the
+publisher and by third-party monitors, giving accountability and a bounded window
+of exposure. This is the transparency guarantee: detection and non-equivocation,
+not prevention.
+
+Explicitly **out of scope** for this iteration: rollback/freshness attacks (an
+adversary replaying an older but validly-signed release — the domain of The
+Update Framework's timestamp/snapshot metadata, Section 6); availability of the
+outboard tree; and active, continuous log monitoring (delegated to existing
+tools such as `rekor-monitor`).
+
+### 3.2 The trust chain
+
+Verification composes into a single unbroken chain from a human-meaningful
+identity down to an individual 16 KiB group of bytes:
+
+```
+OIDC identity (e.g. you@example.com, verified by an issuer the client pins)
+ └─ Fulcio short-lived certificate binding that identity to an ephemeral key
+ └─ signature over the exact manifest bytes
+ └─ Rekor inclusion proof (this signature is in the public log)
+ └─ blake3_root in the manifest (the file's BLAKE3 Merkle root)
+ └─ outboard tree (interior node hashes), checked to hash
+ up to blake3_root
+ └─ per-16-KiB-group leaf chaining values
+ └─ each streamed group, verified on arrival
+```
+
+Every link is checked before the next is trusted. The manifest is verified as
+*raw bytes* against the bundle before it is parsed; the outboard is checked to
+hash up to the signed root before any artifact byte is streamed; and each group
+is checked against the tree the instant it completes.
+
+### 3.3 Artifacts and formats
+
+`publish` emits, alongside the artifact, three sidecar files:
+
+- **Manifest** `.blacklight.json` — a small, versioned JSON document that
+ is the object actually signed. It binds the file name, size, the hex BLAKE3
+ root, and the chunk-group size (fixed at log₂ = 4, i.e. 16 KiB, in format v1
+ because it is baked into the outboard layout). Signing the *manifest* rather
+ than the raw file means the Rekor entry's subject digest is `sha256(manifest)`,
+ so no BLAKE3-specific entry type is required in the log.
+- **Outboard** `.obao` — the pre-order BLAKE3 Merkle tree over the file, at
+ 16 KiB groups, with no length prefix. Its measured size is ~0.4% of the file.
+ Because chunk grouping affects only which interior nodes are stored (not the
+ hashing math), the outboard's root equals plain `blake3::hash(file)`.
+- **Bundle** `.blacklight.json.sigstore.json` — the Sigstore v0.3 bundle
+ (signature + Fulcio certificate + stapled Rekor inclusion proof).
+
+The publisher hosts these four files anywhere — no special server is required.
+
+### 3.4 The fetch state machine
+
+```
+1. Load manifest bytes and the bundle.
+2. Verify the bundle OFFLINE against the embedded trust root: signature,
+ Fulcio certificate chain, Rekor inclusion proof, AND the caller-supplied
+ identity policy (exact SAN + issuer). Refuse to continue on any failure.
+ -- Only now is the manifest parsed and its blake3_root trusted. --
+3. Fetch the (small) outboard. Build a verification plan and CHECK THAT THE
+ OUTBOARD HASHES UP TO THE SIGNED ROOT. A tampered outboard dies here.
+4. Stream the artifact. Accumulate bytes into 16 KiB groups; the instant a
+ group completes, verify its BLAKE3 chaining value against the plan. On the
+ first mismatch: abort the transfer, delete the partial file, exit with a
+ distinct integrity code, and report the byte offset.
+5. On clean completion, check total length equals the signed size (catching
+ truncation/padding), then atomically rename the temp file into place.
+```
+
+Requiring `--expect-identity` and `--expect-issuer` is a deliberate response to a
+known weakness of the closest prior tool (Sigstore's archived `sget`), which
+looked up *a* signature for the content without enforcing *whose*. A signature
+from an attacker's own valid OIDC identity is still an attacker's signature; the
+policy is what makes the check meaningful.
+
+## 4. Implementation
+
+Blacklight is ~1–2k lines of Rust (edition 2024). The dependency choices reflect
+what is actually maintained and correct as of mid-2026:
+
+- **`blake3` 1.8.5** for hashing. Its `hazmat` module (stabilized for exactly
+ this use) exposes the chunk/subtree chaining-value primitives.
+- **`bao-tree` 0.16** (n0-computer) to build the outboard tree on the publish
+ side. This is the production verified-streaming engine underneath iroh-blobs,
+ and it re-exports the same upstream `blake3`, so there is a single `Hash` type
+ across the codebase. We use 16 KiB chunk groups (`BlockSize::from_chunk_log(4)`),
+ which keeps outboard overhead near 0.4%.
+- **`sigstore-sign` / `-verify` / `-oidc` / `-trust-root` / `-types` 0.10** (the
+ sigstore-rust workspace) for keyless signing and offline bundle verification.
+ Signing is keyless-only by necessity — this generation of the library has no
+ self-managed-key path — using ambient CI credentials when present and an
+ interactive browser OIDC flow otherwise, against Sigstore **staging** by
+ default (`--production` selects the public-good instance). Verification uses the
+ library's **embedded** trust roots, so it needs no network and no live Rekor
+ connection.
+- **`reqwest` 0.13 + `tokio` 1.52** for streaming HTTP.
+
+**The forward-only verifier.** The natural choice, `bao_tree::valid_ranges`,
+turned out to be the wrong shape: it requires *random-access* (seekable) data and
+signals a corrupt group by *omitting* its range from the output stream rather
+than raising an error — awkward and slow to turn into "stop at the first bad
+byte." Instead, the verifier (in `verify.rs`) reconstructs the expected per-group
+leaf chaining values from the outboard once (verifying the outboard against the
+trusted root in the process), then streams the artifact forward, hashing each
+completed 16 KiB group with `blake3::hazmat` (`set_input_offset` +
+`finalize_non_root`, or the root hash for a single-group file) and comparing. A
+mismatch aborts immediately with the group's byte offset. This hand-rolled walk
+is unit-tested for exact round-trip agreement with bao-tree's outboard format
+across file sizes from 0 bytes to 200 KB, including files that are empty, a
+single group, and non-power-of-two numbers of groups.
+
+## 5. Evaluation
+
+### 5.1 Correctness and tamper detection
+
+The implementation ships with 13 automated tests: 9 unit tests (manifest
+round-trip and rejection of malformed/mis-versioned manifests; the verifier's
+agreement with bao-tree; single-byte-flip detection in every group with the
+correct byte offset; rejection of a tampered outboard and of a wrong root) and 4
+integration tests that drive the real compiled binary over a local HTTP server:
+a clean download succeeds and is byte-identical to the original; a tampered
+artifact aborts at the correct group with the integrity exit code and leaves no
+output file; a tampered outboard is rejected before any streaming; and a
+truncated stream is rejected on the length check. All pass.
+
+### 5.2 Attack demonstration and detection latency
+
+The repository includes a self-contained demo (`demo/run_demo.sh`) that publishes
+a file, serves it from an honest origin, and interposes a **tampering MITM proxy**
+(`demo/evil_proxy.py`) that flips one byte at a configurable offset as the bytes
+stream through — modeling exactly the in-transit attacker of Section 3.1.
+
+For a 32 MiB artifact with a byte flipped at offset 16,000,000, blacklight
+catches the corruption at **chunk group 976 (byte offset 15,990,784)** and aborts,
+having *consumed and verified* about 16 MB — the tampered byte's own 16 KiB group
+and nothing beyond it. The conventional `curl | sha256sum` pipeline, by contrast,
+must download all 33,554,432 bytes before its single flat hash can mismatch. The
+detection-latency advantage scales with file size and with how early the
+tampering occurs: the tighter the corruption is to the start, the less blacklight
+reads before aborting, whereas the flat-hash baseline always pays for the whole
+file.
+
+A note on honesty in the measurement: "bytes consumed and verified by the client"
+is the security-relevant quantity, and it is bounded by one chunk group past the
+tampered byte. The number of bytes the OS actually pulled onto the wire can be
+higher, because TCP/HTTP read-ahead buffers eagerly (especially on a
+zero-latency loopback); the demo reports both and never conflates them. The
+guarantee blacklight makes is not "minimum bytes on the wire" but "no unverified
+byte is ever accepted, written as good output, or acted upon."
+
+### 5.3 Overhead
+
+The outboard tree is a fixed, small tax on the publisher and on the client's
+initial fetch: measured at **0.39%** of the artifact size at 16 KiB groups
+(e.g. ~131 KB for a 32 MiB file), consistent with the theoretical 64 bytes per
+16 KiB group. BLAKE3 hashing throughput is high enough that hashing is not the
+bottleneck relative to network transfer. Offline verification adds no network
+round trips beyond fetching the manifest, bundle, and outboard.
+
+## 6. Related Work
+
+**Software-update frameworks.** *The Update Framework* (TUF) [Samuel, Mathewson,
+Cappos & Dingledine 2010] defines the canonical threat taxonomy for update
+systems — mirror compromise, rollback/freeze, mix-and-match — and defends with
+role separation, threshold signing, and freshness metadata. *Uptane* [Kuppusamy
+et al. 2016] adapts TUF to automobiles and is deployed at scale. Blacklight is
+narrower and complementary: it does not (yet) address rollback/freshness, which
+is TUF's core strength, but it adds public transparency and per-byte transfer-time
+verification, which base TUF does not require.
+
+**Transparency logs.** Certificate Transparency [RFC 6962; RFC 9162], the formal
+Transparency Overlays model [Chase & Meiklejohn 2016], and the Go checksum
+database [Cox 2019] are the lineage blacklight's Rekor anchoring sits in.
+
+**Blockchain-anchored update transparency** — the closest prior art to the
+original "put it on a blockchain" instinct, and the work blacklight most directly
+supersedes for this use case. *CHAINIAC* [Nikitin et al. 2017] provides proactive
+software-update transparency via collectively-signed skipchains and verified
+builds, evaluated on PyPI; it verifies *releases and metadata*, not bytes in
+flight. *Catena* [Tomescu & Devadas 2017] and *Contour* [Al-Bassam & Meiklejohn
+2018] achieve non-equivocation / binary transparency by embedding log digests in
+Bitcoin. Blacklight takes the same tamper-evidence goal but obtains it from a
+Merkle transparency log with witnessing rather than a blockchain — no consensus,
+no tokens — and, unlike all three, drives verification down to the streamed
+chunk.
+
+**Verified streaming.** Merkle's original tree-authentication constructions
+[Merkle 1987, 1989] underlie everything here. THEX (2003), BitTorrent v2 (BEP 52),
+and the BLAKE3 specification with Bao [O'Connor et al. 2020] provide chunk-granular
+verified transfer — but against a bare, unauthenticated root. iroh-blobs
+(built on the same bao-tree crate blacklight uses) does verified streaming in
+production over QUIC, again with a bare BLAKE3 hash as the trust root and no
+publisher identity or log. Blacklight's addition is precisely to make that root a
+*signed, transparency-logged* object.
+
+**Web integrity mechanisms — useful contrasts.** Subresource Integrity [W3C 2016]
+pins a whole-resource hash in HTML but verifies only after the full resource is
+fetched and carries no transparency. RFC 9530 Digest Fields [Polli & Pardue 2024]
+are in-band and unauthenticated (Section 1.1). Signed HTTP Exchanges with the MICE
+encoding combine streaming Merkle verification with a signature and CT-logged
+certificates — architecturally the nearest cousin — but are browser-scoped, ride
+an expired IETF draft, and log the *certificate*, not the artifact.
+
+**The Sigstore ecosystem and its adoption.** The Sigstore paper [Newman et al.
+2022] and *Speranza* [Merrill et al. 2023] define the keyless-signing and
+privacy-preserving extensions blacklight builds on. Sigstore's archived `sget`
+tool is the closest single existing "transparency-anchored downloader," but it
+computed a flat whole-blob digest, verified only after the full download, and did
+not enforce a signer-identity policy. Ecosystem deployments — SLSA provenance
+(v1.0, 2023; v1.2, 2025), npm provenance (2023), and PyPI PEP 740 attestations
+(2024) — show transparency-logged signing at scale, and recent work on catching
+malicious package releases via Rekor monitoring [Trail of Bits 2025] is direct
+evidence for blacklight's audit-trail claim. Interview studies of signing
+adoption [Kalu et al. 2025] motivate blacklight's stance that verification must
+be automatic and sit in the transfer path, not be a manual step.
+
+**Novelty statement (stated honestly).** Every primitive blacklight uses — BLAKE3,
+Bao verified streaming, Merkle transparency logs, Sigstore keyless signing — is
+prior work and is cited above. The contribution is the *composition* and the
+*trust-chain design*: to our knowledge, blacklight is the first tool in which a
+transparency-logged signature covers a Merkle root that authorizes
+chunk-granular, abort-on-first-bad-byte verification during transfer from
+untrusted mirrors. Signed HTTP Exchanges and content-addressed streamers
+(iroh/BitTorrent v2) are the nearest architectural relatives, and we do not claim
+novelty over their individual mechanisms — only over uniting them.
+
+## 7. Limitations and Future Work
+
+- **Rollback/freshness.** An adversary can serve an older, still-validly-signed
+ release. Blacklight has no notion of "latest." Adopting TUF-style timestamp and
+ snapshot metadata, or a signed monotonic version in the manifest with a
+ freshness check, is the natural next step.
+- **Unaudited streaming dependencies.** bao-tree and the Bao construction are
+ maintained and widely used (iroh) but not formally audited; blacklight inherits
+ that caveat. The signing/verification path rests on sigstore-rust 0.x, whose
+ API churns; we pin exact versions and isolate it behind a thin module.
+- **Outboard availability.** The client needs the outboard to verify; a
+ network-level adversary can withhold it (denial of service), though it cannot
+ forge it. Serving the Bao *combined* encoding, or embedding the tree, would
+ remove the separate fetch at some bandwidth cost.
+- **Log monitoring is out of band.** Transparency yields detection only if
+ someone watches. Blacklight relies on existing monitors (`rekor-monitor`,
+ witness networks) rather than building its own; integrating an
+ inclusion-and-consistency-checking monitor into the publisher workflow is
+ future work.
+- **Verified resume.** Chunk-group-aligned range requests would let an
+ interrupted download resume while re-verifying only the trailing partial group;
+ the outboard already makes this straightforward and it is planned.
+
+## 8. Conclusion
+
+The desire to "just check the hash so hackers can't tamper with my downloads" is
+a good one; it fails only because the usual realizations put the check in the
+wrong place, bind it to the wrong hash, or anchor it in nothing an attacker can't
+also rewrite. Moving the check to the endpoint, binding it to a collision-resistant
+Merkle root, signing that root keylessly, and recording the signature in a public
+transparency log turns the instinct into a guarantee — and doing the verification
+*as the bytes stream* turns "reject bad downloads" into "stop reading the moment a
+byte is wrong." Blacklight shows the combination is buildable today from
+maintained parts, and that it catches a real in-transit attack after reading a
+single bad chunk group rather than an entire tampered file.
+
+## References
+
+1. J. H. Saltzer, D. P. Reed, D. D. Clark. "End-to-End Arguments in System
+ Design." *ACM Transactions on Computer Systems*, 2(4), 1984.
+2. R. C. Merkle. "A Digital Signature Based on a Conventional Encryption
+ Function." *CRYPTO '87*, LNCS 293. And "A Certified Digital Signature."
+ *CRYPTO '89*, LNCS 435.
+3. J. O'Connor, J.-P. Aumasson, S. Neves, Z. Wilcox-O'Hearn. "BLAKE3: one
+ function, fast everywhere." Specification, 2020.
+ https://github.com/BLAKE3-team/BLAKE3
+4. J. O'Connor. "Bao: an implementation of BLAKE3 verified streaming."
+ Specification. https://github.com/oconnor663/bao
+5. J. Chapweske, G. Mohr. "Tree Hash EXchange format (THEX)."
+ Internet-Draft draft-jchapweske-thex-02, 2003 (expired).
+6. B. Cohen. "The BitTorrent Protocol Specification v2." BEP 52, 2008/2020.
+ https://www.bittorrent.org/beps/bep_0052.html
+7. B. Laurie, A. Langley, E. Kasper. "Certificate Transparency." RFC 6962, 2013.
+ Updated as RFC 9162 (Laurie, Messeri, Stradling), 2021.
+8. M. Chase, S. Meiklejohn. "Transparency Overlays and Applications."
+ *ACM CCS 2016.*
+9. R. Cox. "Transparent Logs for Skeptical Clients." 2019.
+ https://research.swtch.com/tlog — and the Go module checksum database,
+ go.dev/blog/module-mirror-launch, 2019.
+10. J. Samuel, N. Mathewson, J. Cappos, R. Dingledine. "Survivable Key Compromise
+ in Software Update Systems." *ACM CCS 2010.*
+11. T. K. Kuppusamy et al. "Uptane: Securing Software Updates for Automobiles."
+ *escar Europe 2016.*
+12. K. Nikitin et al. "CHAINIAC: Proactive Software-Update Transparency via
+ Collectively Signed Skipchains and Verified Builds." *USENIX Security 2017.*
+13. A. Tomescu, S. Devadas. "Catena: Efficient Non-equivocation via Bitcoin."
+ *IEEE S&P 2017.*
+14. M. Al-Bassam, S. Meiklejohn. "Contour: A Practical System for Binary
+ Transparency." *CBT @ ESORICS 2018* (arXiv:1712.08427).
+15. Z. Newman, J. S. Meyers, S. Torres-Arias. "Sigstore: Software Signing for
+ Everybody." *ACM CCS 2022.*
+16. K. Merrill, Z. Newman, S. Torres-Arias, K. R. Sollins. "Speranza: Usable,
+ Privacy-friendly Software Signing." *ACM CCS 2023.*
+17. K. Kalu et al. "An Industry Interview Study of Software Signing for Supply
+ Chain Security." *USENIX Security 2025.*
+18. W3C. "Subresource Integrity." W3C Recommendation, 2016.
+19. R. Polli, L. Pardue. "Digest Fields." RFC 9530, 2024.
+20. SLSA: "Supply-chain Levels for Software Artifacts," v1.0 (2023), v1.2 (2025).
+ https://slsa.dev — npm provenance (GitHub, 2023); PyPI PEP 740 digital
+ attestations (2024).
+21. Trail of Bits. "Catching malicious package releases using a transparency
+ log." 2025. https://blog.trailofbits.com/2025/12/12/
diff --git a/src/fetch.rs b/src/fetch.rs
new file mode 100644
index 0000000..171532c
--- /dev/null
+++ b/src/fetch.rs
@@ -0,0 +1,343 @@
+//! `blacklight fetch`: verify the manifest's Sigstore bundle offline, then
+//! stream the artifact, verifying every 16 KiB chunk group against the signed
+//! BLAKE3 root as it arrives — aborting the transfer on the first bad byte.
+//!
+//! Order of operations matters. Nothing about the artifact is trusted until:
+//! 1. the manifest bytes verify against the Sigstore bundle (signature,
+//! Fulcio cert chain, Rekor inclusion proof, and the required identity
+//! policy), and
+//! 2. every delivered chunk group verifies against the root in that manifest.
+//!
+//! The network, the mirror, and the CDN are all untrusted.
+
+use anyhow::{Context, Result, anyhow, bail};
+use futures_util::StreamExt;
+use std::path::{Path, PathBuf};
+use std::time::Duration;
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+
+use crate::manifest::{BUNDLE_SUFFIX, MANIFEST_SUFFIX, Manifest, OUTBOARD_SUFFIX};
+use crate::sigstore::{self, Env, IdentityPolicy};
+use crate::verify::{GROUP_LEN, GroupPlan, VerifyError};
+
+/// Give up if a connection can't be established, or if an established transfer
+/// stalls with no bytes for this long. `read_timeout` (not a total `timeout`)
+/// is deliberate: a large but healthy download must not be aborted just for
+/// taking a while, only for going silent.
+const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
+const READ_TIMEOUT: Duration = Duration::from_secs(30);
+
+fn http_client() -> Result {
+ reqwest::Client::builder()
+ .connect_timeout(CONNECT_TIMEOUT)
+ .read_timeout(READ_TIMEOUT)
+ .build()
+ .context("building HTTP client")
+}
+
+/// Distinct error type so `main` can map integrity failures to exit code 3.
+#[derive(Debug, thiserror::Error)]
+#[error("{0}")]
+pub struct TamperError(pub String);
+
+pub struct Options {
+ pub manifest: String,
+ pub expect_identity: Option,
+ pub expect_issuer: Option,
+ pub allow_unsigned: bool,
+ pub production: bool,
+ pub url_override: Option,
+ pub output: Option,
+}
+
+pub async fn fetch(opts: Options) -> Result<()> {
+ let env = if opts.production {
+ Env::Production
+ } else {
+ Env::Staging
+ };
+ let client = http_client()?;
+
+ // 1. Load manifest + bundle (from URL or local path).
+ let manifest_bytes = load(&client, &opts.manifest)
+ .await
+ .context("cannot load manifest")?;
+
+ if opts.allow_unsigned {
+ eprintln!(
+ "WARNING: --allow-unsigned — the manifest root is NOT proven to come from any publisher."
+ );
+ } else {
+ let policy = build_policy(&opts)?;
+ let bundle_src = format!("{}{BUNDLE_SUFFIX}", opts.manifest);
+ let bundle_bytes = load(&client, &bundle_src)
+ .await
+ .with_context(|| format!("cannot load Sigstore bundle from {bundle_src}"))?;
+ eprintln!("verifying Sigstore bundle offline ({env:?}) …");
+ let v = sigstore::verify_manifest(&manifest_bytes, &bundle_bytes, &policy, env)
+ .context("Sigstore verification FAILED — refusing to download")?;
+ eprintln!(" signer {} (issuer {})", v.identity, v.issuer);
+ if let Some(t) = v.integrated_time {
+ eprintln!(" rekor integrated at unix time {t}");
+ }
+ }
+
+ // Only now do we parse and trust the manifest contents.
+ let manifest = Manifest::from_bytes(&manifest_bytes)?;
+ let root = manifest.root()?;
+ eprintln!(
+ "manifest verified: {} ({} bytes, root {})",
+ manifest.name,
+ manifest.size,
+ &manifest.blake3_root[..16]
+ );
+
+ // 2. Fetch the outboard tree (small; downloaded fully before streaming data).
+ let outboard_src = artifact_relative(&opts.manifest, OUTBOARD_SUFFIX, &manifest);
+ let outboard = load(&client, &outboard_src)
+ .await
+ .with_context(|| format!("cannot load outboard from {outboard_src}"))?;
+
+ // Build + self-check the verification plan. A tampered outboard is caught
+ // here (it won't hash up to the signed root).
+ let plan = GroupPlan::new(root, manifest.size, &outboard)
+ .map_err(|e| TamperError(e.to_string()))
+ .context("outboard failed to verify against signed root")?;
+
+ // 3. Stream the artifact, verifying each group as it lands.
+ let artifact_src = resolve_artifact_url(&opts, &manifest)?;
+ let out_path = opts
+ .output
+ .clone()
+ .unwrap_or_else(|| PathBuf::from(&manifest.name));
+ eprintln!("streaming {artifact_src} …");
+
+ stream_verified(&client, &artifact_src, &plan, &out_path).await
+}
+
+/// The core loop: pull bytes, accumulate into 16 KiB groups, verify each group
+/// against the plan the instant it completes, and abort on the first mismatch.
+async fn stream_verified(
+ client: &reqwest::Client,
+ src: &str,
+ plan: &GroupPlan,
+ out_path: &Path,
+) -> Result<()> {
+ let tmp_path = out_path.with_extension("blacklight-partial");
+ let mut tmp = tokio::fs::File::create(&tmp_path)
+ .await
+ .with_context(|| format!("cannot create {}", tmp_path.display()))?;
+
+ let result = stream_verified_inner(client, src, plan, &mut tmp).await;
+
+ // On any failure — tampering or network — never leave a partial file that
+ // could be mistaken for a good download.
+ if result.is_err() {
+ drop(tmp);
+ let _ = tokio::fs::remove_file(&tmp_path).await;
+ return result.map(|_| ());
+ }
+
+ tmp.flush().await?;
+ tmp.sync_all().await?;
+ drop(tmp);
+ tokio::fs::rename(&tmp_path, out_path)
+ .await
+ .with_context(|| format!("cannot finalize {}", out_path.display()))?;
+ let bytes = result.unwrap();
+ eprintln!(
+ "OK — {bytes} bytes verified against the signed root; wrote {}",
+ out_path.display()
+ );
+ Ok(())
+}
+
+async fn stream_verified_inner(
+ client: &reqwest::Client,
+ src: &str,
+ plan: &GroupPlan,
+ tmp: &mut tokio::fs::File,
+) -> Result {
+ let mut group_buf: Vec = Vec::with_capacity(GROUP_LEN);
+ let mut state = StreamState {
+ plan,
+ tmp,
+ group_buf: &mut group_buf,
+ group_index: 0,
+ total: 0,
+ };
+
+ if is_url(src) {
+ let resp = client
+ .get(src)
+ .send()
+ .await
+ .with_context(|| format!("request to {src} failed"))?;
+ if !resp.status().is_success() {
+ bail!("server returned HTTP {} for {src}", resp.status());
+ }
+ let mut stream = resp.bytes_stream();
+ while let Some(chunk) = stream.next().await {
+ // A network read error is NOT tampering — surface it as an ordinary
+ // error (exit 1), distinct from integrity failure (exit 3).
+ let chunk = chunk.with_context(|| format!("network error reading {src}"))?;
+ state.feed(&chunk).await?;
+ }
+ } else {
+ // Local artifact (e.g. a local manifest path, or --url pointing at a
+ // file). Stream it from disk through the exact same verification.
+ let mut file = tokio::fs::File::open(src)
+ .await
+ .with_context(|| format!("cannot open local artifact {src}"))?;
+ let mut buf = vec![0u8; 64 * 1024];
+ loop {
+ let n = file
+ .read(&mut buf)
+ .await
+ .with_context(|| format!("error reading {src}"))?;
+ if n == 0 {
+ break;
+ }
+ state.feed(&buf[..n]).await?;
+ }
+ }
+
+ state.finish().await
+}
+
+/// Accumulates the byte stream into 16 KiB groups, verifying and writing each
+/// as it completes. Shared by the URL and local-file paths.
+struct StreamState<'a> {
+ plan: &'a GroupPlan,
+ tmp: &'a mut tokio::fs::File,
+ group_buf: &'a mut Vec,
+ group_index: usize,
+ total: u64,
+}
+
+impl StreamState<'_> {
+ async fn feed(&mut self, mut data: &[u8]) -> Result<()> {
+ while !data.is_empty() {
+ let need = GROUP_LEN - self.group_buf.len();
+ let take = need.min(data.len());
+ self.group_buf.extend_from_slice(&data[..take]);
+ data = &data[take..];
+ if self.group_buf.len() == GROUP_LEN {
+ verify_group(self.plan, self.group_index, self.group_buf)?;
+ self.tmp.write_all(self.group_buf).await?;
+ self.total += self.group_buf.len() as u64;
+ self.group_index += 1;
+ self.group_buf.clear();
+ }
+ }
+ Ok(())
+ }
+
+ async fn finish(mut self) -> Result {
+ // Final short group (if any).
+ if !self.group_buf.is_empty() {
+ verify_group(self.plan, self.group_index, self.group_buf)?;
+ self.tmp.write_all(self.group_buf).await?;
+ self.total += self.group_buf.len() as u64;
+ self.group_index += 1;
+ }
+
+ // Length must match exactly: a truncated or padded stream is a mismatch.
+ if self.total != self.plan.size() {
+ return Err(TamperError(format!(
+ "stream length {} != signed size {} (truncated or padded)",
+ self.total,
+ self.plan.size()
+ ))
+ .into());
+ }
+ if self.group_index != self.plan.group_count() {
+ return Err(TamperError(format!(
+ "received {} groups, expected {}",
+ self.group_index,
+ self.plan.group_count()
+ ))
+ .into());
+ }
+ Ok(self.total)
+ }
+}
+
+fn verify_group(plan: &GroupPlan, index: usize, data: &[u8]) -> Result<()> {
+ match plan.check_group(index, data) {
+ Ok(()) => Ok(()),
+ Err(e @ VerifyError::GroupMismatch { .. }) => {
+ // The whole point: report exactly where tampering was caught, and
+ // how little was fetched past it (at most one group).
+ Err(TamperError(format!(
+ "{e} — aborting transfer; at most {} extra bytes were fetched past the tampered byte",
+ GROUP_LEN
+ ))
+ .into())
+ }
+ Err(e) => Err(TamperError(e.to_string()).into()),
+ }
+}
+
+fn build_policy(opts: &Options) -> Result {
+ let identity = opts.expect_identity.clone().ok_or_else(|| {
+ anyhow!("--expect-identity is required (or pass --allow-unsigned to skip verification)")
+ })?;
+ let issuer = opts.expect_issuer.clone().ok_or_else(|| {
+ anyhow!("--expect-issuer is required (or pass --allow-unsigned to skip verification)")
+ })?;
+ Ok(IdentityPolicy { identity, issuer })
+}
+
+/// Load bytes from an http(s) URL or a local filesystem path.
+async fn load(client: &reqwest::Client, src: &str) -> Result> {
+ if is_url(src) {
+ let resp = client.get(src).send().await?;
+ if !resp.status().is_success() {
+ bail!("HTTP {} for {src}", resp.status());
+ }
+ Ok(resp.bytes().await?.to_vec())
+ } else {
+ Ok(tokio::fs::read(src).await?)
+ }
+}
+
+fn is_url(s: &str) -> bool {
+ s.starts_with("http://") || s.starts_with("https://")
+}
+
+/// Derive a sibling resource's location from the manifest location by swapping
+/// the manifest suffix for another. Works for both URLs and paths.
+fn artifact_relative(manifest_src: &str, new_suffix: &str, manifest: &Manifest) -> String {
+ if let Some(base) = manifest_src.strip_suffix(MANIFEST_SUFFIX) {
+ // base already ends with the artifact name; e.g. ".../demo.bin"
+ format!("{base}{new_suffix}")
+ } else {
+ // Fall back to a sibling of the manifest named after the artifact.
+ sibling(manifest_src, &format!("{}{new_suffix}", manifest.name))
+ }
+}
+
+/// Decide where the artifact bytes come from: explicit override, else the
+/// manifest location minus its suffix, else the manifest's URL hints. The
+/// result may be an http(s) URL or a local path; the streaming reader handles
+/// both.
+fn resolve_artifact_url(opts: &Options, manifest: &Manifest) -> Result {
+ if let Some(u) = &opts.url_override {
+ return Ok(u.clone());
+ }
+ if let Some(base) = opts.manifest.strip_suffix(MANIFEST_SUFFIX) {
+ return Ok(base.to_string());
+ }
+ if let Some(u) = manifest.urls.first() {
+ return Ok(u.clone());
+ }
+ bail!("cannot determine artifact URL; pass --url")
+}
+
+fn sibling(src: &str, name: &str) -> String {
+ match src.rfind('/') {
+ Some(i) => format!("{}{name}", &src[..=i]),
+ None => name.to_string(),
+ }
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..8c78f7a
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,130 @@
+mod fetch;
+mod manifest;
+mod publish;
+mod sigstore;
+mod verify;
+
+use anyhow::Result;
+use clap::{Parser, Subcommand};
+use std::path::PathBuf;
+use std::process::ExitCode;
+
+/// Verified-streaming downloads anchored in the Sigstore transparency log.
+///
+/// `publish` produces, next to the artifact: an outboard BLAKE3 Merkle tree
+/// (.obao), a manifest (.blacklight.json), and a Sigstore bundle
+/// (.blacklight.json.sigstore.json). `fetch` verifies the bundle offline,
+/// then streams the artifact, verifying every 16 KiB chunk group against the
+/// signed root as it arrives — aborting on the first tampered byte.
+#[derive(Parser)]
+#[command(name = "blacklight", version, about)]
+struct Cli {
+ #[command(subcommand)]
+ command: Command,
+}
+
+#[derive(Subcommand)]
+enum Command {
+ /// Hash a file, build its outboard tree, and sign the manifest.
+ Publish {
+ /// File to publish.
+ file: PathBuf,
+ /// Directory for the sidecar files (defaults to the file's directory).
+ #[arg(long)]
+ out: Option,
+ /// Skip signing; emit only .obao + manifest. For local testing.
+ /// (Signing is otherwise keyless via OIDC — ambient CI or browser.)
+ #[arg(long)]
+ unsigned: bool,
+ /// Use Sigstore production infrastructure instead of staging.
+ #[arg(long)]
+ production: bool,
+ /// URL hint(s) to embed in the manifest.
+ #[arg(long)]
+ url: Vec,
+ },
+ /// Fetch and verify an artifact via its manifest URL (or local path).
+ Fetch {
+ /// Manifest URL (…/file.blacklight.json) or local path.
+ manifest: String,
+ /// Expected signer identity (email or URI SAN), e.g. you@example.com.
+ #[arg(long)]
+ expect_identity: Option,
+ /// Expected OIDC issuer, e.g. https://github.com/login/oauth.
+ #[arg(long)]
+ expect_issuer: Option,
+ /// DANGEROUS: skip Sigstore bundle verification entirely. The
+ /// download is still integrity-checked against the manifest root,
+ /// but nothing proves who published that root.
+ #[arg(long)]
+ allow_unsigned: bool,
+ /// Verify against the Sigstore production trust root instead of staging.
+ #[arg(long)]
+ production: bool,
+ /// Override the artifact URL (defaults to manifest URL minus suffix,
+ /// then manifest url hints).
+ #[arg(long)]
+ url: Option,
+ /// Output path (defaults to the manifest's `name` in the current dir).
+ #[arg(short, long)]
+ output: Option,
+ },
+}
+
+#[tokio::main]
+async fn main() -> ExitCode {
+ match run().await {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(err) => {
+ eprintln!("error: {err:#}");
+ // Tampering gets a distinct exit code so scripts/tests can tell
+ // "integrity violation" from ordinary failures.
+ if err.is::() {
+ ExitCode::from(3)
+ } else {
+ ExitCode::FAILURE
+ }
+ }
+ }
+}
+
+async fn run() -> Result<()> {
+ match Cli::parse().command {
+ Command::Publish {
+ file,
+ out,
+ unsigned,
+ production,
+ url,
+ } => {
+ publish::publish(publish::Options {
+ file,
+ out,
+ unsigned,
+ production,
+ urls: url,
+ })
+ .await
+ }
+ Command::Fetch {
+ manifest,
+ expect_identity,
+ expect_issuer,
+ allow_unsigned,
+ production,
+ url,
+ output,
+ } => {
+ fetch::fetch(fetch::Options {
+ manifest,
+ expect_identity,
+ expect_issuer,
+ allow_unsigned,
+ production,
+ url_override: url,
+ output,
+ })
+ .await
+ }
+ }
+}
diff --git a/src/manifest.rs b/src/manifest.rs
new file mode 100644
index 0000000..bdd70c1
--- /dev/null
+++ b/src/manifest.rs
@@ -0,0 +1,199 @@
+//! The blacklight manifest: the small signed document that binds a file's
+//! BLAKE3 Merkle root, its length, and the chunk-group size used for the
+//! outboard tree.
+//!
+//! The Sigstore signature covers the manifest's exact bytes as hosted, so the
+//! manifest is verified as raw bytes *before* parsing. There is deliberately
+//! no canonicalization step: whatever bytes the publisher signed are the bytes
+//! the client must present.
+
+use anyhow::{Context, Result, bail};
+use serde::{Deserialize, Serialize};
+
+/// Chunk-group log2 (in 1 KiB BLAKE3 chunks) fixed for format v1.
+/// 4 => 16 chunks => 16 KiB groups, ~0.4% outboard overhead.
+pub const CHUNK_GROUP_LOG: u8 = 4;
+
+pub const MANIFEST_SUFFIX: &str = ".blacklight.json";
+pub const OUTBOARD_SUFFIX: &str = ".obao";
+pub const BUNDLE_SUFFIX: &str = ".sigstore.json";
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub struct Manifest {
+ /// Format version. Must be 1.
+ pub v: u32,
+ /// Original file name (basename only; informational).
+ pub name: String,
+ /// File size in bytes.
+ pub size: u64,
+ /// Hex-encoded BLAKE3 root hash of the file. Identical to
+ /// `blake3::hash(file)` — chunk grouping affects only the outboard layout.
+ pub blake3_root: String,
+ /// Log2 of the chunk-group size in 1 KiB chunks. Baked into the outboard
+ /// tree layout, so it is part of the signed statement.
+ pub chunk_group_log: u8,
+ /// Optional artifact URL hints. The client may override; integrity never
+ /// depends on where the bytes come from.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub urls: Vec,
+}
+
+impl Manifest {
+ pub fn new(name: String, size: u64, root: blake3::Hash) -> Self {
+ Self {
+ v: 1,
+ name,
+ size,
+ blake3_root: root.to_hex().to_string(),
+ chunk_group_log: CHUNK_GROUP_LOG,
+ urls: Vec::new(),
+ }
+ }
+
+ /// Serialize to the exact bytes that get signed and hosted.
+ pub fn to_bytes(&self) -> Result> {
+ let mut out = serde_json::to_vec_pretty(self)?;
+ out.push(b'\n');
+ Ok(out)
+ }
+
+ /// Parse and validate. Callers must verify the Sigstore bundle over the
+ /// raw bytes *before* trusting anything returned here.
+ pub fn from_bytes(bytes: &[u8]) -> Result {
+ let m: Manifest = serde_json::from_slice(bytes).context("manifest is not valid JSON")?;
+ if m.v != 1 {
+ bail!("unsupported manifest version {} (expected 1)", m.v);
+ }
+ if m.chunk_group_log != CHUNK_GROUP_LOG {
+ bail!(
+ "unsupported chunk_group_log {} (format v1 fixes it at {})",
+ m.chunk_group_log,
+ CHUNK_GROUP_LOG
+ );
+ }
+ // `name` is a basename only (documented, and used by `fetch` as the
+ // default output path). Reject anything that could escape the current
+ // directory — a hostile manifest must not steer writes to `../../…`,
+ // an absolute path, or `.`/`..`.
+ m.validate_name()?;
+ m.root()?;
+ Ok(m)
+ }
+
+ /// Enforce the basename-only contract for `name`.
+ fn validate_name(&self) -> Result<()> {
+ let name = &self.name;
+ if name.is_empty() {
+ bail!("manifest name is empty");
+ }
+ if name == "." || name == ".." {
+ bail!("manifest name {name:?} is a path traversal component");
+ }
+ if name.contains('/') || name.contains('\\') || name.contains('\0') {
+ bail!("manifest name {name:?} must be a bare filename, not a path");
+ }
+ // Belt and suspenders: the OS-parsed form must be exactly one normal
+ // component (rejects absolute paths, drive prefixes, `..`, etc.).
+ let path = std::path::Path::new(name);
+ let mut comps = path.components();
+ match (comps.next(), comps.next()) {
+ (Some(std::path::Component::Normal(c)), None) if c == name.as_str() => Ok(()),
+ _ => bail!("manifest name {name:?} is not a single filename component"),
+ }
+ }
+
+ pub fn root(&self) -> Result {
+ blake3::Hash::from_hex(&self.blake3_root).with_context(|| {
+ format!(
+ "blake3_root {:?} is not a 64-char hex hash",
+ self.blake3_root
+ )
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn sample() -> Manifest {
+ Manifest::new("demo.bin".into(), 1234, blake3::hash(b"hello"))
+ }
+
+ #[test]
+ fn round_trip() {
+ let m = sample();
+ let bytes = m.to_bytes().unwrap();
+ let back = Manifest::from_bytes(&bytes).unwrap();
+ assert_eq!(m, back);
+ assert_eq!(back.root().unwrap(), blake3::hash(b"hello"));
+ }
+
+ #[test]
+ fn rejects_wrong_version() {
+ let mut m = sample();
+ m.v = 2;
+ let err = Manifest::from_bytes(&m.to_bytes().unwrap()).unwrap_err();
+ assert!(err.to_string().contains("unsupported manifest version"));
+ }
+
+ #[test]
+ fn rejects_wrong_chunk_group() {
+ let mut m = sample();
+ m.chunk_group_log = 0;
+ let err = Manifest::from_bytes(&m.to_bytes().unwrap()).unwrap_err();
+ assert!(err.to_string().contains("chunk_group_log"));
+ }
+
+ #[test]
+ fn rejects_bad_root_hex() {
+ let mut m = sample();
+ m.blake3_root = "zz".into();
+ assert!(Manifest::from_bytes(&m.to_bytes().unwrap()).is_err());
+ }
+
+ #[test]
+ fn rejects_unknown_fields() {
+ let json =
+ br#"{"v":1,"name":"a","size":1,"blake3_root":"00","chunk_group_log":4,"evil":true}"#;
+ assert!(Manifest::from_bytes(json).is_err());
+ }
+
+ #[test]
+ fn rejects_path_traversal_names() {
+ for evil in [
+ "../evil",
+ "../../etc/passwd",
+ "/etc/passwd",
+ "a/b",
+ "sub/../x",
+ "..",
+ ".",
+ "",
+ "back\\slash",
+ "with\0null",
+ ] {
+ let mut m = sample();
+ m.name = evil.into();
+ let bytes = m.to_bytes().unwrap();
+ assert!(
+ Manifest::from_bytes(&bytes).is_err(),
+ "should have rejected name {evil:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn accepts_plain_basenames() {
+ for ok in ["demo.bin", "my-file_v1.2.tar.gz", "a"] {
+ let mut m = sample();
+ m.name = ok.into();
+ let bytes = m.to_bytes().unwrap();
+ assert!(
+ Manifest::from_bytes(&bytes).is_ok(),
+ "should have accepted name {ok:?}"
+ );
+ }
+ }
+}
diff --git a/src/publish.rs b/src/publish.rs
new file mode 100644
index 0000000..a4c95d0
--- /dev/null
+++ b/src/publish.rs
@@ -0,0 +1,100 @@
+//! `blacklight publish`: hash the file, build its outboard Merkle tree, write
+//! the manifest, and (unless `--unsigned`) sign the manifest with Sigstore.
+
+use anyhow::{Context, Result};
+use bao_tree::BlockSize;
+use bao_tree::io::outboard::PreOrderOutboard;
+use bao_tree::io::sync::CreateOutboard;
+use std::fs::File;
+use std::path::{Path, PathBuf};
+
+use crate::manifest::{BUNDLE_SUFFIX, CHUNK_GROUP_LOG, MANIFEST_SUFFIX, Manifest, OUTBOARD_SUFFIX};
+use crate::sigstore;
+
+/// 16 KiB chunk groups. Fixed by the manifest format (see CHUNK_GROUP_LOG).
+pub const BLOCK_SIZE: BlockSize = BlockSize::from_chunk_log(CHUNK_GROUP_LOG);
+
+pub struct Options {
+ pub file: PathBuf,
+ pub out: Option,
+ pub unsigned: bool,
+ pub production: bool,
+ pub urls: Vec,
+}
+
+pub async fn publish(opts: Options) -> Result<()> {
+ let file = &opts.file;
+ let name = file
+ .file_name()
+ .and_then(|n| n.to_str())
+ .context("input file has no valid name")?
+ .to_string();
+
+ let out_dir = opts
+ .out
+ .clone()
+ .unwrap_or_else(|| file.parent().map(Path::to_path_buf).unwrap_or_default());
+ std::fs::create_dir_all(&out_dir).ok();
+
+ // Build the outboard tree. bao-tree hashes the file as it goes; the root it
+ // returns equals plain blake3::hash(file) — chunk grouping only changes the
+ // outboard layout, not the hashing math.
+ eprintln!("hashing and building outboard tree for {name} …");
+ let f = File::open(file).with_context(|| format!("cannot open {}", file.display()))?;
+ let size = f.metadata()?.len();
+ let ob = PreOrderOutboard::>::create(&f, BLOCK_SIZE)
+ .context("failed to build outboard tree")?;
+ let root = ob.root;
+
+ let outboard_path = sidecar(&out_dir, &name, OUTBOARD_SUFFIX);
+ std::fs::write(&outboard_path, &ob.data)
+ .with_context(|| format!("cannot write {}", outboard_path.display()))?;
+
+ // Manifest.
+ let mut manifest = Manifest::new(name.clone(), size, root);
+ manifest.urls = opts.urls.clone();
+ let manifest_bytes = manifest.to_bytes()?;
+ let manifest_path = sidecar(&out_dir, &name, MANIFEST_SUFFIX);
+ std::fs::write(&manifest_path, &manifest_bytes)
+ .with_context(|| format!("cannot write {}", manifest_path.display()))?;
+
+ eprintln!(" root {}", root.to_hex());
+ eprintln!(" size {size} bytes");
+ eprintln!(
+ " outboard {} ({} bytes, {:.2}% overhead)",
+ outboard_path.display(),
+ ob.data.len(),
+ 100.0 * ob.data.len() as f64 / size.max(1) as f64
+ );
+ eprintln!(" manifest {}", manifest_path.display());
+
+ // Sign the manifest bytes.
+ if opts.unsigned {
+ eprintln!(" (unsigned — no Sigstore bundle emitted)");
+ return Ok(());
+ }
+
+ let bundle_path = sidecar(
+ &out_dir,
+ &name,
+ &format!("{MANIFEST_SUFFIX}{BUNDLE_SUFFIX}"),
+ );
+ let env = if opts.production {
+ sigstore::Env::Production
+ } else {
+ sigstore::Env::Staging
+ };
+ eprintln!("signing manifest via Sigstore ({env:?}, keyless OIDC) …");
+ let bundle_json = sigstore::sign_manifest(&manifest_bytes, env)
+ .await
+ .context("Sigstore signing failed")?;
+ std::fs::write(&bundle_path, &bundle_json)
+ .with_context(|| format!("cannot write {}", bundle_path.display()))?;
+ eprintln!(" bundle {}", bundle_path.display());
+ eprintln!("done. host: artifact, {OUTBOARD_SUFFIX}, {MANIFEST_SUFFIX}, and the bundle.");
+ Ok(())
+}
+
+fn sidecar(dir: &Path, name: &str, suffix: &str) -> PathBuf {
+ dir.join(format!("{name}{suffix}"))
+}
diff --git a/src/sigstore.rs b/src/sigstore.rs
new file mode 100644
index 0000000..59f3cbb
--- /dev/null
+++ b/src/sigstore.rs
@@ -0,0 +1,119 @@
+//! Sigstore signing and verification, wrapped behind small functions so the
+//! rest of blacklight never depends on the exact sigstore-rust API (which
+//! churns fast at 0.x). See docs/DESIGN.md for the trust chain.
+//!
+//! Signing is **keyless only**: sigstore-rust 0.10 has no path for a
+//! self-managed private key, so `publish` obtains an OIDC identity (ambient CI
+//! credentials if present, otherwise an interactive browser flow) and Fulcio
+//! issues a short-lived certificate bound to that identity. The signature and a
+//! Rekor inclusion proof are stapled into a v0.3 bundle.
+//!
+//! Verification is offline: the trusted roots for staging and production are
+//! embedded in `sigstore-trust-root`, so `fetch` verifies the signature, the
+//! certificate chain, and the Rekor inclusion proof without any network call —
+//! and enforces the caller's identity policy (exact SAN + issuer match).
+
+use anyhow::{Context, Result, anyhow, bail};
+use sigstore_trust_root::{SIGSTORE_PRODUCTION_TRUSTED_ROOT, SIGSTORE_STAGING_TRUSTED_ROOT};
+use sigstore_types::Bundle;
+use sigstore_verify::{VerificationPolicy, verify as sig_verify};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Env {
+ Staging,
+ Production,
+}
+
+/// Identity policy the verifier enforces against the signing certificate.
+#[derive(Debug, Clone)]
+pub struct IdentityPolicy {
+ pub identity: String,
+ pub issuer: String,
+}
+
+/// Result of a successful verification, surfaced to the user.
+#[derive(Debug, Clone)]
+pub struct Verified {
+ pub identity: String,
+ pub issuer: String,
+ /// Rekor integrated time (unix seconds), if the entry carried one.
+ pub integrated_time: Option,
+}
+
+/// Sign the manifest bytes keyless and return the serialized v0.3 bundle JSON.
+pub async fn sign_manifest(manifest_bytes: &[u8], env: Env) -> Result> {
+ use sigstore_oidc::{IdentityToken, get_identity_token};
+ use sigstore_sign::SigningContext;
+
+ let context = match env {
+ Env::Staging => SigningContext::staging(),
+ Env::Production => SigningContext::production(),
+ };
+
+ // Prefer zero-interaction ambient CI credentials; fall back to browser/OOB
+ // only when there genuinely is no ambient identity. A detection *error*
+ // (e.g. an id-token endpoint that is present but failing) is surfaced, not
+ // silently swallowed — otherwise a broken CI signing setup would quietly
+ // pop a browser that no one is watching.
+ let token: IdentityToken = match IdentityToken::detect_ambient().await {
+ Ok(Some(t)) => {
+ eprintln!(" using ambient CI OIDC identity");
+ t
+ }
+ Err(e) => {
+ return Err(anyhow!("ambient OIDC detection failed: {e}")).context(
+ "an ambient CI identity was present but could not be used; \
+ refusing to fall back to an interactive browser flow",
+ );
+ }
+ Ok(None) => {
+ eprintln!(" no ambient CI identity; opening browser for OIDC sign-in …");
+ get_identity_token(context.config().oidc_url.as_deref())
+ .await
+ .context("could not obtain an OIDC identity token")?
+ }
+ };
+
+ let signer = context.signer(token);
+ let bundle = signer
+ .sign(manifest_bytes)
+ .await
+ .context("Fulcio/Rekor signing failed")?;
+ let json = bundle.to_json_pretty().context("serializing bundle")?;
+ Ok(json.into_bytes())
+}
+
+/// Verify the bundle over the manifest bytes, offline, enforcing the policy.
+pub fn verify_manifest(
+ manifest_bytes: &[u8],
+ bundle_json: &[u8],
+ policy: &IdentityPolicy,
+ env: Env,
+) -> Result {
+ let root_json = match env {
+ Env::Staging => SIGSTORE_STAGING_TRUSTED_ROOT,
+ Env::Production => SIGSTORE_PRODUCTION_TRUSTED_ROOT,
+ };
+ let trusted_root = sigstore_trust_root::TrustedRoot::from_json(root_json)
+ .context("loading embedded trusted root")?;
+
+ let bundle_str = std::str::from_utf8(bundle_json).context("bundle is not UTF-8")?;
+ let bundle = Bundle::from_json(bundle_str).context("parsing Sigstore bundle")?;
+
+ let vpolicy = VerificationPolicy::default()
+ .require_identity(&policy.identity)
+ .require_issuer(&policy.issuer);
+
+ let result = sig_verify(manifest_bytes, &bundle, &vpolicy, &trusted_root)
+ .map_err(|e| anyhow!("bundle verification error: {e}"))?;
+
+ if !result.success {
+ bail!("bundle did not verify against the trusted root / identity policy");
+ }
+
+ Ok(Verified {
+ identity: result.identity.unwrap_or_else(|| policy.identity.clone()),
+ issuer: result.issuer.unwrap_or_else(|| policy.issuer.clone()),
+ integrated_time: result.integrated_time,
+ })
+}
diff --git a/src/verify.rs b/src/verify.rs
new file mode 100644
index 0000000..55b5b84
--- /dev/null
+++ b/src/verify.rs
@@ -0,0 +1,334 @@
+//! Forward-only, fail-fast streaming verifier.
+//!
+//! bao-tree's `valid_ranges` needs random-access (seekable) data and reports a
+//! corrupt group as an *omitted* range rather than an error — awkward for the
+//! "stop the moment a byte is wrong" behaviour that is the whole point here. So
+//! we verify the way the BLAKE3 tree is actually defined, using the public
+//! `blake3::hazmat` API (the same primitives bao-tree uses internally):
+//!
+//! * Feed the plain file bytes in order.
+//! * Every time a full 16 KiB chunk group completes, compute its BLAKE3
+//! subtree chaining value and compare it against the corresponding leaf
+//! hash held in the (already-verified-by-signature) outboard.
+//! * On the first mismatch, abort immediately — the caller has fetched at
+//! most one chunk group past the tampered byte.
+//!
+//! The outboard is a pre-order sequence of 64-byte (left CV, right CV) parent
+//! pairs. We don't need the interior parents to check leaves: the leaf hashes
+//! are the CVs stored at the lowest level. Rather than reason about pre-order
+//! offsets, we reconstruct the expected per-group leaf CVs top-down from the
+//! outboard once (cheap: the whole outboard is ~0.4% of the file and already in
+//! memory), then stream-check data groups against that vector. We also verify
+//! the outboard's own internal consistency up to the trusted root while doing
+//! so, so a tampered outboard is caught too.
+
+use blake3::Hash;
+use blake3::hazmat::{HasherExt, Mode, merge_subtrees_non_root, merge_subtrees_root};
+
+const CHUNK_LEN: u64 = 1024;
+const CHUNK_GROUP_LOG: u8 = 4;
+/// 16 KiB.
+pub const GROUP_LEN: usize = (CHUNK_LEN as usize) << CHUNK_GROUP_LOG;
+
+#[derive(Debug, thiserror::Error)]
+pub enum VerifyError {
+ #[error(
+ "integrity check failed: chunk group {group} (byte offset {offset}) does not match the signed root"
+ )]
+ GroupMismatch { group: u64, offset: u64 },
+ #[error(
+ "integrity check failed: the outboard tree does not hash to the signed root (tampered .obao)"
+ )]
+ OutboardRootMismatch,
+ #[error("integrity check failed: stream longer than the signed size ({expected} bytes)")]
+ LongStream { expected: u64 },
+ #[error("malformed outboard: {0}")]
+ BadOutboard(&'static str),
+}
+
+/// Expected leaf chaining values, one per 16 KiB group, in file order, derived
+/// from the outboard and checked to hash up to the trusted root.
+#[derive(Debug)]
+pub struct GroupPlan {
+ size: u64,
+ /// Per-group expected CV. The final group may be short.
+ leaf_cvs: Vec,
+}
+
+impl GroupPlan {
+ pub fn group_count(&self) -> usize {
+ self.leaf_cvs.len()
+ }
+
+ /// Build and validate the plan from the trusted root, the file size, and the
+ /// outboard bytes. Fails if the outboard doesn't hash to `root`.
+ pub fn new(root: Hash, size: u64, outboard: &[u8]) -> Result {
+ if size == 0 {
+ // Empty file: the root is the hash of the empty input; no groups.
+ let empty = blake3::hash(b"");
+ if empty != root {
+ return Err(VerifyError::OutboardRootMismatch);
+ }
+ return Ok(GroupPlan {
+ size,
+ leaf_cvs: vec![],
+ });
+ }
+ let group_count = size.div_ceil(GROUP_LEN as u64) as usize;
+ if group_count == 1 {
+ // Single group: the whole file is one leaf and its root hash is the
+ // trusted root. There are no parent pairs, so the outboard is empty.
+ // check_group() recomputes and compares against `root` directly.
+ if !outboard.is_empty() {
+ return Err(VerifyError::BadOutboard(
+ "single-group outboard must be empty",
+ ));
+ }
+ return Ok(GroupPlan {
+ size,
+ leaf_cvs: vec![root],
+ });
+ }
+ let mut leaves = Vec::with_capacity(group_count);
+ let mut cursor = OutboardCursor {
+ data: outboard,
+ pos: 0,
+ };
+ // Walk the tree top-down. Each leaf subtree covers up to GROUP_LEN bytes.
+ let computed_root = build(&mut cursor, 0, size, /* is_root */ true, &mut leaves)?;
+ if cursor.pos != outboard.len() {
+ return Err(VerifyError::BadOutboard("trailing bytes in outboard"));
+ }
+ if computed_root != root {
+ return Err(VerifyError::OutboardRootMismatch);
+ }
+ debug_assert_eq!(leaves.len(), group_count);
+ Ok(GroupPlan {
+ size,
+ leaf_cvs: leaves,
+ })
+ }
+
+ /// Verify the CV of the `group_index`-th group of `data` (the plain group
+ /// bytes) against the plan. Returns the group's byte offset on mismatch.
+ pub fn check_group(&self, group_index: usize, data: &[u8]) -> Result<(), VerifyError> {
+ let expected = self
+ .leaf_cvs
+ .get(group_index)
+ .ok_or(VerifyError::LongStream {
+ expected: self.size,
+ })?;
+ let start_chunk = (group_index as u64) << CHUNK_GROUP_LOG;
+ let is_root = self.leaf_cvs.len() == 1; // single-group file: leaf is the root
+ let actual = leaf_cv(start_chunk, data, is_root);
+ if &actual != expected {
+ return Err(VerifyError::GroupMismatch {
+ group: group_index as u64,
+ offset: start_chunk * CHUNK_LEN,
+ });
+ }
+ Ok(())
+ }
+
+ pub fn size(&self) -> u64 {
+ self.size
+ }
+}
+
+/// Compute the BLAKE3 chaining value (or root hash) of one chunk group.
+fn leaf_cv(start_chunk: u64, data: &[u8], is_root: bool) -> Hash {
+ let mut hasher = blake3::Hasher::new();
+ hasher.set_input_offset(start_chunk * CHUNK_LEN);
+ hasher.update(data);
+ if is_root {
+ hasher.finalize()
+ } else {
+ Hash::from(hasher.finalize_non_root())
+ }
+}
+
+struct OutboardCursor<'a> {
+ data: &'a [u8],
+ pos: usize,
+}
+
+impl<'a> OutboardCursor<'a> {
+ fn read_pair(&mut self) -> Result<(Hash, Hash), VerifyError> {
+ let end = self.pos + 64;
+ if end > self.data.len() {
+ return Err(VerifyError::BadOutboard("outboard shorter than expected"));
+ }
+ let l: [u8; 32] = self.data[self.pos..self.pos + 32].try_into().unwrap();
+ let r: [u8; 32] = self.data[self.pos + 32..end].try_into().unwrap();
+ self.pos = end;
+ Ok((Hash::from(l), Hash::from(r)))
+ }
+}
+
+/// The largest power-of-two number of chunk-groups strictly less than
+/// `group_count`, i.e. the left-subtree split for a subtree of `group_count`
+/// groups. Mirrors bao-tree's tree geometry at chunk-group granularity.
+fn left_groups(group_count: u64) -> u64 {
+ debug_assert!(group_count > 1);
+ // Largest power of two < group_count.
+ let mut p = 1u64;
+ while p << 1 < group_count {
+ p <<= 1;
+ }
+ p
+}
+
+/// Recursively reconstruct the expected leaf CVs from the outboard for the
+/// subtree covering `[start_byte, start_byte+len)` and return the subtree hash.
+fn build(
+ cursor: &mut OutboardCursor,
+ start_byte: u64,
+ len: u64,
+ is_root: bool,
+ leaves: &mut Vec,
+) -> Result {
+ let start_chunk = start_byte / CHUNK_LEN;
+ let groups = len.div_ceil(GROUP_LEN as u64);
+ if groups <= 1 {
+ // Leaf group: its CV isn't stored in the outboard (only parents are).
+ // The caller will recompute it from data; here we record a placeholder
+ // slot and return the CV that the *parent* expects. But we can't know
+ // the leaf CV without the data. Instead we push the parent-provided CV.
+ // This branch is only reached for a single-group file (no parents at
+ // all); handle that by returning a sentinel the caller replaces.
+ unreachable!("build() is only entered when there is at least one parent");
+ }
+ // There is at least one parent pair for this subtree.
+ let (l_cv, r_cv) = cursor.read_pair()?;
+ let left_len_groups = left_groups(groups);
+ let left_bytes = left_len_groups * GROUP_LEN as u64;
+ let right_start = start_byte + left_bytes;
+ let right_len = len - left_bytes;
+
+ let l_actual = subtree(cursor, start_byte, left_bytes, l_cv, leaves)?;
+ let r_actual = subtree(cursor, right_start, right_len, r_cv, leaves)?;
+ if l_actual != l_cv || r_actual != r_cv {
+ return Err(VerifyError::BadOutboard("internal parent mismatch"));
+ }
+ let _ = start_chunk;
+ if is_root {
+ Ok(merge_subtrees_root(
+ &into_cv(l_cv),
+ &into_cv(r_cv),
+ Mode::Hash,
+ ))
+ } else {
+ Ok(Hash::from(merge_subtrees_non_root(
+ &into_cv(l_cv),
+ &into_cv(r_cv),
+ Mode::Hash,
+ )))
+ }
+}
+
+/// Handle a subtree that may be a single leaf group (CV supplied by the parent,
+/// recorded for later data-checking) or a further parent subtree.
+fn subtree(
+ cursor: &mut OutboardCursor,
+ start_byte: u64,
+ len: u64,
+ expected_cv: Hash,
+ leaves: &mut Vec,
+) -> Result {
+ let groups = len.div_ceil(GROUP_LEN as u64);
+ if groups <= 1 {
+ // Leaf: the parent's CV for this position IS the group's expected leaf
+ // CV. Record it in file order; data verification happens during stream.
+ leaves.push(expected_cv);
+ Ok(expected_cv)
+ } else {
+ build(cursor, start_byte, len, /* is_root */ false, leaves)
+ }
+}
+
+fn into_cv(h: Hash) -> blake3::hazmat::ChainingValue {
+ *h.as_bytes()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use bao_tree::BlockSize;
+ use bao_tree::io::outboard::PreOrderOutboard;
+ use bao_tree::io::sync::CreateOutboard;
+ use std::io::Cursor;
+
+ const BS: BlockSize = BlockSize::from_chunk_log(CHUNK_GROUP_LOG);
+
+ fn make(size: usize) -> (Hash, u64, Vec, Vec) {
+ let mut data = vec![0u8; size];
+ for (i, b) in data.iter_mut().enumerate() {
+ *b = (i % 251) as u8;
+ }
+ let ob = PreOrderOutboard::>::create(Cursor::new(&data), BS).unwrap();
+ (ob.root, size as u64, ob.data, data)
+ }
+
+ fn run_plan(root: Hash, size: u64, outboard: &[u8], data: &[u8]) -> Result<(), VerifyError> {
+ let plan = GroupPlan::new(root, size, outboard)?;
+ for (i, group) in data.chunks(GROUP_LEN).enumerate() {
+ plan.check_group(i, group)?;
+ }
+ Ok(())
+ }
+
+ #[test]
+ fn plan_root_matches_blake3() {
+ for &size in &[
+ 0usize,
+ 1,
+ 1000,
+ GROUP_LEN,
+ GROUP_LEN + 1,
+ 5 * GROUP_LEN + 37,
+ 200_000,
+ ] {
+ let (root, sz, ob, data) = make(size);
+ assert_eq!(root, blake3::hash(&data), "size {size}");
+ run_plan(root, sz, &ob, &data).unwrap_or_else(|e| panic!("size {size}: {e}"));
+ }
+ }
+
+ #[test]
+ fn detects_single_byte_flip_in_each_group() {
+ let (root, size, ob, mut data) = make(5 * GROUP_LEN + 100);
+ let group_count = data.len().div_ceil(GROUP_LEN);
+ for g in 0..group_count {
+ let mut d = data.clone();
+ let idx = g * GROUP_LEN + 3;
+ d[idx] ^= 0xFF;
+ let err = run_plan(root, size, &ob, &d).unwrap_err();
+ match err {
+ VerifyError::GroupMismatch { group, offset } => {
+ assert_eq!(group as usize, g);
+ assert_eq!(offset, (g * GROUP_LEN) as u64);
+ }
+ other => panic!("group {g}: wrong error {other}"),
+ }
+ }
+ let _ = &mut data;
+ }
+
+ #[test]
+ fn detects_tampered_outboard() {
+ let (root, size, mut ob, _data) = make(10 * GROUP_LEN);
+ ob[10] ^= 0xFF;
+ let err = GroupPlan::new(root, size, &ob).unwrap_err();
+ assert!(matches!(
+ err,
+ VerifyError::OutboardRootMismatch | VerifyError::BadOutboard(_)
+ ));
+ }
+
+ #[test]
+ fn detects_wrong_root() {
+ let (_root, size, ob, _data) = make(3 * GROUP_LEN);
+ let wrong = blake3::hash(b"not it");
+ assert!(GroupPlan::new(wrong, size, &ob).is_err());
+ }
+}
diff --git a/tests/tamper.rs b/tests/tamper.rs
new file mode 100644
index 0000000..8832a3e
--- /dev/null
+++ b/tests/tamper.rs
@@ -0,0 +1,181 @@
+//! End-to-end integration test driving the real `blacklight` binary over a
+//! local HTTP server, exercising the clean path and several tampering attacks.
+//!
+//! No Sigstore here (that path needs interactive OIDC); these tests cover the
+//! verified-streaming half — the part that must abort mid-transfer on the first
+//! bad byte. Signing/verification of the manifest is covered by unit-level API
+//! use plus the manual staging check documented in the README.
+
+use std::io::{Read, Write};
+use std::net::{TcpListener, TcpStream};
+use std::path::{Path, PathBuf};
+use std::process::Command;
+use std::sync::Arc;
+use std::sync::atomic::{AtomicUsize, Ordering};
+use std::thread;
+
+const GROUP: usize = 16 * 1024;
+
+fn bin() -> PathBuf {
+ // Cargo sets CARGO_BIN_EXE_ for integration tests.
+ PathBuf::from(env!("CARGO_BIN_EXE_blacklight"))
+}
+
+/// Minimal single-request-per-connection HTTP/1.1 file server rooted at `dir`.
+/// If `tamper` is Some((path, offset)), it flips one byte of that path's body.
+fn serve(dir: PathBuf, tamper: Option<(String, usize)>) -> (String, Arc) {
+ let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+ let addr = listener.local_addr().unwrap();
+ let served = Arc::new(AtomicUsize::new(0));
+ let served2 = served.clone();
+ thread::spawn(move || {
+ for stream in listener.incoming() {
+ let Ok(stream) = stream else { continue };
+ let dir = dir.clone();
+ let tamper = tamper.clone();
+ let counter = served2.clone();
+ thread::spawn(move || handle(stream, &dir, tamper, counter));
+ }
+ });
+ (format!("http://{addr}"), served)
+}
+
+fn handle(
+ mut stream: TcpStream,
+ dir: &Path,
+ tamper: Option<(String, usize)>,
+ counter: Arc,
+) {
+ let mut buf = [0u8; 8192];
+ let n = stream.read(&mut buf).unwrap_or(0);
+ if n == 0 {
+ return;
+ }
+ let req = String::from_utf8_lossy(&buf[..n]);
+ let path = req.split_whitespace().nth(1).unwrap_or("/").to_string();
+ let rel = path.trim_start_matches('/');
+ let full = dir.join(rel);
+ let mut body = match std::fs::read(&full) {
+ Ok(b) => b,
+ Err(_) => {
+ let _ = stream.write_all(
+ b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
+ );
+ return;
+ }
+ };
+ // Match guard rather than an `if let` chain, so this test compiles on the
+ // documented MSRV (let-chains stabilized later than edition 2024's floor).
+ match &tamper {
+ Some((tpath, off)) if &path == tpath && *off < body.len() => {
+ body[*off] ^= 0xFF;
+ }
+ _ => {}
+ }
+ counter.fetch_add(1, Ordering::SeqCst);
+ let header = format!(
+ "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
+ body.len()
+ );
+ let _ = stream.write_all(header.as_bytes());
+ let _ = stream.write_all(&body);
+ let _ = stream.flush();
+}
+
+fn publish(dir: &Path, name: &str, bytes: &[u8]) {
+ std::fs::write(dir.join(name), bytes).unwrap();
+ let status = Command::new(bin())
+ .arg("publish")
+ .arg(dir.join(name))
+ .arg("--unsigned")
+ .status()
+ .unwrap();
+ assert!(status.success(), "publish failed");
+}
+
+fn fetch(base: &str, name: &str, out: &Path) -> std::process::Output {
+ Command::new(bin())
+ .arg("fetch")
+ .arg(format!("{base}/{name}.blacklight.json"))
+ .arg("--allow-unsigned")
+ .arg("-o")
+ .arg(out)
+ .output()
+ .unwrap()
+}
+
+fn sample(size: usize) -> Vec {
+ (0..size).map(|i| ((i * 7 + 3) % 256) as u8).collect()
+}
+
+#[test]
+fn clean_download_succeeds_and_matches() {
+ let dir = tempdir();
+ let data = sample(5 * GROUP + 123);
+ publish(&dir, "demo.bin", &data);
+ let (base, _) = serve(dir.clone(), None);
+ let out = dir.join("out.bin");
+ let o = fetch(&base, "demo.bin", &out);
+ assert!(
+ o.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&o.stderr)
+ );
+ assert_eq!(std::fs::read(&out).unwrap(), data);
+}
+
+#[test]
+fn tampered_artifact_aborts_with_no_output() {
+ let dir = tempdir();
+ let data = sample(10 * GROUP + 7);
+ publish(&dir, "demo.bin", &data);
+ // Flip a byte inside group 3.
+ let off = 3 * GROUP + 500;
+ let (base, _) = serve(dir.clone(), Some(("/demo.bin".into(), off)));
+ let out = dir.join("out.bin");
+ let o = fetch(&base, "demo.bin", &out);
+ assert_eq!(o.status.code(), Some(3), "expected integrity exit code 3");
+ let stderr = String::from_utf8_lossy(&o.stderr);
+ assert!(stderr.contains("chunk group 3"), "stderr: {stderr}");
+ assert!(!out.exists(), "a partial output file was left behind");
+}
+
+#[test]
+fn tampered_outboard_is_rejected_before_streaming() {
+ let dir = tempdir();
+ let data = sample(8 * GROUP);
+ publish(&dir, "demo.bin", &data);
+ // Corrupt the outboard tree; it must no longer hash to the signed root.
+ let (base, _) = serve(dir.clone(), Some(("/demo.bin.obao".into(), 40)));
+ let out = dir.join("out.bin");
+ let o = fetch(&base, "demo.bin", &out);
+ assert_eq!(o.status.code(), Some(3));
+ assert!(!out.exists());
+}
+
+#[test]
+fn truncated_stream_is_rejected() {
+ // The last group is short; if the server drops it entirely, length check fails.
+ let dir = tempdir();
+ let data = sample(4 * GROUP);
+ publish(&dir, "demo.bin", &data);
+ // Overwrite hosted artifact with a truncated copy (server serves as-is).
+ std::fs::write(dir.join("demo.bin"), &data[..3 * GROUP]).unwrap();
+ let (base, _) = serve(dir.clone(), None);
+ let out = dir.join("out.bin");
+ let o = fetch(&base, "demo.bin", &out);
+ assert_eq!(o.status.code(), Some(3));
+ assert!(!out.exists());
+}
+
+fn tempdir() -> PathBuf {
+ let base = std::env::temp_dir().join(format!(
+ "blacklight-it-{}-{}",
+ std::process::id(),
+ NEXT.fetch_add(1, Ordering::SeqCst)
+ ));
+ std::fs::create_dir_all(&base).unwrap();
+ base
+}
+
+static NEXT: AtomicUsize = AtomicUsize::new(0);