diff --git a/.github/workflows/echo-keep-experimental.yml b/.github/workflows/echo-keep-experimental.yml new file mode 100644 index 00000000..b9b05946 --- /dev/null +++ b/.github/workflows/echo-keep-experimental.yml @@ -0,0 +1,57 @@ +# SPDX-License-Identifier: Apache-2.0 +# © James Ross Ω FLYING•ROBOTS +name: Experimental Keep contract +on: + pull_request: + push: + branches: [main] +permissions: + contents: read +jobs: + identity: + name: Echo–Keep experimental conformance + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + CARGO_INCREMENTAL: 0 + CARGO_BUILD_JOBS: 2 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@1.96.0 + with: + components: rustfmt, clippy + - run: cargo test --manifest-path experiments/echo-keep/Cargo.toml --locked + - run: cargo clippy --manifest-path experiments/echo-keep/Cargo.toml --locked --all-targets -- -D warnings + - run: cargo fmt --manifest-path experiments/echo-keep/Cargo.toml -- --check + - name: Verify dependency-tree failure handling + run: bash scripts/tests/keep_dependency_boundary_test.sh + - name: Preserve the default CAS toolchain and dependency boundary + run: | + rustup toolchain install 1.90.0 --profile minimal + cargo +1.90.0 check --locked -p echo-cas + dependency_tree=$(cargo tree --locked -p echo-cas) + if grep -q "keep v" <<< "$dependency_tree"; then exit 1; fi + + dependency-policy: + name: Experimental Keep dependency policy + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Generate and verify scoped source policy + run: | + python3 scripts/keep_deny_policy.py .echo-keep-deny.toml + python3 scripts/tests/keep_deny_policy_test.py + - uses: EmbarkStudios/cargo-deny-action@76cd80eb775d7bbbd2d80292136d74d39e1b4918 # v2.0.14 + with: + manifest-path: experiments/echo-keep/Cargo.toml + rust-version: "1.96.0" + arguments: --locked --all-features + command-arguments: --config /github/workspace/.echo-keep-deny.toml + - name: Remove generated policy + if: always() + run: rm -f .echo-keep-deny.toml diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index 6d09b04c..207a0b4b 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -31,6 +31,8 @@ jobs: if ! command -v cargo-audit >/dev/null; then cargo install cargo-audit --locked fi + - name: Verify isolated lockfile audit coverage + run: bash scripts/tests/keep_audit_lockfiles_test.sh - name: Run cargo audit run: | set -euo pipefail diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a27fdbb..1d98d1e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ ### Added +- An isolated experimental Echo–Keep identity bridge checks both independent hash laws and exact byte length from one bounded stream. It leaves the default CAS dependency graph unchanged. + - A fallible complete-object CAS port stages and verifies exact bytes before atomic destination promotion. Memory and disk adapters share conformance checks; existing APIs remain compatible and no durability or authenticated absence is claimed. ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 86c310b4..078b403d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -128,6 +128,8 @@ prioritize: ### Runtime and provider toolchains +The isolated `experiments/echo-keep` workspace requires Rust 1.96.0 and remains outside default dependency resolution. Its manifest is part of the same explicit MSRV inventory; experimental packages do not bypass the version guard. + The general toolchain is Rust 1.96.0 because `bunny-num` 0.6.0 requires it. Numerical consumers declare MSRV 1.96.0. Independent leaves retain the workspace default MSRV 1.90.0; [the explicit policy](scripts/rust-msrv-policy.tsv) enumerates diff --git a/docs/architecture/echo-keep-physical-content-boundary.md b/docs/architecture/echo-keep-physical-content-boundary.md index 1d74c7d4..4e94ce4e 100644 --- a/docs/architecture/echo-keep-physical-content-boundary.md +++ b/docs/architecture/echo-keep-physical-content-boundary.md @@ -6,7 +6,7 @@ - **Status:** Accepted for experimental conformance; production adoption is not accepted. - **Decision date:** 2026-08-09 -- **Implementation posture:** `echo-cas::physical_content` supplies a fallible complete-object port and MemoryTier/DiskTier adapters. Borrowed views authenticate exact bytes but certify no pinned generation, complete-view absence, retention, synchronization, or crash durability. No Keep backend adapter is implemented yet. +- **Implementation posture:** `experiments/echo-keep` supplies a bounded dual-identity bridge against Keep revision `3165890e9291cfb5fe10e81a9d7cd151f3e59464` in a separate Rust 1.96 workspace. `echo-cas::physical_content` supplies the fallible complete-object port and MemoryTier/DiskTier adapters. Borrowed views certify no pinned filesystem generation, complete-view absence, retention, synchronization, or crash durability. No Keep backend adapter is implemented yet. Echo CAS remains the default. - **Refines:** [Retained reading storage and proof boundary](../adr/0020-retained-reading-storage-and-proof-boundary.md) - **Depends on:** [Durable external-action settlement](../adr/0026-durable-external-action-settlement.md) - **Related:** [Keep authenticated reconstruction contract](https://github.com/flyingrobots/keep/blob/3bf7b9179db41e90620e6d1875c2d40222a2330b/docs/architecture/authenticated-reconstruction-contract.md) diff --git a/experiments/echo-keep/Cargo.lock b/experiments/echo-keep/Cargo.lock new file mode 100644 index 00000000..522df29b --- /dev/null +++ b/experiments/echo-keep/Cargo.lock @@ -0,0 +1,478 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + +[[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 = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[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", +] + +[[package]] +name = "cap-fs-ext" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78e5a3368ae89b7cb68186411452b4b9fac8b41be9c19bf3f47c2d2c8e36e6b" +dependencies = [ + "cap-primitives", + "cap-std", + "io-lifetimes 3.0.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "cap-primitives" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b5f74729fd2f44701d1a8eb47e906cdb3ccd9ec0f02baad85a744b791940b18" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes 3.0.1", + "ipnet", + "maybe-owned", + "rustix", + "rustix-linux-procfs", + "windows-sys 0.61.2", + "winx", +] + +[[package]] +name = "cap-std" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7281235d6e96d3544ca18bba9049be92f4190f8d923e3caef1b5f66cfa752608" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes 3.0.1", + "rustix", +] + +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "echo-cas" +version = "0.1.0" +dependencies = [ + "blake3", + "thiserror", +] + +[[package]] +name = "echo-keep-experimental" +version = "0.0.0" +dependencies = [ + "blake3", + "echo-cas", + "keep", + "thiserror", +] + +[[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 = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes 2.0.4", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "io-extras" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" +dependencies = [ + "io-lifetimes 3.0.1", + "windows-sys 0.60.2", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "io-lifetimes" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "keep" +version = "0.0.0" +source = "git+https://github.com/flyingrobots/keep?rev=3165890e9291cfb5fe10e81a9d7cd151f3e59464#3165890e9291cfb5fe10e81a9d7cd151f3e59464" +dependencies = [ + "blake3", + "cap-fs-ext", + "cap-std", + "rustix", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[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.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +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 = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags", + "windows-sys 0.59.0", +] diff --git a/experiments/echo-keep/Cargo.toml b/experiments/echo-keep/Cargo.toml new file mode 100644 index 00000000..e7f4dfc0 --- /dev/null +++ b/experiments/echo-keep/Cargo.toml @@ -0,0 +1,19 @@ +# SPDX-License-Identifier: Apache-2.0 +# © James Ross Ω FLYING•ROBOTS +[package] +name = "echo-keep-experimental" +version = "0.0.0" +edition = "2024" +rust-version = "1.96.0" +publish = false +license = "Apache-2.0" +description = "Experimental identity conformance for Echo and Keep" + +# A separate workspace keeps Keep out of Echo default dependency resolution. +[workspace] + +[dependencies] +echo-cas = { version = "0.1.0", path = "../../crates/echo-cas" } +blake3 = "=1.8.5" +keep = { version = "=0.0.0", git = "https://github.com/flyingrobots/keep", rev = "3165890e9291cfb5fe10e81a9d7cd151f3e59464" } +thiserror = "2" diff --git a/experiments/echo-keep/README.md b/experiments/echo-keep/README.md new file mode 100644 index 00000000..47c25485 --- /dev/null +++ b/experiments/echo-keep/README.md @@ -0,0 +1,23 @@ + + + +# Experimental Echo–Keep identity bridge + +This separate Rust 1.96 workspace pins Keep at `3165890e9291cfb5fe10e81a9d7cd151f3e59464`. It does not enter Echo’s default workspace or change `echo-cas`’s Rust 1.90 dependency graph. + +`IdentityBinding::from_source` computes raw BLAKE3 for Echo and the version-1 Keep domain, bytes, and exact-length hash from one bounded stream. The binding keeps Keep coordinates private and has no persisted encoding. `verify_source` rechecks both identities and length. Neither method proves presence, retention, publication, or durability. + +The conformance witness stages the named Keep golden vectors into `ReferenceStore`, reconstructs them, and rechecks exact bytes, both identities, and length. The largest fixture is one MiB. Source failures, limits, and coordinate substitution refuse a complete binding. This does not establish a durable backend or a physical-content port. + +Run these checks inside the admitted, bounded Docker worker: + +```sh +cargo +1.96.0 test --manifest-path experiments/echo-keep/Cargo.toml --locked +cargo +1.96.0 clippy --manifest-path experiments/echo-keep/Cargo.toml --locked --all-targets -- -D warnings +cargo +1.96.0 fmt --manifest-path experiments/echo-keep/Cargo.toml -- --check +cargo +1.90.0 check --locked -p echo-cas +``` + +[The canonical boundary](../../docs/architecture/echo-keep-physical-content-boundary.md) owns the contract. [Issue #759](https://github.com/flyingrobots/echo/issues/759) owns this identity slice. + +The isolated graph has its own dependency-policy CI check. It derives all license, ban, advisory, and source rules from the root policy, with one scoped allowance for the pinned Keep Git source. The production workspace policy remains unchanged. diff --git a/experiments/echo-keep/src/lib.rs b/experiments/echo-keep/src/lib.rs new file mode 100644 index 00000000..49dc70e9 --- /dev/null +++ b/experiments/echo-keep/src/lib.rs @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS +//! Experimental identity conformance for the Echo–Keep physical-content boundary. +//! +//! Echo hashes raw bytes. Keep has a distinct, versioned identity law. A binding +//! authenticates one bounded source under both laws; it proves no storage presence, +//! retention, visibility, or durability. This crate has no persisted binding ABI. +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +use echo_cas::BlobHash; +use keep::{BlobHasher, BlobId}; +use std::io::{self, Read}; + +/// An opaque in-process binding under the version-1 identity bridge. +/// +/// Keep coordinates stay private. This experimental type has no wire encoding. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct IdentityBinding { + echo: BlobHash, + keep: BlobId, + length: u64, +} + +impl std::fmt::Debug for IdentityBinding { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("IdentityBinding") + .field("echo", &self.echo) + .field("length", &self.length) + .finish_non_exhaustive() + } +} + +/// Operational or verification failure; no complete identity claim is returned. +#[derive(Debug, thiserror::Error)] +pub enum IdentityError { + /// Input I/O failed; the original cause is retained. + #[error("identity source I/O failed: {0}")] + Input(#[from] io::Error), + /// Source bytes or the requested complete verification exceed the byte limit. + #[error("identity source exceeds its byte limit")] + ResourceLimit, + /// A reader returned an impossible byte count. + #[error("identity source returned an invalid read count")] + InvalidReadCount, + /// Checked Keep length accounting failed. + #[error("Keep identity accounting failed: {0}")] + Accounting(#[source] Box), + /// Exact Echo identity, Keep identity, or length did not match the binding. + #[error("reconstructed bytes do not match the identity binding")] + Mismatch, +} + +impl IdentityBinding { + /// Computes both independent laws from one bounded, non-materializing stream. + /// + /// Interrupted reads are retried. EOF seals the binding. The limit applies + /// to accepted source bytes, with one fixed 8 KiB read buffer. Reads request + /// at most the remaining allowance plus one overlength probe. + /// + /// # Errors + /// Returns an operational error or resource refusal without a binding. + pub fn from_source(source: &mut dyn Read, byte_limit: u64) -> Result { + let mut echo = blake3::Hasher::new(); + let mut keep = BlobHasher::new(); + let mut length = 0_u64; + let mut buffer = [0_u8; 8192]; + loop { + let remaining = byte_limit - length; + let request = usize::try_from(remaining.saturating_add(1)) + .unwrap_or(usize::MAX) + .min(buffer.len()); + let window = &mut buffer[..request]; + let count = match source.read(window) { + Ok(0) => break, + Ok(count) => count, + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error.into()), + }; + let bytes = window.get(..count).ok_or(IdentityError::InvalidReadCount)?; + let incoming = u64::try_from(count).map_err(|_| IdentityError::ResourceLimit)?; + length = length + .checked_add(incoming) + .filter(|n| *n <= byte_limit) + .ok_or(IdentityError::ResourceLimit)?; + echo.update(bytes); + keep.update(bytes) + .map_err(|error| IdentityError::Accounting(Box::new(error)))?; + } + Ok(Self { + echo: BlobHash::from_bytes(*echo.finalize().as_bytes()), + keep: keep.finish(), + length, + }) + } + + /// Returns the raw-content Echo identity. + pub const fn echo_identity(&self) -> BlobHash { + self.echo + } + + /// Returns the exact authenticated length. + pub const fn length(&self) -> u64 { + self.length + } + + /// Rechecks both laws and exact length on a bounded reconstructed source. + /// + /// # Errors + /// Returns `ResourceLimit` before reading when the declared binding cannot + /// fit the byte limit. Otherwise, observed identity or length disagreement + /// returns `Mismatch`. Source failures establish no complete identity. + pub fn verify_source( + &self, + source: &mut dyn Read, + byte_limit: u64, + ) -> Result<(), IdentityError> { + if byte_limit < self.length { + return Err(IdentityError::ResourceLimit); + } + let observed = match Self::from_source(source, self.length) { + Err(IdentityError::ResourceLimit) => return Err(IdentityError::Mismatch), + result => result?, + }; + if observed == *self { + Ok(()) + } else { + Err(IdentityError::Mismatch) + } + } +} + +#[cfg(test)] +mod tests; diff --git a/experiments/echo-keep/src/tests.rs b/experiments/echo-keep/src/tests.rs new file mode 100644 index 00000000..55205bbc --- /dev/null +++ b/experiments/echo-keep/src/tests.rs @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS +use super::*; +use keep::{LayoutEntryLimit, ReferenceStore, ReferenceStoreCapacity}; +use std::io::Cursor; + +#[test] +fn pinned_vectors_bind_distinct_laws_and_reconstructed_bytes() +-> Result<(), Box> { + // Keep golden-file-worldline/v1 vectors at the pinned revision. + let cases = [ + ( + Vec::new(), + "keep:blob:v1:blake3-256:0:c0074a279c09f9d019dc10e4c821f79f1450cfb8541ab4627132ab9f3c75e33f", + ), + ( + b"Keep exact bytes.\n".to_vec(), + "keep:blob:v1:blake3-256:18:af75d70e4993121254ac71f16c5edd02410a36f94d795e4d6064ed3122b7967d", + ), + ( + (0..=255).collect::>(), + "keep:blob:v1:blake3-256:256:e782f90f48483f6a8520c9b05eca57ace1647374dd9456b9e41aadccacd10f12", + ), + ( + (0..=255).cycle().take(1_048_576).collect::>(), + "keep:blob:v1:blake3-256:1048576:25399c3df18ecd403c8cacf50a44409e005ca71452e4ad367bd14423c1f86e20", + ), + ]; + for (bytes, vector) in cases { + let limit = bytes.len() as u64; + let binding = IdentityBinding::from_source(&mut Cursor::new(&bytes), limit)?; + assert_eq!(binding.echo, echo_cas::blob_hash(&bytes)); + assert_eq!(binding.keep, vector.parse()?); + assert_eq!(binding.length, limit); + // Keep digest domain and framing never become an Echo CAS coordinate. + let mut independent = blake3::Hasher::new(); + independent.update(b"KEEP:BLOB:DATA\0\0"); + independent.update(&1_u16.to_be_bytes()); + independent.update(&[1]); + independent.update(&bytes); + independent.update(&limit.to_be_bytes()); + let expected = format!( + "keep:blob:v1:blake3-256:{limit}:{}", + independent.finalize().to_hex() + ); + assert_eq!(binding.keep.to_string(), expected); + assert_ne!( + binding.echo.to_string(), + independent.finalize().to_hex().to_string() + ); + let mut store = ReferenceStore::new(ReferenceStoreCapacity::new(bytes.len())); + let published = store + .stage_expected( + &mut Cursor::new(&bytes), + binding.keep, + LayoutEntryLimit::MAXIMUM, + )? + .commit(&mut store)?; + let mut output = Vec::new(); + let receipt = store.reconstruct(published.target(), &mut output)?; + assert_eq!(receipt.target(), binding.keep); + assert_eq!(receipt.bytes_written().get(), binding.length); + assert_eq!(output, bytes); + binding.verify_source(&mut Cursor::new(&output), limit)?; + } + // Official raw-BLAKE3 empty vector anchors the Echo domain independently. + assert_eq!( + echo_cas::blob_hash(b"").to_string(), + "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262" + ); + Ok(()) +} + +#[test] +fn substitution_and_length_mismatch_refuse() -> Result<(), IdentityError> { + let binding = IdentityBinding::from_source(&mut Cursor::new(b"abc"), 3)?; + assert!(matches!( + binding.verify_source(&mut Cursor::new(b"abd"), 3), + Err(IdentityError::Mismatch) + )); + assert!(matches!( + binding.verify_source(&mut Cursor::new(b"abcd"), 3), + Err(IdentityError::Mismatch) + )); + for changed in [ + IdentityBinding { + echo: echo_cas::blob_hash(b"other"), + ..binding + }, + IdentityBinding { + keep: BlobId::hash_bytes(b"other") + .map_err(|error| IdentityError::Accounting(Box::new(error)))?, + ..binding + }, + IdentityBinding { + length: 4, + ..binding + }, + ] { + assert!(matches!( + changed.verify_source(&mut Cursor::new(b"abc"), 4), + Err(IdentityError::Mismatch) + )); + } + assert!(matches!( + IdentityBinding::from_source(&mut Cursor::new(b"abcd"), 3), + Err(IdentityError::ResourceLimit) + )); + Ok(()) +} + +struct AdversarialRead { + phase: u8, + failure: bool, +} +impl Read for AdversarialRead { + fn read(&mut self, out: &mut [u8]) -> io::Result { + self.phase += 1; + match self.phase { + 1 => Err(io::ErrorKind::Interrupted.into()), + 2 => { + out[0] = 120; + Ok(1) + } + _ if self.failure => Err(io::ErrorKind::BrokenPipe.into()), + _ => Ok(0), + } + } +} +#[test] +fn interrupted_short_source_retries_but_failed_source_returns_no_binding() +-> Result<(), IdentityError> { + let binding = IdentityBinding::from_source( + &mut AdversarialRead { + phase: 0, + failure: false, + }, + 1, + )?; + assert_eq!(binding.echo, echo_cas::blob_hash(b"x")); + assert!( + matches!(IdentityBinding::from_source(&mut AdversarialRead { phase: 0, failure: true }, 1), Err(IdentityError::Input(e)) if e.kind() == io::ErrorKind::BrokenPipe) + ); + Ok(()) +} + +#[test] +fn complete_binding_budget_preflight_does_not_read_source() -> Result<(), IdentityError> { + struct UnreadSource(bool); + impl Read for UnreadSource { + fn read(&mut self, _: &mut [u8]) -> io::Result { + self.0 = true; + Err(io::ErrorKind::BrokenPipe.into()) + } + } + let binding = IdentityBinding::from_source(&mut Cursor::new(b"abc"), 3)?; + let mut source = UnreadSource(false); + assert!(matches!( + binding.verify_source(&mut source, 1), + Err(IdentityError::ResourceLimit) + )); + assert!(!source.0); + assert!(matches!( + binding.verify_source(&mut Cursor::new(b""), 3), + Err(IdentityError::Mismatch) + )); + Ok(()) +} + +#[test] +fn identity_reader_stops_at_one_overlength_probe() -> Result<(), IdentityError> { + struct Endless { + consumed: usize, + } + impl Read for Endless { + fn read(&mut self, bytes: &mut [u8]) -> io::Result { + bytes.fill(97); + self.consumed += bytes.len(); + Ok(bytes.len()) + } + } + let binding = IdentityBinding::from_source(&mut Cursor::new(b"aaa"), 3)?; + let mut source = Endless { consumed: 0 }; + assert!(matches!( + binding.verify_source(&mut source, 32), + Err(IdentityError::Mismatch) + )); + assert_eq!(source.consumed, 4); + let mut source = Endless { consumed: 0 }; + assert!(matches!( + IdentityBinding::from_source(&mut source, 1), + Err(IdentityError::ResourceLimit) + )); + assert_eq!(source.consumed, 2); + Ok(()) +} + +#[test] +fn binding_debug_keeps_backend_coordinates_private() -> Result<(), IdentityError> { + let binding = IdentityBinding::from_source(&mut Cursor::new(b"abc"), 3)?; + let debug = format!("{binding:?}"); + assert!(!debug.contains("keep"), "{debug}"); + assert!(!debug.contains(&format!("{:?}", binding.keep)), "{debug}"); + Ok(()) +} diff --git a/scripts/check_rust_versions.sh b/scripts/check_rust_versions.sh index 4be880e0..81a461a8 100755 --- a/scripts/check_rust_versions.sh +++ b/scripts/check_rust_versions.sh @@ -30,7 +30,7 @@ while read -r key version extra || [[ -n "$key$version$extra" ]]; do [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "invalid MSRV policy version: $key $version" case "$key" in toolchain|workspace|xtask/Cargo.toml|tests/edict-provider-host-v1/Cargo.toml) ;; - crates/*/Cargo.toml|specs/*/Cargo.toml) + crates/*/Cargo.toml|specs/*/Cargo.toml|experiments/*/Cargo.toml) [[ "$key" != *"/../"* && "$key" != *"/./"* && "$key" != *"//"* ]] || fail "invalid MSRV policy path: $key" ;; *) fail "unknown MSRV policy key: $key" ;; @@ -94,7 +94,7 @@ manifests=() manifest_count=0 # Include nested manifests, the build driver, and the independent host witness. # Their presence must be accounted for explicitly in the policy inventory. -for root in "$repo_root/crates" "$repo_root/specs"; do +for root in "$repo_root/crates" "$repo_root/specs" "$repo_root/experiments"; do if [[ -d "$root" ]]; then while IFS= read -r manifest; do manifests[$manifest_count]="$manifest" diff --git a/scripts/keep_deny_policy.py b/scripts/keep_deny_policy.py new file mode 100644 index 00000000..26c69864 --- /dev/null +++ b/scripts/keep_deny_policy.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# © James Ross Ω FLYING•ROBOTS +"""Derive the experimental policy without weakening the root workspace policy.""" +import argparse +import json +from pathlib import Path +import re +import tomllib + +KEEP_SOURCE = "https://github.com/flyingrobots/keep" + +def derive_policy(policy_text, manifest_text): + """Preserve all root rules, admitting only the inspected Keep Git source.""" + policy = tomllib.loads(policy_text) + dependency = tomllib.loads(manifest_text)["dependencies"]["keep"] + if dependency.get("git") != KEEP_SOURCE or not re.fullmatch(r"[0-9a-f]{40}", dependency.get("rev", "")): + raise ValueError("experiment requires the reviewed Keep source and an exact revision") + if "allow-git" in policy["sources"] or policy_text.count("[sources]") != 1: + raise ValueError("root source policy changed; reconcile the scoped exception explicitly") + result = policy_text.replace("[sources]", "[sources]\nallow-git = [" + json.dumps(KEEP_SOURCE) + "]", 1) + parsed = tomllib.loads(result) + assert parsed["sources"].pop("allow-git") == [KEEP_SOURCE] + assert parsed == policy + return result + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("output", type=Path) + args = parser.parse_args() + root = Path(__file__).resolve().parent.parent + result = derive_policy((root / "deny.toml").read_text(), (root / "experiments/echo-keep/Cargo.toml").read_text()) + with args.output.open("x") as output: + output.write(result) + +if __name__ == "__main__": + main() diff --git a/scripts/run_cargo_audit.sh b/scripts/run_cargo_audit.sh index f1991bfd..e71154a5 100755 --- a/scripts/run_cargo_audit.sh +++ b/scripts/run_cargo_audit.sh @@ -48,4 +48,10 @@ if [[ "${#ignore_ids[@]}" -ne 0 ]]; then printf ' - %s\n' "${ignore_ids[@]}" >&2 fi -cargo audit --deny warnings "${ignore_flags[@]}" +[[ -f "$repo_root/Cargo.lock" ]] || { echo "Error: root Cargo.lock is missing" >&2; exit 1; } +# The experiment has a separate resolver and must have its own advisory check. +for lockfile in "$repo_root/Cargo.lock" "$repo_root/experiments/echo-keep/Cargo.lock"; do + if [[ -f "$lockfile" ]]; then + cargo audit --file "$lockfile" --deny warnings "${ignore_flags[@]}" + fi +done diff --git a/scripts/rust-msrv-policy.tsv b/scripts/rust-msrv-policy.tsv index d8350dc1..05ddde2e 100644 --- a/scripts/rust-msrv-policy.tsv +++ b/scripts/rust-msrv-policy.tsv @@ -29,3 +29,4 @@ crates/warp-math/Cargo.toml 1.96.0 crates/warp-wasm/Cargo.toml 1.96.0 tests/edict-provider-host-v1/Cargo.toml 1.96.0 xtask/Cargo.toml 1.96.0 +experiments/echo-keep/Cargo.toml 1.96.0 diff --git a/scripts/tests/check_rust_versions_test.sh b/scripts/tests/check_rust_versions_test.sh index ea8ddf2e..a11496b7 100644 --- a/scripts/tests/check_rust_versions_test.sh +++ b/scripts/tests/check_rust_versions_test.sh @@ -358,9 +358,44 @@ test_standalone_host_toolchain_matches_its_policy() { [[ "$failures" -eq 0 ]] } +test_rejects_unregistered_experiment() { + with_tmp_repo bash -c ' + set -euo pipefail + mkdir -p experiments/unknown + cp crates/foo/Cargo.toml experiments/unknown/Cargo.toml + if ./scripts/check_rust_versions.sh > refusal.log 2>&1; then exit 1; fi + grep -q "unregistered.*experiments/unknown/Cargo.toml" refusal.log + ' +} + +test_experiment_policy_requires_exact_version() { + for version in 1.90.0 1.96.0; do + with_tmp_repo bash -c ' + set -euo pipefail + sed "s/1.90.0/1.96.0/" rust-toolchain.toml > replacement + mv replacement rust-toolchain.toml + sed "s/toolchain 1.90.0/toolchain 1.96.0/" scripts/rust-msrv-policy.tsv > replacement + mv replacement scripts/rust-msrv-policy.tsv + mkdir -p experiments/example + cp crates/foo/Cargo.toml experiments/example/Cargo.toml + sed "s/1.90.0/$1/" experiments/example/Cargo.toml > replacement + mv replacement experiments/example/Cargo.toml + printf "%s\n" "experiments/example/Cargo.toml 1.96.0" >> scripts/rust-msrv-policy.tsv + if [[ "$1" == 1.96.0 ]]; then + ./scripts/check_rust_versions.sh + else + if ./scripts/check_rust_versions.sh > refusal.log 2>&1; then exit 1; fi + grep -q "rust-version mismatch.*experiments/example/Cargo.toml" refusal.log + fi + ' bash "$version" + done +} + main() { [[ -f "$checker_src" ]] || fail "checker script missing: $checker_src" + test_rejects_unregistered_experiment + test_experiment_policy_requires_exact_version test_standalone_host_toolchain_matches_its_policy test_rejects_metadata_only_versions test_ignores_metadata_before_package diff --git a/scripts/tests/keep_audit_lockfiles_test.sh b/scripts/tests/keep_audit_lockfiles_test.sh new file mode 100644 index 00000000..444572de --- /dev/null +++ b/scripts/tests/keep_audit_lockfiles_test.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# © James Ross Ω FLYING•ROBOTS +set -euo pipefail +root=$(git rev-parse --show-toplevel) +work=$(mktemp -d "${TMPDIR:-/tmp}/echo-audit-lockfiles.XXXXXX") +trap 'rm -rf "$work"' EXIT +mkdir "$work/bin" +cat > "$work/bin/cargo" <<'MOCK' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$ECHO_AUDIT_CAPTURE" +if [[ "${ECHO_AUDIT_FAIL_EXPERIMENT:-0}" == 1 && "$*" == *experiments/echo-keep/Cargo.lock* ]]; then exit 23; fi +exit 0 +MOCK +chmod +x "$work/bin/cargo" +PATH="$work/bin:$PATH" ECHO_AUDIT_CAPTURE="$work/calls" bash "$root/scripts/run_cargo_audit.sh" +if ! grep -q -- "--file $root/experiments/echo-keep/Cargo.lock" "$work/calls"; then + echo "FAIL: experimental lockfile was not audited" >&2 + exit 1 +fi +grep -q -- "--file $root/Cargo.lock" "$work/calls" +if PATH="$work/bin:$PATH" ECHO_AUDIT_CAPTURE="$work/failing-calls" ECHO_AUDIT_FAIL_EXPERIMENT=1 bash "$root/scripts/run_cargo_audit.sh"; then + echo 'FAIL: experimental audit failure was accepted' >&2 + exit 1 +fi +printf '%s\n' 'PASS: both lockfiles are audited and an experimental audit failure blocks' diff --git a/scripts/tests/keep_deny_policy_test.py b/scripts/tests/keep_deny_policy_test.py new file mode 100644 index 00000000..a79259af --- /dev/null +++ b/scripts/tests/keep_deny_policy_test.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# © James Ross Ω FLYING•ROBOTS +"""Check the real generated policy, unknown-source refusal, and CI aperture.""" +import importlib.util +from pathlib import Path +import tomllib + +root = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location("policy", root / "scripts/keep_deny_policy.py") +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +policy_text = (root / "deny.toml").read_text() +manifest_text = (root / "experiments/echo-keep/Cargo.toml").read_text() +derived = tomllib.loads(module.derive_policy(policy_text, manifest_text)) +original = tomllib.loads(policy_text) +assert derived["sources"].pop("allow-git") == [module.KEEP_SOURCE] +assert derived == original +for invalid in [manifest_text.replace(module.KEEP_SOURCE, "https://example.com/unknown"), manifest_text.replace("3165890e9291cfb5fe10e81a9d7cd151f3e59464", "main")]: + try: + module.derive_policy(policy_text, invalid) + except ValueError: + pass + else: + raise AssertionError("unreviewed source or unpinned revision was admitted") +workflow = (root / ".github/workflows/echo-keep-experimental.yml").read_text() +assert "manifest-path: experiments/echo-keep/Cargo.toml" in workflow +assert "--config /github/workspace/.echo-keep-deny.toml" in workflow +print("PASS: complete root policy retained, only pinned Keep source admitted, isolated CI graph checked") diff --git a/scripts/tests/keep_dependency_boundary_test.sh b/scripts/tests/keep_dependency_boundary_test.sh new file mode 100644 index 00000000..49b34ca1 --- /dev/null +++ b/scripts/tests/keep_dependency_boundary_test.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# © James Ross Ω FLYING•ROBOTS +set -euo pipefail +root=$(git rev-parse --show-toplevel) +work=$(mktemp -d "${TMPDIR:-/tmp}/echo-dependency-step.XXXXXX") +trap 'rm -rf "$work"' EXIT +mkdir "$work/bin" +cat > "$work/bin/cargo" <<'MOCK' +#!/usr/bin/env bash +if [[ "$1" == tree ]]; then exit 23; fi +exit 0 +MOCK +cat > "$work/bin/rustup" <<'MOCK' +#!/usr/bin/env bash +exit 0 +MOCK +chmod +x "$work/bin/cargo" "$work/bin/rustup" +body=$(awk ' + /name: Preserve the default CAS toolchain and dependency boundary/ { step = 1; next } + step && /^[[:space:]]*run: \|/ { command = 1; next } + command { sub(/^ /, ""); print } +' "$root/.github/workflows/echo-keep-experimental.yml") +test -n "$body" +if PATH="$work/bin:$PATH" bash -e -c "$body"; then + echo 'FAIL: cargo tree failure was accepted' >&2 + exit 1 +fi +printf '%s\n' 'PASS: failing dependency-tree command blocks the exact workflow step'