From a3635245e9d4f80ca44a5569ab2b4a51e1a3ec23 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Fri, 2 Oct 2026 19:32:57 +1000 Subject: [PATCH 01/16] feat(run): ck run on the file backend (LAB-5118) ck run wraps a command and caches its stdout when it exits 0, on the cachekit-rs file backend with client-side encryption. - Reader-side freshness from a stored_at timestamp, so each call applies its own --ttl and --stale. - A keyed BLAKE2b-256 cache key over the length-prefixed scope and argv. - A failure marker with escalating backoff and a 15-minute active window. - A per-key flock fill lock, so concurrent cold calls run the command once. - An auto-created file key, published with link() so racing first runs agree. - Only /dev/null stdin is cached; CACHEKIT_* is stripped from the child. - SIGTERM and SIGHUP are forwarded; output past 20 MiB streams uncached. The binary is named ck. CI gains an MSRV 1.85 build and the hit-path budget benchmark, cargo-deny bans a direct HTTP client, and the licence allow list carries exactly the permissive licences in the graph. --- .github/workflows/ci.yml | 22 +- Cargo.lock | 1405 ++++++++++++++++++++++++++++++++++++++ Cargo.toml | 22 + README.md | 128 +++- deny.toml | 11 +- src/child.rs | 212 ++++++ src/cli.rs | 214 ++++++ src/entry.rs | 209 ++++++ src/keys.rs | 229 +++++++ src/lib.rs | 64 ++ src/main.rs | 2 +- src/run.rs | 344 ++++++++++ src/store.rs | 99 +++ tests/bench.rs | 84 +++ tests/cli.rs | 14 - tests/common/mod.rs | 147 ++++ tests/readme.rs | 71 ++ tests/run.rs | 494 ++++++++++++++ 18 files changed, 3752 insertions(+), 19 deletions(-) create mode 100644 src/child.rs create mode 100644 src/cli.rs create mode 100644 src/entry.rs create mode 100644 src/keys.rs create mode 100644 src/lib.rs create mode 100644 src/run.rs create mode 100644 src/store.rs create mode 100644 tests/bench.rs delete mode 100644 tests/cli.rs create mode 100644 tests/common/mod.rs create mode 100644 tests/readme.rs create mode 100644 tests/run.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fc82a7..6199abf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,7 +83,7 @@ jobs: run: | # grep exits 0 on a match, 1 on none, and 2 on error (a missing binary included). rc=0 - grep -aoiE "$ADMIN_API_PATTERN" target/release/cachekit-cli || rc=$? + grep -aoiE "$ADMIN_API_PATTERN" target/release/ck || rc=$? if [ "$rc" -eq 0 ]; then echo "::error::admin API reference found in the release binary" exit 1 @@ -91,3 +91,23 @@ jobs: echo "::error::grep failed with exit code $rc" exit "$rc" fi + + - name: Hit-path budget + # ck run -- true may add at most 5 ms over true, against a cache + # directory of about 10,000 entries. + run: cargo test --locked --release --test bench -- --ignored --nocapture + + msrv: + name: msrv (1.85) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install the rust-version toolchain + run: rustup toolchain install 1.85 --profile minimal + + - name: Build on rust-version + run: cargo +1.85 check --locked --all-targets diff --git a/Cargo.lock b/Cargo.lock index f1343ed..6993bad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,1411 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", + "zeroize", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", + "zeroize", +] + +[[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 = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[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 = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cachekit-cli" version = "0.0.0" +dependencies = [ + "blake2", + "cachekit-core", + "cachekit-rs", + "getrandom 0.2.17", + "hex", + "rustix", + "signal-hook", + "tempfile", + "tokio", +] + +[[package]] +name = "cachekit-core" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93adc5646956ba8da140f4179a02e60a2cc7a401b83ebb1270cc4d03748e1fac" +dependencies = [ + "aes", + "aes-gcm", + "byteorder", + "bytes", + "cbindgen", + "generic-array", + "getrandom 0.2.17", + "hkdf", + "hmac", + "lz4_flex", + "ring", + "rmp-serde", + "serde", + "serde_bytes", + "sha2", + "thiserror", + "xxhash-rust", + "zeroize", +] + +[[package]] +name = "cachekit-rs" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986848f4b095fae7897d045305a2ccca9a62c4f5a33e50cfc9c7a100d3b3d941" +dependencies = [ + "async-trait", + "blake2", + "bytes", + "cachekit-core", + "hex", + "js-sys", + "libc", + "rmp-serde", + "rustix", + "serde", + "thiserror", + "tokio", + "url", + "urlencoding", + "uuid", + "zeroize", +] + +[[package]] +name = "cbindgen" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20" +dependencies = [ + "clap", + "heck", + "indexmap", + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 2.0.119", + "tempfile", + "toml", +] + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +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 = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[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 = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[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.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[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 = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[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 = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lz4_flex" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90071f8077f8e40adfc4b7fe9cd495ce316263f19e75c2211eeff3fdf475a3d9" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[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 = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[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 = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[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", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[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 = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[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 = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[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 = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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.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 3.0.6", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "twox-hash" +version = "2.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a" + +[[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.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[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 = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[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 = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[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", +] + +[[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", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "xxhash-rust" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "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.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index a6c558c..076085a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,28 @@ edition = "2021" description = "Command-line client for CacheKit" license = "MIT" repository = "https://github.com/cachekit-io/cachekit-cli" +rust-version = "1.85" +# Resolve dependency versions that build on rust-version, so the lockfile +# keeps the MSRV promise that CI checks. +resolver = "3" + +[[bin]] +name = "ck" +path = "src/main.rs" [lints.rust] unsafe_code = "forbid" + +[dependencies] +blake2 = "0.10" +cachekit-core = { version = "0.6", default-features = false, features = ["encryption"] } +cachekit-rs = { version = "0.8", default-features = false, features = ["encryption", "file"] } +getrandom = "0.2" +hex = "0.4" +rustix = { version = "1", default-features = false, features = ["std", "fs", "process"] } +signal-hook = "0.4" +tokio = { version = "1", default-features = false, features = ["rt"] } + +[dev-dependencies] +rustix = { version = "1", features = ["pty"] } +tempfile = "3" diff --git a/README.md b/README.md index 2f907eb..33bd93c 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,132 @@ # cachekit-cli -The command-line client for [CacheKit](https://cachekit.io). +`ck`, the command-line client for [CacheKit](https://cachekit.io). -Pre-release: nothing is published yet. +Pre-release: nothing is published yet. To try it, build from a checkout with +`cargo build --release` and put `target/release/ck` on your `PATH`. + +## `ck run` + +`ck run` wraps a command and caches its stdout, so a slow or rate-limited call +is not repeated. A typical use is a secret read in a shell startup file, where +every new shell would otherwise call the secret manager again: + +```sh +export API_TOKEN="$(ck run --ttl 12h --stale 7d -- op read op://Private/api/token < /dev/null)" +``` + +The first call runs `op` and stores its output. Calls in the next 12 hours +print the stored output without running `op`. After that, ck runs `op` again; +if `op` fails, ck serves the stored output for up to 7 more days. + +```text +ck run [--backend file] [--ttl D] [--stale D] [--scope S] [--refresh] -- [args...] +``` + +| Flag | Default | Meaning | +|---|---|---| +| `--ttl D` | `60s` | Serve the stored output as fresh for `D`. At least `1s`. | +| `--stale D` | `0s` | After that, serve it for `D` more when the command fails. | +| `--scope S` | empty | A string folded into the cache key (see below). | +| `--refresh` | off | Run the command now and store the result, ignoring what is stored. | +| `--backend file` | `file` | Entries live in `~/.cache/ck`. It is the only backend in this release. | + +A duration `D` is a whole number followed by one unit: `s`, `m`, `h` or `d`, +such as `90s`, `15m`, `12h` or `7d`. `--ttl` plus `--stale` may not exceed +`30d`. ck's own errors, such as a bad flag, exit 125 before anything runs. + +### Only exit 0 is cached + +ck stores a run only when the command exits 0. A run that fails is never +stored and never served. Commands that use a non-zero code for a normal +result, like `grep` (1 for no match) or `diff` (1 for a difference), are +therefore not cacheable. + +When a run fails, ck backs off: it records the failure and does not run the +command again for 30 seconds, doubling after each consecutive failure up to +15 minutes. During the backoff a call serves the stored output if `--stale` +allows it (exit 0, with one line on stderr giving its age), and otherwise +prints nothing and exits with the failed run's code. `--refresh` runs the +command whatever the backoff says: + +```sh +ck run --refresh -- date -u < /dev/null +``` + +Concurrent calls for the same command on one machine run it once: the first +takes a lock and the rest wait for its result, or serve stale output if they +have it. Output larger than 20 MiB is passed through and not cached. + +### stdin must be `/dev/null` + +The cache key covers the command line and `--scope`, not what arrives on +stdin. So ck caches a call only when stdin is `/dev/null` (or closed). With a +terminal, a pipe, a file or a socket on stdin, ck runs the command uncached and +prints one warning. That is why every example here ends in `< /dev/null`. +Programs that prompt by opening the terminal themselves, as `op`, `ssh` and +`sudo` do, still reach you with stdin redirected. + +### What the key does not cover + +The key is the command and its arguments, plus `--scope`. It does not cover +the environment, the working directory, the contents of files the command +reads, or the network. Redirecting stdin to `/dev/null` is your assertion that +none of those change the output. When one does, put it in `--scope`: + +```sh +ck run --ttl 1h --scope "$PWD" -- ls < /dev/null +``` + +### Wrap the command itself + +Run the command directly rather than through `sh -c`: + +```sh +ck run --ttl 5m -- uname -a < /dev/null +``` + +When ck cannot start the command at all (it is not installed, or not +executable), it exits 127 or 126 and records no failure, so the next call +tries again at once. Through `sh -c`, a missing command is just the shell +exiting 127, which ck must treat as an ordinary failure and back off. + +### How long a cached secret lives + +Stored output can be served for up to `--ttl` plus `--stale` after it was +produced. ck gets no signal when a secret is rotated at its source, so if you +cache a secret, set `--stale` to how long you can tolerate serving a revoked +one. The default `--stale 0s` keeps the bound at the TTL. + +To stop serving old output: + +- lower `--stale` or `--ttl`, which takes effect on the next call; +- delete `~/.cache/ck` to remove every entry; +- delete `~/.config/ck/file.key`, or change `CACHEKIT_MASTER_KEY` if you set + one, to make every stored entry unreadable. + +### Encryption and the file key + +Entries are encrypted with AES-256-GCM, and their file names are keyed hashes, +so neither the output nor the command line appears on disk. The key comes from +`CACHEKIT_MASTER_KEY` (64 or more hex characters) if it is set. Otherwise ck +creates a random key in `~/.config/ck/file.key` on first use, mode 0600. If +that file is damaged, ck exits 125 and tells you to delete it; it never +overwrites it. + +ck removes every `CACHEKIT_*` variable from the command's environment, so a +log or environment dump from the command does not carry your keys. + +### Signals + +ck forwards SIGTERM and SIGHUP to the command. On those and on SIGINT or +SIGQUIT it waits for the command, stores nothing and exits 128 plus the +signal number. + +### Platforms + +Linux and macOS. The cache directory must be on a local filesystem: on NFS or +SMB, concurrent first calls may each run the command, though entries are never +torn. ## License diff --git a/deny.toml b/deny.toml index 4e811e8..b55ff50 100644 --- a/deny.toml +++ b/deny.toml @@ -2,10 +2,19 @@ # cargo-deny cannot make: committed Cargo configs and versioned path deps. [licenses] -allow = ["MIT"] +# Permissive licences only, each one carried by the real dependency graph. +allow = ["MIT", "Apache-2.0", "BSD-3-Clause", "BSL-1.0", "ISC", "Unicode-3.0"] +# cbindgen is an unconditional build dependency of cachekit-core that its +# build script uses only under the `ffi` feature, which ck does not enable. +# It runs at build time and none of its code is linked into ck. +exceptions = [{ crate = "cbindgen", allow = ["MPL-2.0"] }] [bans] wildcards = "deny" +# ck has no HTTP client of its own: every network call goes through +# cachekit-rs, whose request paths are fixed. A direct HTTP client +# dependency fails here. +deny = [{ crate = "reqwest", wrappers = ["cachekit-rs"] }] [sources] unknown-registry = "deny" diff --git a/src/child.rs b/src/child.rs new file mode 100644 index 0000000..a935979 --- /dev/null +++ b/src/child.rs @@ -0,0 +1,212 @@ +//! Running the wrapped command: environment, signals and stdout capture. + +use std::ffi::OsString; +use std::io::{ErrorKind, Read}; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::process::ExitStatusExt; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicI32, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; + +use rustix::process::{Pid, Signal, WaitId, WaitIdOptions}; +use signal_hook::consts::{SIGHUP, SIGINT, SIGQUIT, SIGTERM}; +use signal_hook::iterator::Signals; + +use crate::{emit, warn, Fatal}; + +/// Output past this is streamed, not cached. The SaaS rejects values over +/// 25 MB; the margin covers the envelope and encryption overhead. +pub const OUTPUT_CAP: usize = 20 * 1024 * 1024; + +pub enum Ran { + /// ck could not start the command, so the origin was never reached. + SpawnFailed { + code: i32, + }, + Exited { + code: i32, + stdout: Stdout, + }, +} + +pub enum Stdout { + /// Held back: the caller decides whether to print it or replay a stored run. + Captured(Vec), + /// Already written to ck's stdout. + Written, +} + +enum State { + /// No child yet. A signal now ends ck at once: nothing has been written. + Waiting, + Running(Pid), + /// The child has exited. Its pid must not be signalled again. + Exited, +} + +/// Owns ck's handling of SIGTERM, SIGHUP, SIGINT and SIGQUIT from the point +/// ck may block (on the fill lock) or run the command. +/// +/// SIGTERM and SIGHUP are forwarded to the child. SIGINT and SIGQUIT are not: +/// the child shares ck's foreground process group and has already had them +/// from the terminal, and some programs treat a second interrupt as a hard +/// abort. On any of them ck waits for the child, stores nothing, and exits +/// 128 + n. +pub struct Supervisor { + state: Arc>, + received: Arc, +} + +impl Supervisor { + pub fn install() -> Result { + let mut signals = Signals::new([SIGTERM, SIGHUP, SIGINT, SIGQUIT]) + .map_err(|e| Fatal(format!("cannot install signal handlers: {e}")))?; + let state = Arc::new(Mutex::new(State::Waiting)); + let received = Arc::new(AtomicI32::new(0)); + let (thread_state, thread_received) = (Arc::clone(&state), Arc::clone(&received)); + std::thread::Builder::new() + .name("signals".into()) + .spawn(move || { + for sig in signals.forever() { + let _ = thread_received.compare_exchange( + 0, + sig, + Ordering::SeqCst, + Ordering::SeqCst, + ); + let state = thread_state.lock().unwrap_or_else(PoisonError::into_inner); + match *state { + State::Waiting => std::process::exit(128 + sig), + State::Running(pid) => forward(pid, sig), + State::Exited => {} + } + } + }) + .map_err(|e| Fatal(format!("cannot start the signal thread: {e}")))?; + Ok(Self { state, received }) + } + + /// The first terminating signal ck received, if any. + pub fn received(&self) -> Option { + Some(self.received.load(Ordering::SeqCst)).filter(|&s| s != 0) + } + + /// Run `argv` with every `CACHEKIT_*` variable removed from its + /// environment. stdin and stderr pass straight through. stdout is + /// captured up to [`OUTPUT_CAP`] when `capture` is set, else inherited. + pub fn run(&self, argv: &[OsString], capture: bool) -> Ran { + let mut command = Command::new(&argv[0]); + command.args(&argv[1..]); + // Hygiene against accidents, not a boundary: a build log or `env` + // dump from the command should not carry ck's keys. + for (name, _) in std::env::vars_os() { + if name.as_bytes().starts_with(b"CACHEKIT_") { + command.env_remove(name); + } + } + if capture { + command.stdout(Stdio::piped()); + } + + let mut child = { + // Held across the spawn so a signal cannot fall between "no + // child yet" and "child running". + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + match command.spawn() { + Ok(child) => { + *state = State::Running(pid_of(child.id())); + child + } + Err(e) => { + let name = argv[0].to_string_lossy(); + return Ran::SpawnFailed { + code: if e.kind() == ErrorKind::NotFound { + warn(&format!("{name}: command not found")); + 127 + } else { + warn(&format!("{name}: cannot run it: {e}")); + 126 + }, + }; + } + } + }; + + let stdout = match child.stdout.take() { + Some(pipe) => collect(pipe), + None => Stdout::Written, + }; + + // Wait without reaping, then mark the child exited, then reap. A + // signal forwarded in between reaches a zombie, never a recycled pid. + let pid = pid_of(child.id()); + let exited = WaitIdOptions::EXITED | WaitIdOptions::NOWAIT; + while let Err(rustix::io::Errno::INTR) = rustix::process::waitid(WaitId::Pid(pid), exited) { + } + *self.state.lock().unwrap_or_else(PoisonError::into_inner) = State::Exited; + let code = match child.wait() { + Ok(status) => status + .code() + .unwrap_or_else(|| 128 + status.signal().unwrap_or(0)), + Err(e) => { + warn(&format!("cannot collect the command's exit status: {e}")); + 126 + } + }; + Ran::Exited { code, stdout } + } +} + +/// Read the child's stdout into memory, or, once it passes the cap, write +/// out what is held and stream the rest. +fn collect(mut pipe: impl Read) -> Stdout { + let mut buffer = Vec::new(); + let mut chunk = vec![0u8; 64 * 1024]; + loop { + let n = match pipe.read(&mut chunk) { + Ok(0) => return Stdout::Captured(buffer), + Ok(n) => n, + Err(e) if e.kind() == ErrorKind::Interrupted => continue, + Err(e) => { + warn(&format!("cannot read the command's output: {e}")); + emit(&buffer); + return Stdout::Written; + } + }; + if buffer.len() + n > OUTPUT_CAP { + warn("the command's output passed 20 MiB, so this run is not cached"); + emit(&buffer); + emit(&chunk[..n]); + stream(pipe, &mut chunk); + return Stdout::Written; + } + buffer.extend_from_slice(&chunk[..n]); + } +} + +fn stream(mut pipe: impl Read, chunk: &mut [u8]) { + loop { + match pipe.read(chunk) { + Ok(0) => return, + Ok(n) => emit(&chunk[..n]), + Err(e) if e.kind() == ErrorKind::Interrupted => {} + Err(e) => return warn(&format!("cannot read the command's output: {e}")), + } + } +} + +fn forward(pid: Pid, sig: i32) { + let signal = match sig { + SIGTERM => Signal::TERM, + SIGHUP => Signal::HUP, + _ => return, + }; + let _ = rustix::process::kill_process(pid, signal); +} + +fn pid_of(id: u32) -> Pid { + i32::try_from(id) + .ok() + .and_then(Pid::from_raw) + .unwrap_or_else(|| unreachable!("a spawned child has a positive pid")) +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..04a84a5 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,214 @@ +//! Argument parsing for `ck run`. +//! +//! Hand-rolled rather than clap: one subcommand with five flags does not +//! justify the start-up cost and dependency tree of a parser framework on a +//! path that has to stay inside a 5 ms budget. + +use std::ffi::OsString; + +/// `ttl + stale` may not exceed this on any backend. It is the SaaS storage +/// cap, applied everywhere so a command that works on file also works on saas. +pub const MAX_RETENTION_SECS: u64 = 30 * 86_400; + +pub const USAGE: &str = "\ +usage: ck run [--backend file] [--ttl D] [--stale D] [--scope S] [--refresh] -- [args...] + +Runs and caches its stdout when it exits 0. Only a call whose stdin +is /dev/null is cached, so end every cached call with `< /dev/null`. + + --backend file where entries live (default: file, in ~/.cache/ck) + --ttl D serve the stored output as fresh for D (default: 60s) + --stale D after that, serve it for D more if the command fails (default: 0s) + --scope S an extra string folded into the cache key + --refresh run the command now, ignoring any stored output + +D is a whole number followed by s, m, h or d, such as 90s, 15m, 12h or 7d."; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BackendKind { + File, +} + +#[derive(Debug, PartialEq, Eq)] +pub struct RunArgs { + pub backend: BackendKind, + pub ttl_secs: u64, + pub stale_secs: u64, + pub scope: OsString, + pub refresh: bool, + pub command: Vec, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Invocation { + Run(RunArgs), + Help, + Version, +} + +/// Parse everything after the program name. An `Err` is a usage error: the +/// caller prints it and exits 125 without running anything. +pub fn parse(args: impl IntoIterator) -> Result { + let mut args = args.into_iter(); + let Some(sub) = args.next() else { + return Err("missing subcommand".into()); + }; + match sub.to_str() { + Some("run") => parse_run(args), + Some("-h" | "--help" | "help") => Ok(Invocation::Help), + Some("-V" | "--version") => Ok(Invocation::Version), + _ => Err(format!("unknown subcommand {:?}", sub)), + } +} + +fn parse_run(mut args: impl Iterator) -> Result { + let mut backend = BackendKind::File; + let mut ttl_secs = 60; + let mut stale_secs = 0; + let mut scope = OsString::new(); + let mut refresh = false; + + let mut command = Vec::new(); + while let Some(arg) = args.next() { + // Flags end at `--` or at the first argument that is not a flag, so + // the wrapped command's own flags are never read as ck's. + let Some(text) = arg.to_str().filter(|s| s.starts_with('-')) else { + command.push(arg); + break; + }; + if text == "--" { + break; + } + let (name, inline) = match text.split_once('=') { + Some((n, v)) => (n, Some(OsString::from(v))), + None => (text, None), + }; + let mut value = || -> Result { + inline + .clone() + .or_else(|| args.next()) + .ok_or_else(|| format!("{name} needs a value")) + }; + match name { + "--backend" => { + backend = match value()?.to_str() { + Some("file") => BackendKind::File, + Some("saas") => { + return Err("--backend saas is not available in this release".into()) + } + _ => return Err("--backend must be file".into()), + } + } + "--ttl" => ttl_secs = duration(&value()?, name)?, + "--stale" => stale_secs = duration(&value()?, name)?, + "--scope" => scope = value()?, + "--refresh" if inline.is_none() => refresh = true, + "--refresh" => return Err("--refresh takes no value".into()), + "-h" | "--help" => return Ok(Invocation::Help), + _ => return Err(format!("unknown flag {name}")), + } + } + command.extend(args); + + if command.is_empty() { + return Err("missing the command to run".into()); + } + if ttl_secs < 1 { + return Err("--ttl must be at least 1s".into()); + } + if ttl_secs.saturating_add(stale_secs) > MAX_RETENTION_SECS { + return Err("--ttl plus --stale may not exceed 30d".into()); + } + Ok(Invocation::Run(RunArgs { + backend, + ttl_secs, + stale_secs, + scope, + refresh, + command, + })) +} + +/// A non-negative integer followed by exactly one unit: `s`, `m`, `h` or `d`. +fn duration(raw: &OsString, flag: &str) -> Result { + let bad = || format!("{flag}: {raw:?} is not a duration such as 90s, 15m, 12h or 7d"); + let text = raw.to_str().ok_or_else(bad)?; + let split = text.len().checked_sub(1).ok_or_else(bad)?; + let (digits, unit) = text.split_at(split); + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return Err(bad()); + } + let scale = match unit { + "s" => 1, + "m" => 60, + "h" => 3_600, + "d" => 86_400, + _ => return Err(bad()), + }; + digits + .parse::() + .ok() + .and_then(|n| n.checked_mul(scale)) + .ok_or_else(bad) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run(args: &[&str]) -> Result { + let all = std::iter::once("run").chain(args.iter().copied()); + match parse(all.map(OsString::from))? { + Invocation::Run(r) => Ok(r), + other => panic!("expected run, got {other:?}"), + } + } + + #[test] + fn defaults() { + let r = run(&["--", "op", "read", "x"]).unwrap(); + assert_eq!((r.ttl_secs, r.stale_secs, r.refresh), (60, 0, false)); + assert_eq!(r.command, ["op", "read", "x"]); + assert!(r.scope.is_empty()); + } + + #[test] + fn flags_end_at_the_first_non_flag() { + let r = run(&["--ttl=12h", "--stale", "7d", "op", "--stale", "1s"]).unwrap(); + assert_eq!((r.ttl_secs, r.stale_secs), (43_200, 604_800)); + assert_eq!(r.command, ["op", "--stale", "1s"]); + } + + #[test] + fn duration_grammar() { + for ok in ["0s", "90s", "15m", "12h", "7d"] { + assert!(duration(&ok.into(), "--stale").is_ok(), "{ok}"); + } + for bad in [ + "", + "s", + "1", + "1.5h", + "-1s", + "+1s", + "1h30m", + "1S", + " 1s", + "1w", + "99999999999999999999d", + ] { + assert!(duration(&bad.into(), "--stale").is_err(), "{bad}"); + } + } + + #[test] + fn limits() { + assert!(run(&["--ttl", "0s", "--", "x"]).is_err()); + assert!(run(&["--ttl", "29d", "--stale", "1d", "--", "x"]).is_ok()); + assert!(run(&["--ttl", "29d", "--stale", "1d", "--stale", "86401s", "--", "x"]).is_err()); + assert!(run(&["--"]).is_err()); + assert!(run(&["--bogus", "--", "x"]).is_err()); + assert!(run(&["--backend", "saas", "--", "x"]).is_err()); + assert!(run(&["--refresh=yes", "--", "x"]).is_err()); + } +} diff --git a/src/entry.rs b/src/entry.rs new file mode 100644 index 0000000..8819725 --- /dev/null +++ b/src/entry.rs @@ -0,0 +1,209 @@ +//! What ck stores: the value envelope and the failure marker, and the rules +//! that judge them. Both are encrypted before they reach a backend. +//! +//! The formats are fixed-width big-endian fields. They carry no version byte: +//! the `v1` in the cache key is the version, and it is bound into the AAD. + +use std::time::Duration; + +/// The longest backoff step, and how far ahead a marker's `retry_at` may sit +/// before the marker is ignored as misdated. +pub const MAX_BACKOFF_MS: u64 = 15 * 60 * 1000; +const FIRST_BACKOFF_MS: u64 = 30 * 1000; + +pub fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)) + .unwrap_or(0) +} + +/// `{stored_at, stdout}` of one exit-0 run: `[stored_at ms: u64][stdout]`. +pub struct Envelope { + pub stored_at_ms: u64, + pub stdout: Vec, +} + +impl Envelope { + pub fn encode(stored_at_ms: u64, stdout: &[u8]) -> Vec { + let mut out = Vec::with_capacity(8 + stdout.len()); + out.extend_from_slice(&stored_at_ms.to_be_bytes()); + out.extend_from_slice(stdout); + out + } + + pub fn decode(mut bytes: Vec) -> Option { + let stored_at_ms = u64::from_be_bytes(bytes.get(..8)?.try_into().ok()?); + bytes.drain(..8); + Some(Self { + stored_at_ms, + stdout: bytes, + }) + } + + /// Judged on this reader's clock with this reader's flags, so lowering + /// `--stale` takes effect on the next call whatever the writer used. + pub fn freshness(&self, now_ms: u64, ttl_secs: u64, stale_secs: u64) -> Freshness { + let age_ms = now_ms.saturating_sub(self.stored_at_ms); + let ttl_ms = ttl_secs.saturating_mul(1000); + let stale_ms = stale_secs.saturating_mul(1000); + if age_ms < ttl_ms { + Freshness::Fresh + } else if age_ms < ttl_ms.saturating_add(stale_ms) { + Freshness::Stale { age_ms } + } else { + Freshness::Expired + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Freshness { + Fresh, + Stale { age_ms: u64 }, + Expired, +} + +/// `{exit, failures, retry_at}` of the latest failing run: +/// `[exit: i32][failures: u32][retry_at ms: u64]`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Marker { + pub exit: i32, + pub failures: u32, + pub retry_at_ms: u64, +} + +impl Marker { + pub fn encode(&self) -> Vec { + let mut out = Vec::with_capacity(16); + out.extend_from_slice(&self.exit.to_be_bytes()); + out.extend_from_slice(&self.failures.to_be_bytes()); + out.extend_from_slice(&self.retry_at_ms.to_be_bytes()); + out + } + + pub fn decode(bytes: &[u8]) -> Option { + if bytes.len() != 16 { + return None; + } + Some(Self { + exit: i32::from_be_bytes(bytes[0..4].try_into().ok()?), + failures: u32::from_be_bytes(bytes[4..8].try_into().ok()?), + retry_at_ms: u64::from_be_bytes(bytes[8..16].try_into().ok()?), + }) + } + + /// The marker after one more consecutive failure. The count survives + /// between calls, so the backoff escalates: 30 s, 1 m, 2 m, ... 15 m. + pub fn bumped(previous: Option<&Self>, exit: i32, now_ms: u64) -> Self { + let failures = previous.map_or(1, |m| m.failures.saturating_add(1)); + let doublings = (failures - 1).min(16); + let step = FIRST_BACKOFF_MS + .saturating_mul(1 << doublings) + .min(MAX_BACKOFF_MS); + Self { + exit, + failures, + retry_at_ms: now_ms.saturating_add(step), + } + } + + /// Active only while `now < retry_at <= now + 15 min` on this reader's + /// clock. A marker dated further ahead (a fast writer clock, or a key + /// holder writing a far-future marker) is ignored, so no reader is ever + /// suppressed for longer than the cap. + pub fn is_active(&self, now_ms: u64) -> bool { + now_ms < self.retry_at_ms && self.retry_at_ms <= now_ms.saturating_add(MAX_BACKOFF_MS) + } + + /// Long enough that the failure count survives until the next call. + pub fn backend_ttl(&self, now_ms: u64) -> Duration { + Duration::from_millis( + self.retry_at_ms + .saturating_sub(now_ms) + .saturating_add(MAX_BACKOFF_MS), + ) + } +} + +/// `4m30s`-style rendering for stderr lines. +pub fn human(ms: u64) -> String { + let secs = ms / 1000; + let (d, h, m, s) = (secs / 86_400, secs / 3_600 % 24, secs / 60 % 60, secs % 60); + match (d, h, m) { + (0, 0, 0) => format!("{s}s"), + (0, 0, _) if s == 0 => format!("{m}m"), + (0, 0, _) => format!("{m}m{s}s"), + (0, _, _) => format!("{h}h{m}m"), + _ => format!("{d}d{h}h"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn envelope_round_trip_and_freshness() { + let env = Envelope::decode(Envelope::encode(10_000, b"out")).unwrap(); + assert_eq!( + (env.stored_at_ms, env.stdout.as_slice()), + (10_000, &b"out"[..]) + ); + assert_eq!(env.freshness(10_999, 1, 0), Freshness::Fresh); + assert_eq!(env.freshness(11_000, 1, 0), Freshness::Expired); + assert_eq!( + env.freshness(11_000, 1, 5), + Freshness::Stale { age_ms: 1_000 } + ); + assert_eq!(env.freshness(16_000, 1, 5), Freshness::Expired); + // A writer clock ahead of the reader's reads as age 0, not negative. + assert_eq!(env.freshness(5_000, 1, 0), Freshness::Fresh); + assert!(Envelope::decode(vec![0; 7]).is_none()); + } + + #[test] + fn backoff_steps() { + let mut m = Marker::bumped(None, 1, 0); + let mut steps = vec![m.retry_at_ms]; + for _ in 0..7 { + m = Marker::bumped(Some(&m), 1, 0); + steps.push(m.retry_at_ms); + } + let s = |n: u64| n * 1000; + assert_eq!( + steps, + [s(30), s(60), s(120), s(240), s(480), s(900), s(900), s(900)] + ); + assert_eq!(m.failures, 8); + let far = Marker { + failures: u32::MAX, + ..m + }; + assert_eq!(Marker::bumped(Some(&far), 1, 0).retry_at_ms, s(900)); + } + + #[test] + fn active_window() { + let m = Marker { + exit: 1, + failures: 1, + retry_at_ms: 1_000_000, + }; + assert!(m.is_active(999_999)); + assert!(!m.is_active(1_000_000)); + assert!(m.is_active(1_000_000 - MAX_BACKOFF_MS)); + assert!(!m.is_active(1_000_000 - MAX_BACKOFF_MS - 1)); + assert_eq!(Marker::decode(&m.encode()), Some(m)); + assert!(Marker::decode(&[0; 15]).is_none()); + } + + #[test] + fn human_durations() { + assert_eq!(human(5_400), "5s"); + assert_eq!(human(240_000), "4m"); + assert_eq!(human(270_000), "4m30s"); + assert_eq!(human(7_380_000), "2h3m"); + assert_eq!(human(90_000_000), "1d1h"); + } +} diff --git a/src/keys.rs b/src/keys.rs new file mode 100644 index 0000000..75e7772 --- /dev/null +++ b/src/keys.rs @@ -0,0 +1,229 @@ +//! The master key, the auto-created file key, and the cache key. + +use std::ffi::OsString; +use std::fs; +use std::io::{ErrorKind, Write}; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt}; +use std::path::Path; + +use blake2::digest::consts::U32; +use blake2::digest::Mac; +use blake2::Blake2bMac; + +use crate::Fatal; + +/// The encryption tenant and the HKDF salt context. A fixed constant: deriving +/// it from an API key would orphan every entry when the key rotates. +pub const TENANT: &str = "ck-run"; + +/// Every value key starts with this. `v1` is the one version authority for +/// the envelope and marker formats, and it is bound into the AAD. +const KEY_PREFIX: &str = "ns:ck-run:v1:"; + +pub const MASTER_KEY_ENV: &str = "CACHEKIT_MASTER_KEY"; + +/// `CACHEKIT_MASTER_KEY` when set, otherwise the file key in `config_dir`, +/// created on first use. +pub fn master_key(config_dir: &Path) -> Result, Fatal> { + if let Some(hex_key) = std::env::var_os(MASTER_KEY_ENV) { + return decode_master_key(&hex_key); + } + file_key(config_dir) +} + +fn decode_master_key(hex_key: &OsString) -> Result, Fatal> { + let bad = || { + Fatal(format!( + "{MASTER_KEY_ENV} must be at least 32 bytes, hex-encoded (generate one with `openssl rand -hex 32`)" + )) + }; + let bytes = hex::decode(hex_key.as_bytes()).map_err(|_| bad())?; + if bytes.len() < 32 { + return Err(bad()); + } + Ok(bytes) +} + +/// Read `file.key`, creating it first if it does not exist. +/// +/// Creation is publish-by-`link()`: the key is written and synced under a +/// unique temp name, then hard-linked to its final name. A racing first run +/// either wins the link or reads the winner's complete file; it can never +/// read a half-written one, and a crash leaves either no key or a whole key. +pub fn file_key(config_dir: &Path) -> Result, Fatal> { + let path = config_dir.join("file.key"); + match fs::symlink_metadata(&path) { + Err(e) if e.kind() == ErrorKind::NotFound => create_file_key(config_dir, &path)?, + Err(e) => return Err(Fatal(format!("cannot read {}: {e}", path.display()))), + Ok(_) => {} + } + let unusable = |why: &str| { + Fatal(format!( + "{} {why}. Delete it and ck creates a new one; every entry cached under the old key becomes a miss", + path.display() + )) + }; + let meta = fs::symlink_metadata(&path).map_err(|e| unusable(&e.to_string()))?; + if !meta.is_file() { + return Err(unusable("is not a regular file")); + } + if meta.uid() != rustix::process::geteuid().as_raw() { + return Err(unusable("is not owned by you")); + } + if meta.mode() & 0o777 != 0o600 { + return Err(unusable("must have mode 0600")); + } + let text = fs::read(&path).map_err(|e| unusable(&e.to_string()))?; + if text.len() != 64 || !text.iter().all(u8::is_ascii_hexdigit) { + return Err(unusable("is not 64 hex characters")); + } + hex::decode(text).map_err(|_| unusable("is not 64 hex characters")) +} + +fn create_file_key(dir: &Path, path: &Path) -> Result<(), Fatal> { + let fail = + |what: &str, e: std::io::Error| Fatal(format!("cannot {what} {}: {e}", path.display())); + + fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(dir) + .map_err(|e| fail("create the directory for", e))?; + + let mut secret = [0u8; 32]; + getrandom::getrandom(&mut secret) + .map_err(|e| Fatal(format!("cannot generate a file key: {e}")))?; + + let temp = dir.join(format!( + ".file.key.{}.{}", + std::process::id(), + unique_suffix() + )); + let written = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&temp) + .and_then(|mut f| { + f.write_all(hex::encode(secret).as_bytes())?; + f.sync_all() + }); + if let Err(e) = written { + let _ = fs::remove_file(&temp); + return Err(fail("write", e)); + } + + let linked = fs::hard_link(&temp, path); + // The temp name goes whether the link won or lost. A failure to remove it + // leaves an inert file of the same mode in the same directory. + let _ = fs::remove_file(&temp); + match linked { + // Another first run won the link: its key is complete, use it. + Err(e) if e.kind() == ErrorKind::AlreadyExists => Ok(()), + Err(e) => Err(fail("create", e)), + Ok(()) => fs::File::open(dir) + .and_then(|d| d.sync_all()) + .map_err(|e| fail("sync the directory of", e)), + } +} + +fn unique_suffix() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) +} + +/// The keyed hash behind every cache key, derived from the master key with +/// the protocol's `cache_key_salt` derivation. +pub struct KeyHasher([u8; 32]); + +impl KeyHasher { + pub fn new(master_key: &[u8]) -> Result { + cachekit_core::encryption::derive_domain_key(master_key, "cache_keys", TENANT.as_bytes()) + .map(Self) + .map_err(|e| Fatal(format!("cannot derive the cache-key hash key: {e}"))) + } + + /// `ns:ck-run:v1:<64 hex>`: a keyed BLAKE2b-256 over the length-prefixed + /// scope, then each argv element. Every field is length-prefixed, so the + /// encoding is injective: no scope or argv produces another entry's key. + pub fn value_key(&self, scope: &OsString, argv: &[OsString]) -> String { + let mut mac = Blake2bMac::::new_from_slice(&self.0) + .unwrap_or_else(|_| unreachable!("a 32-byte key is within BLAKE2b's 64-byte limit")); + for field in std::iter::once(scope).chain(argv) { + let bytes = field.as_bytes(); + mac.update(&(bytes.len() as u64).to_be_bytes()); + mac.update(bytes); + } + format!("{KEY_PREFIX}{}", hex::encode(mac.finalize().into_bytes())) + } +} + +/// The marker sits beside the value, under the same keyed hash. +pub fn marker_key(value_key: &str) -> String { + format!("{value_key}:neg") +} + +/// The 64-hex hash part of a value key, used to name its fill lock. +pub fn key_hash(value_key: &str) -> &str { + value_key.strip_prefix(KEY_PREFIX).unwrap_or(value_key) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hasher() -> KeyHasher { + KeyHasher::new(&[7u8; 32]).unwrap() + } + + fn os(v: &[&str]) -> Vec { + v.iter().map(OsString::from).collect() + } + + #[test] + fn key_shape() { + let key = hasher().value_key(&"".into(), &os(&["op", "read"])); + let hash = key.strip_prefix("ns:ck-run:v1:").unwrap(); + assert_eq!(hash.len(), 64); + assert!(hash.bytes().all(|b| b.is_ascii_hexdigit())); + assert_eq!(key_hash(&key), hash); + assert_eq!(marker_key(&key), format!("{key}:neg")); + } + + #[test] + fn field_boundaries_do_not_collide() { + let h = hasher(); + let keys = [ + h.value_key(&"".into(), &os(&["ab", "c"])), + h.value_key(&"".into(), &os(&["a", "bc"])), + h.value_key(&"".into(), &os(&["abc"])), + h.value_key(&"a".into(), &os(&["bc"])), + h.value_key(&"".into(), &os(&["", "abc"])), + ]; + for (i, a) in keys.iter().enumerate() { + for b in &keys[i + 1..] { + assert_ne!(a, b); + } + } + } + + #[test] + fn different_master_keys_give_disjoint_keys() { + let other = KeyHasher::new(&[8u8; 32]).unwrap(); + let argv = os(&["op", "read"]); + assert_ne!( + hasher().value_key(&"".into(), &argv), + other.value_key(&"".into(), &argv) + ); + } + + #[test] + fn master_key_hex() { + assert!(decode_master_key(&"ab".repeat(32).into()).is_ok()); + assert!(decode_master_key(&"ab".repeat(31).into()).is_err()); + assert!(decode_master_key(&"zz".repeat(32).into()).is_err()); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..f35d0bf --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,64 @@ +//! `ck`, the CacheKit command-line client. +//! +//! This library exists to serve the `ck` binary and its tests. It is not a +//! stable API. + +#![forbid(unsafe_code)] + +use std::ffi::OsString; +use std::io::{ErrorKind, Write}; + +mod child; +pub mod cli; +pub mod entry; +pub mod keys; +mod run; +pub mod store; + +/// ck's own error before the command runs: printed, then exit 125. +#[derive(Debug)] +pub struct Fatal(pub String); + +/// Exit code for ck's own errors, after the `env` and `timeout` convention. +pub const EXIT_CK_ERROR: i32 = 125; + +pub fn main(args: impl IntoIterator) -> i32 { + let invocation = match cli::parse(args) { + Ok(i) => i, + Err(e) => { + warn(&e); + eprintln!("{}", cli::USAGE); + return EXIT_CK_ERROR; + } + }; + match invocation { + cli::Invocation::Help => { + println!("{}", cli::USAGE); + 0 + } + cli::Invocation::Version => { + println!("ck {}", env!("CARGO_PKG_VERSION")); + 0 + } + cli::Invocation::Run(args) => run::run(&args).unwrap_or_else(|Fatal(e)| { + warn(&e); + EXIT_CK_ERROR + }), + } +} + +/// One line on stderr, prefixed so it is never mistaken for the command's. +pub(crate) fn warn(message: &str) { + eprintln!("ck: {message}"); +} + +/// Write to stdout. A reader that has gone away (EPIPE) is normal in a +/// pipeline and stays silent; any other failure is reported. +pub(crate) fn emit(bytes: &[u8]) { + let mut out = std::io::stdout().lock(); + if let Err(e) = out.write_all(bytes).and_then(|()| out.flush()) { + if e.kind() != ErrorKind::BrokenPipe { + warn(&format!("cannot write stdout: {e}")); + } + } +} diff --git a/src/main.rs b/src/main.rs index f0f2d5b..b20cb49 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,3 @@ fn main() { - println!("cachekit-cli {}", env!("CARGO_PKG_VERSION")); + std::process::exit(cachekit_cli::main(std::env::args_os().skip(1))); } diff --git a/src/run.rs b/src/run.rs new file mode 100644 index 0000000..12c90d8 --- /dev/null +++ b/src/run.rs @@ -0,0 +1,344 @@ +//! `ck run`: the control flow. +//! +//! A fresh value always wins, and the marker is read only when no fresh value +//! exists, so a failure that lands its marker after another process's success +//! is inert while the value is fresh. + +use std::fs; +use std::io::IsTerminal; +use std::os::fd::{AsFd, OwnedFd}; +use std::os::unix::fs::{DirBuilderExt, FileTypeExt, MetadataExt}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use rustix::fs::{FlockOperation, Mode, OFlags}; + +use crate::child::{Ran, Stdout, Supervisor}; +use crate::cli::RunArgs; +use crate::entry::{human, now_ms, Envelope, Freshness, Marker}; +use crate::keys::{self, KeyHasher}; +use crate::store::{ReadError, Store}; +use crate::{emit, warn, Fatal}; + +pub fn run(args: &RunArgs) -> Result { + if let Some(kind) = uncacheable_stdin() { + warn(&format!( + "stdin is {kind}, so this call is not cached. End the command with `< /dev/null` to cache it" + )); + return uncached(&args.command); + } + + let home = std::env::var_os("HOME") + .filter(|h| !h.is_empty()) + .map(PathBuf::from) + .ok_or_else(|| Fatal("HOME is not set".into()))?; + let master_key = keys::master_key(&home.join(".config/ck"))?; + let cache_dir = home.join(".cache/ck"); + let store = Store::open_file(&cache_dir.join("data"), &master_key)?; + let key = KeyHasher::new(&master_key)?.value_key(&args.scope, &args.command); + + let call = Call { + args, + marker_key: keys::marker_key(&key), + lock_path: cache_dir.join("locks").join(keys::key_hash(&key)), + key, + store, + }; + if args.refresh { + return call.refresh(); + } + call.cached() +} + +struct Call<'a> { + args: &'a RunArgs, + store: Store, + key: String, + marker_key: String, + lock_path: PathBuf, +} + +/// A stored run that may be replayed because the command failed. +struct Stale { + envelope: Envelope, + age_ms: u64, +} + +impl Call<'_> { + fn cached(&self) -> Result { + let value = match self.store.value(&self.key) { + Ok(v) => v, + Err(e) => return self.read_failed(e), + }; + let stale = match value.map(|v| self.judge(v)) { + Some(Judged::Fresh(stdout)) => { + emit(&stdout); + return Ok(0); + } + Some(Judged::Stale(s)) => Some(s), + Some(Judged::Expired) | None => None, + }; + + let supervisor = Supervisor::install()?; + // Another process holding the lock is filling. Serve what we have + // rather than wait; with nothing servable, wait for its result. + let lock = match fill_lock(&self.lock_path, stale.is_none()) { + Ok(Some(lock)) => lock, + Ok(None) => { + let stale = stale.unwrap_or_else(|| { + unreachable!("only a call with stale output skips the wait") + }); + let marker = self.store.marker(&self.marker_key).ok().flatten(); + return Ok(self.serve_stale(&stale, marker.as_ref(), None)); + } + Err(e) => { + warn(&format!( + "cannot take the fill lock {}: {e}; running uncached", + self.lock_path.display() + )); + return Ok(run_uncached(&supervisor, &self.args.command)); + } + }; + + // Holding the lock: whoever held it before may have filled the value. + let (value, marker) = match ( + self.store.value(&self.key), + self.store.marker(&self.marker_key), + ) { + (Ok(v), Ok(m)) => (v, m), + (Err(e), _) | (_, Err(e)) => return self.read_failed_with(e, &supervisor), + }; + let stale = match value.map(|v| self.judge(v)) { + Some(Judged::Fresh(stdout)) => { + emit(&stdout); + return Ok(0); + } + Some(Judged::Stale(s)) => Some(s), + Some(Judged::Expired) | None => None, + }; + let now = now_ms(); + if let Some(m) = marker.filter(|m| m.is_active(now)) { + if let Some(stale) = stale { + return Ok(self.serve_stale(&stale, Some(&m), None)); + } + warn(&format!( + "not running the command: its last run exited {}; next retry in {}. Use --refresh to run it now", + m.exit, + human(m.retry_at_ms - now) + )); + return Ok(m.exit); + } + + let code = self.record(&supervisor, stale, marker, false); + drop(lock); + Ok(code) + } + + /// `--refresh`: run now, ignoring the value, the marker and the lock. + /// Never serves stale. + fn refresh(&self) -> Result { + let supervisor = Supervisor::install()?; + Ok(self.record(&supervisor, None, None, true)) + } + + fn record( + &self, + supervisor: &Supervisor, + stale: Option, + marker: Option, + refresh: bool, + ) -> i32 { + let (code, stdout) = match supervisor.run(&self.args.command, true) { + Ran::SpawnFailed { code } => { + // The command never started, so the origin was not reached: + // no marker is set or bumped. + return match stale { + Some(stale) => self.serve_stale(&stale, None, None), + None => code, + }; + } + Ran::Exited { code, stdout } => (code, stdout), + }; + if let Some(sig) = supervisor.received() { + if let Stdout::Captured(out) = &stdout { + emit(out); + } + return 128 + sig; + } + + let now = now_ms(); + if code == 0 { + let Stdout::Captured(out) = stdout else { + return 0; + }; + // Print before the write-back, so a pipeline sees output at once. + emit(&out); + let ttl = Duration::from_secs(self.args.ttl_secs + self.args.stale_secs); + let stored = self + .store + .set(&self.key, &Envelope::encode(now, &out), ttl) + .and_then(|()| self.store.delete(&self.marker_key)); + if let Err(e) = stored { + warn(&format!( + "the command succeeded but its output was not cached: {e}" + )); + } + return 0; + } + + let previous = match marker { + Some(m) => Some(m), + None if refresh => self.store.marker(&self.marker_key).ok().flatten(), + None => None, + }; + let next = Marker::bumped(previous.as_ref(), code, now); + if let Err(e) = self + .store + .set(&self.marker_key, &next.encode(), next.backend_ttl(now)) + { + warn(&format!("cannot record the failure: {e}")); + } + match (stdout, stale) { + (Stdout::Captured(_), Some(stale)) => self.serve_stale(&stale, Some(&next), Some(code)), + (Stdout::Captured(out), None) => { + emit(&out); + code + } + (Stdout::Written, _) => code, + } + } + + fn judge(&self, envelope: Envelope) -> Judged { + match envelope.freshness(now_ms(), self.args.ttl_secs, self.args.stale_secs) { + Freshness::Fresh => Judged::Fresh(envelope.stdout), + Freshness::Stale { age_ms } => Judged::Stale(Stale { envelope, age_ms }), + Freshness::Expired => Judged::Expired, + } + } + + /// Replay a stored exit-0 run with exit 0 and one stderr line. + fn serve_stale(&self, stale: &Stale, marker: Option<&Marker>, failed_now: Option) -> i32 { + let now = now_ms(); + let mut line = match failed_now { + Some(code) => format!( + "the command exited {code}; serving its output from {} ago", + human(stale.age_ms) + ), + None => format!("serving output from {} ago", human(stale.age_ms)), + }; + if let Some(m) = marker.filter(|m| m.is_active(now)) { + if failed_now.is_none() { + line.push_str(&format!("; the last run exited {}", m.exit)); + } + line.push_str(&format!("; next retry in {}", human(m.retry_at_ms - now))); + } + warn(&line); + emit(&stale.envelope.stdout); + 0 + } + + fn read_failed(&self, e: ReadError) -> Result { + let supervisor = Supervisor::install()?; + self.read_failed_with(e, &supervisor) + } + + /// Any backend error before the run disables caching for this call, never + /// the command. No further backend calls are made. + fn read_failed_with(&self, e: ReadError, supervisor: &Supervisor) -> Result { + match e { + ReadError::Local(fatal) => Err(fatal), + ReadError::Backend(e) => { + warn(&format!("the cache is unavailable ({e}); running uncached")); + Ok(run_uncached(supervisor, &self.args.command)) + } + } + } +} + +enum Judged { + Fresh(Vec), + Stale(Stale), + Expired, +} + +fn uncached(argv: &[std::ffi::OsString]) -> Result { + Ok(run_uncached(&Supervisor::install()?, argv)) +} + +fn run_uncached(supervisor: &Supervisor, argv: &[std::ffi::OsString]) -> i32 { + match supervisor.run(argv, false) { + Ran::SpawnFailed { code } => code, + Ran::Exited { code, .. } => supervisor.received().map_or(code, |sig| 128 + sig), + } +} + +/// The key covers argv and `--scope` only, so only a call whose caller has +/// shut stdin off is cached: `/dev/null`, or a closed fd 0 (which Rust's +/// runtime reopens on `/dev/null` before `main`). A TTY carries whatever the +/// user types, so it does not qualify. Returns what stdin is otherwise. +fn uncacheable_stdin() -> Option<&'static str> { + let stdin = std::io::stdin(); + let meta = match stdin + .as_fd() + .try_clone_to_owned() + .map(fs::File::from) + .and_then(|f| f.metadata()) + { + Ok(meta) => meta, + Err(e) if e.raw_os_error() == Some(rustix::io::Errno::BADF.raw_os_error()) => return None, + Err(_) => return Some("not inspectable"), + }; + let kind = meta.file_type(); + if kind.is_char_device() { + let null = fs::metadata("/dev/null").ok()?; + if meta.rdev() == null.rdev() { + return None; + } + return Some(if stdin.is_terminal() { + "a terminal" + } else { + "a character device" + }); + } + Some(if kind.is_fifo() { + "a pipe" + } else if kind.is_socket() { + "a socket" + } else if kind.is_file() { + "a file" + } else { + "not /dev/null" + }) +} + +/// Take the fill lock, an exclusive `flock` on the key's lockfile. When it is +/// contended, wait for it only if `wait`; otherwise return `None`. flock is +/// released when its holder exits or dies, so no lock goes stale. +/// +/// Fill locks live outside the backend's directory and are never unlinked: +/// unlinking a lockfile races a waiter that has already opened it. +fn fill_lock(path: &Path, wait: bool) -> std::io::Result> { + if let Some(dir) = path.parent() { + fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(dir)?; + } + // CLOEXEC keeps the lock out of the child, so a command that leaves a + // daemon behind does not hold it after ck exits. + let flags = OFlags::CREATE | OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC; + let lock = rustix::fs::open(path, flags, Mode::from_raw_mode(0o600))?; + match rustix::fs::flock(&lock, FlockOperation::NonBlockingLockExclusive) { + Ok(()) => return Ok(Some(lock)), + Err(rustix::io::Errno::WOULDBLOCK) if !wait => return Ok(None), + Err(rustix::io::Errno::WOULDBLOCK) => {} + Err(e) => return Err(e.into()), + } + loop { + match rustix::fs::flock(&lock, FlockOperation::LockExclusive) { + Ok(()) => return Ok(Some(lock)), + Err(rustix::io::Errno::INTR) => {} + Err(e) => return Err(e.into()), + } + } +} diff --git a/src/store.rs b/src/store.rs new file mode 100644 index 0000000..cfa96b1 --- /dev/null +++ b/src/store.rs @@ -0,0 +1,99 @@ +//! Encrypted reads and writes against a cachekit-rs `Backend`. + +use std::path::Path; +use std::time::Duration; + +use cachekit::backend::file::FileBackend; +use cachekit::backend::Backend; +use cachekit::{BackendErrorKind, CachekitError, EncryptionLayer}; + +use crate::entry::{Envelope, Marker}; +use crate::keys::TENANT; +use crate::Fatal; + +/// Why a read produced no answer. +pub enum ReadError { + /// The backend failed. The call runs uncached. + Backend(String), + /// A local configuration fault, not an answer from the backend: exit 125. + Local(Fatal), +} + +pub struct Store { + runtime: tokio::runtime::Runtime, + backend: Box, + layer: EncryptionLayer, +} + +impl Store { + pub fn open_file(data_dir: &Path, master_key: &[u8]) -> Result { + let backend = FileBackend::builder().cache_dir(data_dir).build().map_err(|e| { + Fatal(format!( + "cannot use the cache directory: {e}. Fix it with `chmod 700 {0}` if you own it, or remove {0}", + data_dir.display() + )) + })?; + Self::new(Box::new(backend), master_key) + } + + fn new(backend: Box, master_key: &[u8]) -> Result { + let layer = EncryptionLayer::new(master_key, TENANT) + .map_err(|e| Fatal(format!("cannot set up encryption: {e}")))?; + // The file backend runs its I/O on tokio's blocking pool; a + // current-thread runtime is the cheapest one that provides it. + let runtime = tokio::runtime::Builder::new_current_thread() + .build() + .map_err(|e| Fatal(format!("cannot start the async runtime: {e}")))?; + Ok(Self { + runtime, + backend, + layer, + }) + } + + pub fn value(&self, key: &str) -> Result, ReadError> { + Ok(self.get(key)?.and_then(Envelope::decode)) + } + + pub fn marker(&self, key: &str) -> Result, ReadError> { + Ok(self.get(key)?.as_deref().and_then(Marker::decode)) + } + + pub fn set(&self, key: &str, plaintext: &[u8], ttl: Duration) -> Result<(), String> { + let ciphertext = self + .layer + .encrypt(plaintext, key) + .map_err(|e| e.to_string())?; + self.runtime + .block_on(self.backend.set(key, ciphertext, Some(ttl))) + .map_err(|e| e.to_string()) + } + + pub fn delete(&self, key: &str) -> Result<(), String> { + self.runtime + .block_on(self.backend.delete(key)) + .map(|_| ()) + .map_err(|e| e.to_string()) + } + + fn get(&self, key: &str) -> Result>, ReadError> { + let ciphertext = match self.runtime.block_on(self.backend.get(key)) { + Ok(Some(c)) => c, + Ok(None) => return Ok(None), + // ck's own traffic cannot produce lock contention on the file + // backend (reads share the lock, writes take none), so a timeout + // is a miss rather than a fault. + Err(e) if e.kind == BackendErrorKind::Timeout => return Ok(None), + Err(e) => return Err(ReadError::Backend(e.to_string())), + }; + match self.layer.decrypt(&ciphertext, key) { + Ok(plaintext) => Ok(Some(plaintext)), + Err(CachekitError::Config(e)) => Err(ReadError::Local(Fatal(format!( + "encryption is misconfigured: {e}" + )))), + // A decrypt or AAD failure is a miss: it is never served, not even + // as stale, and a marker that fails to decrypt is absent. + Err(_) => Ok(None), + } + } +} diff --git a/tests/bench.rs b/tests/bench.rs new file mode 100644 index 0000000..206ebdd --- /dev/null +++ b/tests/bench.rs @@ -0,0 +1,84 @@ +//! The hit-path budget: `ck run -- true` may add at most 5 ms over `true`, +//! against a cache directory holding about 10,000 entries. +//! +//! The populated directory matters: the file backend scans its whole +//! directory each time it is opened, and nothing evicts unread entries. +//! Ignored by default because it needs a release build to mean anything: +//! `cargo test --release --test bench -- --ignored --nocapture`. + +mod common; + +use std::fs; +use std::io::Write; +use std::os::unix::fs::OpenOptionsExt; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use common::Sandbox; + +const ENTRIES: usize = 10_000; +const ROUNDS: usize = 300; +const BUDGET: Duration = Duration::from_millis(5); + +#[test] +#[ignore = "run in release mode by CI"] +fn hit_path_adds_at_most_5ms() { + let s = Sandbox::new(); + let hit = || { + s.ck(&["run", "--ttl", "1d", "--", "true"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap() + }; + assert!(hit().success()); + populate(&s); + assert!(fs::read_dir(s.data_dir()).unwrap().count() > ENTRIES); + + let time = |cmd: &mut Command| { + let started = Instant::now(); + assert!(cmd.status().unwrap().success()); + started.elapsed() + }; + let mut ck = Vec::with_capacity(ROUNDS); + let mut bare = Vec::with_capacity(ROUNDS); + for _ in 0..ROUNDS { + ck.push(time( + s.ck(&["run", "--ttl", "1d", "--", "true"]) + .stdout(Stdio::null()), + )); + bare.push(time(Command::new("true").stdin(Stdio::null()))); + } + let (ck, bare) = (median(ck), median(bare)); + let added = ck.saturating_sub(bare); + println!("hit path: ck {ck:?}, true {bare:?}, added {added:?} (budget {BUDGET:?})"); + assert!( + added <= BUDGET, + "ck added {added:?} on a hit, over the {BUDGET:?} budget" + ); +} + +/// Entries in the file backend's on-disk format: a 14-byte header (magic, +/// version, flags, expiry) and a payload. Contents do not matter; the scan +/// cost is per directory entry. +fn populate(s: &Sandbox) { + let mut header = [0u8; 14]; + header[..3].copy_from_slice(b"CK\x01"); + header[6..].copy_from_slice(&4_102_444_800u64.to_be_bytes()); + for i in 0..ENTRIES { + let path = s.data_dir().join(format!("{i:032x}")); + let mut f = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + .unwrap(); + f.write_all(&header).unwrap(); + f.write_all(&[0u8; 64]).unwrap(); + } +} + +fn median(mut samples: Vec) -> Duration { + samples.sort(); + samples[samples.len() / 2] +} diff --git a/tests/cli.rs b/tests/cli.rs deleted file mode 100644 index bfcef62..0000000 --- a/tests/cli.rs +++ /dev/null @@ -1,14 +0,0 @@ -use std::process::Command; - -#[test] -fn binary_runs() { - let out = Command::new(env!("CARGO_BIN_EXE_cachekit-cli")) - .output() - .expect("failed to spawn the binary"); - assert!( - out.status.success(), - "exit {:?}, stderr: {}", - out.status, - String::from_utf8_lossy(&out.stderr) - ); -} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..d09a022 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,147 @@ +//! A sandbox per test: its own HOME, and a PATH shim that counts its calls. +//! +//! Every test drives the real `ck` binary as a separate process. + +#![allow(dead_code)] + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Output, Stdio}; + +pub const BIN: &str = env!("CARGO_BIN_EXE_ck"); + +/// A fixed master key, for tests that need to write entries themselves. +pub const MASTER_KEY_HEX: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +pub struct Sandbox { + root: tempfile::TempDir, +} + +impl Sandbox { + pub fn new() -> Self { + let sandbox = Self { + root: tempfile::tempdir().expect("tempdir"), + }; + fs::create_dir_all(sandbox.home()).unwrap(); + fs::create_dir_all(sandbox.bin()).unwrap(); + sandbox.install_shim("origin"); + sandbox + } + + pub fn home(&self) -> PathBuf { + self.root.path().join("home") + } + + pub fn bin(&self) -> PathBuf { + self.root.path().join("bin") + } + + fn state(&self, name: &str) -> PathBuf { + self.root.path().join(name) + } + + /// A command on PATH that records each call, then prints the contents of + /// `out` (default `hello`) and exits with the code in `exit` (default 0). + /// `sleep` and `env` files make it sleep first or dump its environment. + pub fn install_shim(&self, name: &str) { + let r = self.root.path().display(); + let script = format!( + "#!/bin/sh\n\ + echo run >> '{r}/calls'\n\ + [ -f '{r}/sleep' ] && sleep \"$(cat '{r}/sleep')\"\n\ + [ -f '{r}/env' ] && env > '{r}/env.out'\n\ + if [ -f '{r}/out' ]; then cat '{r}/out'; else echo hello; fi\n\ + exit \"$(cat '{r}/exit' 2>/dev/null || echo 0)\"\n" + ); + let path = self.bin().join(name); + fs::write(&path, script).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); + } + + pub fn set(&self, name: &str, value: &str) { + fs::write(self.state(name), value).unwrap(); + } + + pub fn unset(&self, name: &str) { + let _ = fs::remove_file(self.state(name)); + } + + pub fn read(&self, name: &str) -> String { + fs::read_to_string(self.state(name)).unwrap_or_default() + } + + /// How many times the shim has run. + pub fn runs(&self) -> usize { + self.read("calls").lines().count() + } + + /// `ck ` in this sandbox, with stdin on /dev/null and no CacheKit + /// variables inherited from the machine running the tests. + pub fn ck(&self, args: &[&str]) -> Command { + let mut cmd = Command::new(BIN); + cmd.args(args); + for (name, _) in std::env::vars_os() { + if name.to_string_lossy().starts_with("CACHEKIT_") { + cmd.env_remove(name); + } + } + let path = std::env::var_os("PATH").unwrap_or_default(); + let mut paths = vec![self.bin()]; + paths.extend(std::env::split_paths(&path)); + cmd.env("HOME", self.home()) + .env("PATH", std::env::join_paths(paths).unwrap()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + cmd + } + + /// `ck run -- origin`, run to completion. + pub fn run(&self, flags: &[&str]) -> Output { + self.ck(&run_args(flags)).output().unwrap() + } + + pub fn spawn(&self, flags: &[&str]) -> Child { + self.ck(&run_args(flags)).spawn().unwrap() + } + + pub fn config_dir(&self) -> PathBuf { + self.home().join(".config/ck") + } + + pub fn data_dir(&self) -> PathBuf { + self.home().join(".cache/ck/data") + } +} + +fn run_args<'a>(flags: &[&'a str]) -> Vec<&'a str> { + let mut args = vec!["run"]; + args.extend_from_slice(flags); + args.extend(["--", "origin"]); + args +} + +pub fn stdout(out: &Output) -> String { + String::from_utf8_lossy(&out.stdout).into_owned() +} + +pub fn stderr(out: &Output) -> String { + String::from_utf8_lossy(&out.stderr).into_owned() +} + +pub fn code(out: &Output) -> i32 { + out.status.code().unwrap_or(-1) +} + +pub fn entries(dir: &Path) -> Vec { + let mut names: Vec = fs::read_dir(dir) + .map(|d| { + d.flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect() + }) + .unwrap_or_default(); + names.sort(); + names +} diff --git a/tests/readme.rs b/tests/readme.rs new file mode 100644 index 0000000..c6a7f75 --- /dev/null +++ b/tests/readme.rs @@ -0,0 +1,71 @@ +//! Every `sh` example in the README runs as written. + +mod common; + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::process::Command; + +use common::{code, stderr, Sandbox, BIN}; + +#[test] +fn readme_examples_run() { + let readme = fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md")).unwrap(); + let examples = sh_blocks(&readme); + assert!( + examples.len() >= 4, + "found only {} examples", + examples.len() + ); + + let s = Sandbox::new(); + // A stand-in for the 1Password CLI, which CI does not have. + let op = s.bin().join("op"); + fs::write(&op, "#!/bin/sh\necho stand-in-secret\n").unwrap(); + fs::set_permissions(&op, fs::Permissions::from_mode(0o755)).unwrap(); + let ck_dir = Path::new(BIN).parent().unwrap(); + + for example in examples { + assert!( + example.contains("< /dev/null"), + "an example without the redirect:\n{example}" + ); + let path = std::env::join_paths( + [s.bin().as_path(), ck_dir] + .into_iter() + .map(Path::to_path_buf) + .chain(std::env::split_paths( + &std::env::var_os("PATH").unwrap_or_default(), + )), + ) + .unwrap(); + let out = Command::new("sh") + .args(["-euc", &example]) + .env("HOME", s.home()) + .env("PATH", path) + .output() + .unwrap(); + assert_eq!(code(&out), 0, "{example}\n{}", stderr(&out)); + } +} + +fn sh_blocks(markdown: &str) -> Vec { + let mut blocks = Vec::new(); + let mut current: Option = None; + for line in markdown.lines() { + match (&mut current, line.trim_end()) { + (None, "```sh") => current = Some(String::new()), + (Some(block), "```") => { + blocks.push(std::mem::take(block)); + current = None; + } + (Some(block), text) => { + block.push_str(text); + block.push('\n'); + } + (None, _) => {} + } + } + blocks +} diff --git a/tests/run.rs b/tests/run.rs new file mode 100644 index 0000000..6299ed9 --- /dev/null +++ b/tests/run.rs @@ -0,0 +1,494 @@ +//! `ck run` failure semantics, each case driven through separate processes. + +mod common; + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::net::UnixStream; +use std::process::Stdio; +use std::thread::sleep; +use std::time::{Duration, Instant}; + +use common::{code, entries, stderr, stdout, Sandbox, MASTER_KEY_HEX}; + +/// Long enough for a 1 s TTL to lapse. +const PAST_TTL: Duration = Duration::from_millis(1_100); + +#[test] +fn a_fresh_value_is_served_without_running() { + let s = Sandbox::new(); + assert_eq!(stdout(&s.run(&["--ttl", "1h"])), "hello\n"); + s.set("out", "changed\n"); + let out = s.run(&["--ttl", "1h"]); + assert_eq!((code(&out), stdout(&out)), (0, "hello\n".into())); + assert_eq!(s.runs(), 1); +} + +#[test] +fn outage_with_a_stale_value_serves_stale() { + let s = Sandbox::new(); + let flags = ["--ttl", "1s", "--stale", "1h"]; + assert_eq!(code(&s.run(&flags)), 0); + sleep(PAST_TTL); + s.set("exit", "1"); + s.set("out", "error page\n"); + + for call in 1..=10 { + let out = s.run(&flags); + assert_eq!(code(&out), 0, "call {call}: {}", stderr(&out)); + assert_eq!(stdout(&out), "hello\n", "call {call}"); + assert_eq!( + stderr(&out).lines().count(), + 1, + "call {call}: {}", + stderr(&out) + ); + } + // One run filled the value; the outage cost at most two more. + assert!( + s.runs() - 1 <= 2, + "the command ran {} times during the outage", + s.runs() - 1 + ); +} + +#[test] +fn outage_with_no_value_runs_at_most_twice() { + let s = Sandbox::new(); + s.set("exit", "3"); + for call in 1..=10 { + let out = s.run(&[]); + assert_eq!(code(&out), 3, "call {call}"); + } + assert!(s.runs() <= 2, "the command ran {} times", s.runs()); + let out = s.run(&[]); + assert!(out.stdout.is_empty(), "a suppressed call writes no stdout"); + assert!(stderr(&out).contains("next retry in"), "{}", stderr(&out)); +} + +#[test] +fn concurrent_cold_calls_run_once() { + let s = Sandbox::new(); + s.set("sleep", "0.5"); + let children: Vec<_> = (0..10).map(|_| s.spawn(&["--ttl", "1h"])).collect(); + for child in children { + let out = child.wait_with_output().unwrap(); + assert_eq!( + (code(&out), stdout(&out)), + (0, "hello\n".into()), + "{}", + stderr(&out) + ); + } + assert_eq!(s.runs(), 1); +} + +#[test] +fn a_contended_lock_with_a_stale_value_serves_stale_at_once() { + let s = Sandbox::new(); + let flags = ["--ttl", "1s", "--stale", "1h"]; + s.run(&flags); + sleep(PAST_TTL); + s.set("sleep", "2"); + s.set("out", "new\n"); + let filler = s.spawn(&flags); + sleep(Duration::from_millis(300)); + + let started = Instant::now(); + let out = s.run(&flags); + assert!( + started.elapsed() < Duration::from_secs(1), + "the second call waited on the lock" + ); + assert_eq!((code(&out), stdout(&out)), (0, "hello\n".into())); + assert_eq!(stdout(&filler.wait_with_output().unwrap()), "new\n"); +} + +#[test] +fn a_late_marker_is_inert_while_the_value_is_fresh() { + let s = Sandbox::new(); + assert_eq!(code(&s.run(&["--ttl", "1h"])), 0); + // A failure that lands its marker after the success. + s.set("exit", "1"); + assert_eq!(code(&s.run(&["--ttl", "1h", "--refresh"])), 1); + + let out = s.run(&["--ttl", "1h"]); + assert_eq!( + (code(&out), stdout(&out), stderr(&out)), + (0, "hello\n".into(), String::new()) + ); + assert_eq!(s.runs(), 2); +} + +#[test] +fn a_spawn_failure_sets_no_marker() { + let s = Sandbox::new(); + let missing = ["run", "--", "ck-test-not-installed"]; + let out = s.ck(&missing).output().unwrap(); + assert_eq!(code(&out), 127); + assert!( + stderr(&out).contains("command not found"), + "{}", + stderr(&out) + ); + + s.install_shim("ck-test-not-installed"); + let out = s.ck(&missing).output().unwrap(); + assert_eq!( + (code(&out), stdout(&out)), + (0, "hello\n".into()), + "{}", + stderr(&out) + ); + assert_eq!(s.runs(), 1); +} + +#[test] +fn a_child_that_exits_127_sets_a_marker() { + let s = Sandbox::new(); + s.set("exit", "127"); + assert_eq!(code(&s.run(&[])), 127); + let out = s.run(&[]); + assert_eq!(code(&out), 127); + assert!( + stderr(&out).contains("not running the command"), + "{}", + stderr(&out) + ); + assert_eq!(s.runs(), 1); +} + +#[test] +fn a_marker_more_than_15_minutes_ahead_is_inactive() { + use cachekit_cli::entry::{now_ms, Marker}; + use cachekit_cli::keys::{marker_key, KeyHasher}; + use cachekit_cli::store::Store; + + let write_marker = |s: &Sandbox, ahead_ms: u64| { + let master = hex::decode(MASTER_KEY_HEX).unwrap(); + let key = KeyHasher::new(&master) + .unwrap() + .value_key(&"".into(), &["origin".into()]); + let store = Store::open_file(&s.data_dir(), &master).unwrap(); + let marker = Marker { + exit: 9, + failures: 1, + retry_at_ms: now_ms() + ahead_ms, + }; + store + .set( + &marker_key(&key), + &marker.encode(), + Duration::from_secs(3600), + ) + .unwrap(); + }; + let ck = |s: &Sandbox| { + s.ck(&["run", "--", "origin"]) + .env("CACHEKIT_MASTER_KEY", MASTER_KEY_HEX) + .output() + .unwrap() + }; + + let s = Sandbox::new(); + write_marker(&s, 16 * 60 * 1000); + let out = ck(&s); + assert_eq!((code(&out), s.runs()), (0, 1), "{}", stderr(&out)); + + // The control: inside the window, the same marker suppresses. + let s = Sandbox::new(); + write_marker(&s, 14 * 60 * 1000); + let out = ck(&s); + assert_eq!((code(&out), s.runs()), (9, 0), "{}", stderr(&out)); +} + +#[test] +fn lowering_stale_stops_an_old_entry_being_served() { + let s = Sandbox::new(); + s.run(&["--ttl", "1s", "--stale", "1h"]); + sleep(PAST_TTL); + s.set("exit", "1"); + s.set("out", "error\n"); + + let out = s.run(&["--ttl", "1s", "--stale", "1h"]); + assert_eq!((code(&out), stdout(&out)), (0, "hello\n".into())); + + let out = s.run(&["--ttl", "1s", "--stale", "0s"]); + assert_ne!(code(&out), 0); + assert!( + !stdout(&out).contains("hello"), + "an entry past the reader's --stale was served" + ); +} + +#[test] +fn only_dev_null_stdin_is_cached() { + let file = tempfile::NamedTempFile::new().unwrap(); + // Each stdin comes with whatever must stay open while ck runs. + let stdin_for = |kind: &str| -> (Stdio, Option) { + match kind { + "a terminal" => { + use rustix::pty::{grantpt, openpt, ptsname, unlockpt, OpenptFlags}; + let master = openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY).unwrap(); + grantpt(&master).unwrap(); + unlockpt(&master).unwrap(); + let name = ptsname(&master, Vec::new()).unwrap(); + let slave = fs::OpenOptions::new() + .read(true) + .write(true) + .open(name.to_str().unwrap()) + .unwrap(); + (Stdio::from(slave), Some(master)) + } + "a pipe" => (Stdio::piped(), None), + "a socket" => { + let (a, b) = UnixStream::pair().unwrap(); + (Stdio::from(std::os::fd::OwnedFd::from(a)), Some(b.into())) + } + _ => (Stdio::from(fs::File::open(file.path()).unwrap()), None), + } + }; + let cases = ["a terminal", "a pipe", "a socket", "a file"]; + + for kind in cases { + let s = Sandbox::new(); + for call in 1..=2 { + let (stdin, _keep) = stdin_for(kind); + let out = s + .ck(&["run", "--", "origin"]) + .stdin(stdin) + .output() + .unwrap(); + assert_eq!(code(&out), 0, "{kind}"); + let err = stderr(&out); + assert_eq!(err.lines().count(), 1, "{kind}: {err}"); + assert!( + err.contains(kind) && err.contains("< /dev/null"), + "{kind}: {err}" + ); + assert_eq!(s.runs(), call, "{kind}: the call was cached"); + } + assert!( + !s.home().join(".cache").exists(), + "{kind}: an uncached call touched the cache" + ); + } + + let s = Sandbox::new(); + s.run(&[]); + let out = s.run(&[]); + assert_eq!((stderr(&out), s.runs()), (String::new(), 1)); +} + +#[test] +fn the_child_sees_no_cachekit_variables() { + let s = Sandbox::new(); + s.set("env", ""); + let out = s + .ck(&["run", "--", "origin"]) + .env("CACHEKIT_MASTER_KEY", MASTER_KEY_HEX) + .env("CACHEKIT_API_KEY", "ck_sdk_test") + .env("CACHEKIT_ANYTHING", "x") + .env("NOT_CACHEKIT_SURVIVES", "y") + .output() + .unwrap(); + assert_eq!(code(&out), 0); + let env = s.read("env.out"); + assert!(!env.lines().any(|l| l.starts_with("CACHEKIT_")), "{env}"); + assert!(env.contains("NOT_CACHEKIT_SURVIVES=y")); +} + +#[test] +fn concurrent_first_runs_agree_on_one_file_key() { + let s = Sandbox::new(); + s.set("sleep", "0.3"); + let children: Vec<_> = (0..10).map(|_| s.spawn(&["--ttl", "1h"])).collect(); + for child in children { + let out = child.wait_with_output().unwrap(); + assert_eq!(code(&out), 0, "{}", stderr(&out)); + } + // One key means one cache key, one fill lock and so one run. + assert_eq!(s.runs(), 1); + assert_eq!(entries(&s.config_dir()), ["file.key"]); + let meta = fs::metadata(s.config_dir().join("file.key")).unwrap(); + assert_eq!(meta.permissions().mode() & 0o777, 0o600); + assert_eq!( + fs::metadata(s.config_dir()).unwrap().permissions().mode() & 0o777, + 0o700 + ); + s.run(&["--ttl", "1h"]); + assert_eq!(s.runs(), 1); +} + +#[test] +fn an_invalid_file_key_exits_125_and_is_left_untouched() { + let valid = "ab".repeat(32); + let cases = [ + ("not hex", "zz".repeat(32), 0o600), + ("too short", "ab".repeat(31), 0o600), + ("a trailing newline", format!("{valid}\n"), 0o600), + ("mode 0644", valid.clone(), 0o644), + ]; + for (why, content, mode) in cases { + let s = Sandbox::new(); + fs::create_dir_all(s.config_dir()).unwrap(); + let path = s.config_dir().join("file.key"); + fs::write(&path, &content).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(mode)).unwrap(); + + let out = s.run(&[]); + assert_eq!(code(&out), 125, "{why}"); + assert!( + stderr(&out).contains("Delete it"), + "{why}: {}", + stderr(&out) + ); + assert_eq!(fs::read_to_string(&path).unwrap(), content, "{why}"); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + mode, + "{why}" + ); + assert_eq!(s.runs(), 0, "{why}"); + } +} + +#[test] +fn an_unusable_cache_directory_exits_125() { + let s = Sandbox::new(); + fs::create_dir_all(s.data_dir()).unwrap(); + fs::set_permissions(s.data_dir(), fs::Permissions::from_mode(0o777)).unwrap(); + let out = s.run(&[]); + assert_eq!((code(&out), s.runs()), (125, 0)); + assert!(stderr(&out).contains("chmod 700"), "{}", stderr(&out)); +} + +#[test] +fn bad_flags_exit_125_before_anything_runs() { + let s = Sandbox::new(); + for flags in [ + &["--ttl", "0s"][..], + &["--ttl", "1.5h"], + &["--stale", "1w"], + &["--ttl", "20d", "--stale", "10d1s"], + &["--ttl", "20d", "--stale", "864001s"], + &["--backend", "saas"], + &["--nope"], + ] { + let out = s.run(flags); + assert_eq!(code(&out), 125, "{flags:?}"); + } + assert_eq!(s.runs(), 0); + assert!(!s.home().join(".cache").exists()); + assert_eq!(code(&s.run(&["--ttl", "20d", "--stale", "10d"])), 0); +} + +#[test] +fn refresh_runs_and_never_serves_stale() { + let s = Sandbox::new(); + s.run(&["--ttl", "1h", "--stale", "1h"]); + s.set("out", "new\n"); + assert_eq!(stdout(&s.run(&["--ttl", "1h", "--refresh"])), "new\n"); + assert_eq!(stdout(&s.run(&["--ttl", "1h"])), "new\n"); + + s.set("exit", "4"); + s.set("out", "failure output\n"); + let out = s.run(&["--ttl", "1h", "--stale", "1h", "--refresh"]); + assert_eq!((code(&out), stdout(&out)), (4, "failure output\n".into())); + assert_eq!(s.runs(), 3); +} + +#[test] +fn output_past_20_mib_is_streamed_and_not_cached() { + let s = Sandbox::new(); + let big = 21 * 1024 * 1024; + let args = ["run", "--", "head", "-c", &big.to_string(), "/dev/zero"]; + for _ in 0..2 { + let out = s.ck(&args).output().unwrap(); + assert_eq!((code(&out), out.stdout.len()), (0, big)); + assert!(stderr(&out).contains("20 MiB"), "{}", stderr(&out)); + } +} + +fn signal(child: &std::process::Child, sig: rustix::process::Signal) { + let pid = rustix::process::Pid::from_raw(child.id() as i32).unwrap(); + rustix::process::kill_process(pid, sig).unwrap(); +} + +#[test] +fn sigterm_is_forwarded_and_nothing_is_stored() { + let s = Sandbox::new(); + // `sleep` itself, not the shim: a shell killed mid-`sleep` leaves the + // sleep holding stdout open, with or without ck. + let child = s + .ck(&["run", "--ttl", "1h", "--", "sleep", "5"]) + .spawn() + .unwrap(); + sleep(Duration::from_millis(300)); + let started = Instant::now(); + signal(&child, rustix::process::Signal::TERM); + let out = child.wait_with_output().unwrap(); + assert!( + started.elapsed() < Duration::from_secs(3), + "the child was not terminated" + ); + assert_eq!(code(&out), 128 + 15); + assert!( + entries(&s.data_dir()).is_empty(), + "the interrupted run was cached" + ); +} + +#[test] +fn sigint_waits_for_the_child_and_stores_nothing() { + let s = Sandbox::new(); + s.set("sleep", "0.6"); + let child = s.spawn(&["--ttl", "1h"]); + sleep(Duration::from_millis(200)); + // Only ck gets it, as if the child ignored the terminal's interrupt. + signal(&child, rustix::process::Signal::INT); + let out = child.wait_with_output().unwrap(); + assert_eq!(code(&out), 128 + 2); + assert_eq!(stdout(&out), "hello\n", "ck did not wait for the child"); + + s.unset("sleep"); + s.run(&["--ttl", "1h"]); + assert_eq!(s.runs(), 2, "the interrupted run was cached"); +} + +#[test] +fn a_waiter_blocked_on_the_lock_exits_on_a_signal() { + let s = Sandbox::new(); + s.set("sleep", "3"); + let holder = s.spawn(&["--ttl", "1h"]); + sleep(Duration::from_millis(300)); + let waiter = s.spawn(&["--ttl", "1h"]); + sleep(Duration::from_millis(300)); + let started = Instant::now(); + signal(&waiter, rustix::process::Signal::TERM); + let out = waiter.wait_with_output().unwrap(); + assert!(started.elapsed() < Duration::from_secs(1)); + assert_eq!(code(&out), 128 + 15); + assert_eq!(code(&holder.wait_with_output().unwrap()), 0); +} + +#[test] +fn scope_and_argv_are_in_the_key() { + let s = Sandbox::new(); + s.run(&["--ttl", "1h"]); + s.run(&["--ttl", "1h", "--scope", "prod"]); + s.run(&["--ttl", "1h", "--scope", "prod"]); + s.ck(&["run", "--ttl", "1h", "--", "origin", "x"]) + .output() + .unwrap(); + assert_eq!(s.runs(), 3); +} + +#[test] +fn version_and_usage() { + let s = Sandbox::new(); + let out = s.ck(&["--version"]).output().unwrap(); + assert_eq!(code(&out), 0); + assert!(stdout(&out).starts_with("ck ")); + assert_eq!(code(&s.ck(&[]).output().unwrap()), 125); +} From 2e1b2f507e76e407358051f015b3ba11b82b98bb Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Fri, 2 Oct 2026 20:05:15 +1000 Subject: [PATCH 02/16] fix(run): signal, marker and stdin edge cases (LAB-5118) - Record a received signal inside its handler, so a child killed by a process-group SIGINT is never read as an ordinary failure. A child that dies of SIGINT, SIGQUIT, SIGTERM or SIGHUP also counts as interrupted. - After the child exits, a signal ends ck at once instead of being dropped. - Leave signals inherited as ignored alone, so `nohup ck run` keeps its SIGHUP immunity and the command inherits it. Reading the disposition is the crate's one unsafe block; unsafe_code is deny instead of forbid. - Delete the marker on every exit-0 run, even one too large to store. - Stop streaming and drop the pipe once stdout fails, so the command gets SIGPIPE. - Treat stdin as uncacheable when /dev/null cannot be inspected. - Read file.key through one O_NOFOLLOW descriptor that is also fstat'ed. - Reject a non-ASCII duration instead of panicking; never panic on a closed stderr. --- Cargo.lock | 1 + Cargo.toml | 5 +- README.md | 12 +++- src/child.rs | 112 ++++++++++++++++++++++++++---------- src/cli.rs | 6 +- src/keys.rs | 43 +++++++++----- src/lib.rs | 28 +++++---- src/run.rs | 133 ++++++++++++++++++------------------------- tests/readme.rs | 11 ++-- tests/run.rs | 148 ++++++++++++++++++++++++++++++++++++++++++++++-- 10 files changed, 351 insertions(+), 148 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6993bad..174a7d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -157,6 +157,7 @@ dependencies = [ "cachekit-rs", "getrandom 0.2.17", "hex", + "libc", "rustix", "signal-hook", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 076085a..7b04a0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,9 @@ name = "ck" path = "src/main.rs" [lints.rust] -unsafe_code = "forbid" +# deny, not forbid: one audited block in src/child.rs reads a signal's +# inherited disposition, which no dependency exposes safely. +unsafe_code = "deny" [dependencies] blake2 = "0.10" @@ -23,6 +25,7 @@ cachekit-core = { version = "0.6", default-features = false, features = ["encryp cachekit-rs = { version = "0.8", default-features = false, features = ["encryption", "file"] } getrandom = "0.2" hex = "0.4" +libc = "0.2" rustix = { version = "1", default-features = false, features = ["std", "fs", "process"] } signal-hook = "0.4" tokio = { version = "1", default-features = false, features = ["rt"] } diff --git a/README.md b/README.md index 33bd93c..6100276 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,17 @@ is not repeated. A typical use is a secret read in a shell startup file, where every new shell would otherwise call the secret manager again: ```sh -export API_TOKEN="$(ck run --ttl 12h --stale 7d -- op read op://Private/api/token < /dev/null)" +API_TOKEN="$(ck run --ttl 12h --stale 7d -- op read op://Private/api/token < /dev/null)" +export API_TOKEN ``` The first call runs `op` and stores its output. Calls in the next 12 hours print the stored output without running `op`. After that, ck runs `op` again; -if `op` fails, ck serves the stored output for up to 7 more days. +if `op` fails, ck serves the stored output for up to 7 more days. That is +also the revocation bound: a token rotated at the source can still be served +for up to 12 hours plus 7 days, so set `--stale` to how long you can tolerate +serving a revoked secret (see [How long a cached secret lives](#how-long-a-cached-secret-lives)). +Assigning before exporting keeps ck's exit code visible to the shell. ```text ck run [--backend file] [--ttl D] [--stale D] [--scope S] [--refresh] -- [args...] @@ -108,7 +113,8 @@ To stop serving old output: Entries are encrypted with AES-256-GCM, and their file names are keyed hashes, so neither the output nor the command line appears on disk. The key comes from -`CACHEKIT_MASTER_KEY` (64 or more hex characters) if it is set. Otherwise ck +`CACHEKIT_MASTER_KEY` (hex, an even number of characters and at least 64) if +it is set. Otherwise ck creates a random key in `~/.config/ck/file.key` on first use, mode 0600. If that file is damaged, ck exits 125 and tells you to delete it; it never overwrites it. diff --git a/src/child.rs b/src/child.rs index a935979..d11a7c0 100644 --- a/src/child.rs +++ b/src/child.rs @@ -5,7 +5,7 @@ use std::io::{ErrorKind, Read}; use std::os::unix::ffi::OsStrExt; use std::os::unix::process::ExitStatusExt; use std::process::{Command, Stdio}; -use std::sync::atomic::{AtomicI32, Ordering}; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; use rustix::process::{Pid, Signal, WaitId, WaitIdOptions}; @@ -14,6 +14,9 @@ use signal_hook::iterator::Signals; use crate::{emit, warn, Fatal}; +/// The signals ck takes over while it may block or run the command. +const HANDLED: [i32; 4] = [SIGTERM, SIGHUP, SIGINT, SIGQUIT]; + /// Output past this is streamed, not cached. The SaaS rejects values over /// 25 MB; the margin covers the envelope and encryption overhead. pub const OUTPUT_CAP: usize = 20 * 1024 * 1024; @@ -27,6 +30,12 @@ pub enum Ran { code: i32, stdout: Stdout, }, + /// ck received one of [`HANDLED`], or the command died of one. Nothing + /// is stored: the caller prints any captured output and exits 128 + n. + Interrupted { + signal: i32, + stdout: Stdout, + }, } pub enum Stdout { @@ -37,11 +46,11 @@ pub enum Stdout { } enum State { - /// No child yet. A signal now ends ck at once: nothing has been written. - Waiting, + /// No child is running: none yet, or it has exited. A signal now ends ck + /// at once. Nothing is half-written, because entries are written to a + /// temp file and renamed into place. + Idle, Running(Pid), - /// The child has exited. Its pid must not be signalled again. - Exited, } /// Owns ck's handling of SIGTERM, SIGHUP, SIGINT and SIGQUIT from the point @@ -52,33 +61,41 @@ enum State { /// from the terminal, and some programs treat a second interrupt as a hard /// abort. On any of them ck waits for the child, stores nothing, and exits /// 128 + n. +/// +/// A signal ck inherited as ignored (`nohup`, a background job) is left +/// ignored, so the command inherits it ignored too and ck never forwards it: +/// wrapping a command must not change what it would do bare. pub struct Supervisor { state: Arc>, - received: Arc, + /// Set inside the signal handler itself, so it is visible before the + /// main thread can reap a child killed by the same process-group signal. + received: Arc, } impl Supervisor { pub fn install() -> Result { - let mut signals = Signals::new([SIGTERM, SIGHUP, SIGINT, SIGQUIT]) - .map_err(|e| Fatal(format!("cannot install signal handlers: {e}")))?; - let state = Arc::new(Mutex::new(State::Waiting)); - let received = Arc::new(AtomicI32::new(0)); - let (thread_state, thread_received) = (Arc::clone(&state), Arc::clone(&received)); + let handled: Vec = HANDLED + .into_iter() + .filter(|&s| !inherited_ignored(s)) + .collect(); + let fail = |e: std::io::Error| Fatal(format!("cannot install signal handlers: {e}")); + let received = Arc::new(AtomicUsize::new(0)); + for &sig in &handled { + let value = usize::try_from(sig) + .unwrap_or_else(|_| unreachable!("signal numbers are positive")); + signal_hook::flag::register_usize(sig, Arc::clone(&received), value).map_err(fail)?; + } + let mut signals = Signals::new(&handled).map_err(fail)?; + let state = Arc::new(Mutex::new(State::Idle)); + let thread_state = Arc::clone(&state); std::thread::Builder::new() .name("signals".into()) .spawn(move || { for sig in signals.forever() { - let _ = thread_received.compare_exchange( - 0, - sig, - Ordering::SeqCst, - Ordering::SeqCst, - ); let state = thread_state.lock().unwrap_or_else(PoisonError::into_inner); match *state { - State::Waiting => std::process::exit(128 + sig), + State::Idle => std::process::exit(128 + sig), State::Running(pid) => forward(pid, sig), - State::Exited => {} } } }) @@ -86,9 +103,11 @@ impl Supervisor { Ok(Self { state, received }) } - /// The first terminating signal ck received, if any. - pub fn received(&self) -> Option { - Some(self.received.load(Ordering::SeqCst)).filter(|&s| s != 0) + /// The latest handled signal ck received, if any. + fn received(&self) -> Option { + i32::try_from(self.received.load(Ordering::SeqCst)) + .ok() + .filter(|&s| s != 0) } /// Run `argv` with every `CACHEKIT_*` variable removed from its @@ -143,8 +162,17 @@ impl Supervisor { let exited = WaitIdOptions::EXITED | WaitIdOptions::NOWAIT; while let Err(rustix::io::Errno::INTR) = rustix::process::waitid(WaitId::Pid(pid), exited) { } - *self.state.lock().unwrap_or_else(PoisonError::into_inner) = State::Exited; - let code = match child.wait() { + *self.state.lock().unwrap_or_else(PoisonError::into_inner) = State::Idle; + let status = child.wait(); + let died_of = status + .as_ref() + .ok() + .and_then(|s| s.signal()) + .filter(|s| HANDLED.contains(s)); + if let Some(signal) = self.received().or(died_of) { + return Ran::Interrupted { signal, stdout }; + } + let code = match status { Ok(status) => status .code() .unwrap_or_else(|| 128 + status.signal().unwrap_or(0)), @@ -158,7 +186,9 @@ impl Supervisor { } /// Read the child's stdout into memory, or, once it passes the cap, write -/// out what is held and stream the rest. +/// out what is held and stream the rest. Once ck's own stdout fails (the +/// reader has gone), the pipe is dropped, so the command gets SIGPIPE just as +/// it would without ck. fn collect(mut pipe: impl Read) -> Stdout { let mut buffer = Vec::new(); let mut chunk = vec![0u8; 64 * 1024]; @@ -175,9 +205,9 @@ fn collect(mut pipe: impl Read) -> Stdout { }; if buffer.len() + n > OUTPUT_CAP { warn("the command's output passed 20 MiB, so this run is not cached"); - emit(&buffer); - emit(&chunk[..n]); - stream(pipe, &mut chunk); + if emit(&buffer) && emit(&chunk[..n]) { + stream(pipe, &mut chunk); + } return Stdout::Written; } buffer.extend_from_slice(&chunk[..n]); @@ -186,11 +216,14 @@ fn collect(mut pipe: impl Read) -> Stdout { fn stream(mut pipe: impl Read, chunk: &mut [u8]) { loop { - match pipe.read(chunk) { + let n = match pipe.read(chunk) { Ok(0) => return, - Ok(n) => emit(&chunk[..n]), - Err(e) if e.kind() == ErrorKind::Interrupted => {} + Ok(n) => n, + Err(e) if e.kind() == ErrorKind::Interrupted => continue, Err(e) => return warn(&format!("cannot read the command's output: {e}")), + }; + if !emit(&chunk[..n]) { + return; } } } @@ -204,6 +237,23 @@ fn forward(pid: Pid, sig: i32) { let _ = rustix::process::kill_process(pid, signal); } +/// Whether `sig` is ignored, as inherited from ck's parent. +/// +/// No dependency offers a safe query of a signal's disposition, so this is +/// ck's one `unsafe` block. +#[allow(unsafe_code)] +fn inherited_ignored(sig: i32) -> bool { + // SAFETY: an all-zero `sigaction` is a valid value of the C struct, and + // with a null new action `sigaction` only writes the current disposition + // into `old`; it changes nothing. + let (rc, old) = unsafe { + let mut old: libc::sigaction = std::mem::zeroed(); + let rc = libc::sigaction(sig, std::ptr::null(), &mut old); + (rc, old) + }; + rc == 0 && old.sa_sigaction == libc::SIG_IGN +} + fn pid_of(id: u32) -> Pid { i32::try_from(id) .ok() diff --git a/src/cli.rs b/src/cli.rs index 04a84a5..2338b5e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -133,8 +133,8 @@ fn parse_run(mut args: impl Iterator) -> Result Result { let bad = || format!("{flag}: {raw:?} is not a duration such as 90s, 15m, 12h or 7d"); let text = raw.to_str().ok_or_else(bad)?; - let split = text.len().checked_sub(1).ok_or_else(bad)?; - let (digits, unit) = text.split_at(split); + let unit_len = text.chars().last().map_or(0, char::len_utf8); + let (digits, unit) = text.split_at(text.len() - unit_len); if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { return Err(bad()); } @@ -196,6 +196,8 @@ mod tests { " 1s", "1w", "99999999999999999999d", + "5é", + "é", ] { assert!(duration(&bad.into(), "--stale").is_err(), "{bad}"); } diff --git a/src/keys.rs b/src/keys.rs index 75e7772..7e2ac4d 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -2,7 +2,7 @@ use std::ffi::OsString; use std::fs; -use std::io::{ErrorKind, Write}; +use std::io::{ErrorKind, Read, Write}; use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt}; use std::path::Path; @@ -53,18 +53,32 @@ fn decode_master_key(hex_key: &OsString) -> Result, Fatal> { /// read a half-written one, and a crash leaves either no key or a whole key. pub fn file_key(config_dir: &Path) -> Result, Fatal> { let path = config_dir.join("file.key"); - match fs::symlink_metadata(&path) { - Err(e) if e.kind() == ErrorKind::NotFound => create_file_key(config_dir, &path)?, - Err(e) => return Err(Fatal(format!("cannot read {}: {e}", path.display()))), - Ok(_) => {} - } let unusable = |why: &str| { Fatal(format!( "{} {why}. Delete it and ck creates a new one; every entry cached under the old key becomes a miss", path.display() )) }; - let meta = fs::symlink_metadata(&path).map_err(|e| unusable(&e.to_string()))?; + // O_NOFOLLOW, then fstat and read the same descriptor: the file whose + // owner and mode are checked is the file whose key is used. + let open = || { + fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(&path) + }; + let mut file = match open() { + Err(e) if e.kind() == ErrorKind::NotFound => { + create_file_key(config_dir, &path)?; + open() + } + other => other, + } + .map_err(|e| match e.raw_os_error() { + Some(libc::ELOOP) => unusable("is a symbolic link"), + _ => unusable(&format!("cannot be read ({e})")), + })?; + let meta = file.metadata().map_err(|e| unusable(&e.to_string()))?; if !meta.is_file() { return Err(unusable("is not a regular file")); } @@ -74,11 +88,13 @@ pub fn file_key(config_dir: &Path) -> Result, Fatal> { if meta.mode() & 0o777 != 0o600 { return Err(unusable("must have mode 0600")); } - let text = fs::read(&path).map_err(|e| unusable(&e.to_string()))?; - if text.len() != 64 || !text.iter().all(u8::is_ascii_hexdigit) { - return Err(unusable("is not 64 hex characters")); + let mut text = Vec::new(); + file.read_to_end(&mut text) + .map_err(|e| unusable(&e.to_string()))?; + match hex::decode(&text) { + Ok(key) if key.len() == 32 => Ok(key), + _ => Err(unusable("is not 64 hex characters")), } - hex::decode(text).map_err(|_| unusable("is not 64 hex characters")) } fn create_file_key(dir: &Path, path: &Path) -> Result<(), Fatal> { @@ -166,9 +182,10 @@ pub fn marker_key(value_key: &str) -> String { format!("{value_key}:neg") } -/// The 64-hex hash part of a value key, used to name its fill lock. +/// The 64-hex hash part of a key from [`KeyHasher::value_key`], used to name +/// its fill lock. pub fn key_hash(value_key: &str) -> &str { - value_key.strip_prefix(KEY_PREFIX).unwrap_or(value_key) + &value_key[KEY_PREFIX.len()..] } #[cfg(test)] diff --git a/src/lib.rs b/src/lib.rs index f35d0bf..464fb74 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,8 +3,6 @@ //! This library exists to serve the `ck` binary and its tests. It is not a //! stable API. -#![forbid(unsafe_code)] - use std::ffi::OsString; use std::io::{ErrorKind, Write}; @@ -27,17 +25,17 @@ pub fn main(args: impl IntoIterator) -> i32 { Ok(i) => i, Err(e) => { warn(&e); - eprintln!("{}", cli::USAGE); + let _ = writeln!(std::io::stderr(), "{}", cli::USAGE); return EXIT_CK_ERROR; } }; match invocation { cli::Invocation::Help => { - println!("{}", cli::USAGE); + emit(format!("{}\n", cli::USAGE).as_bytes()); 0 } cli::Invocation::Version => { - println!("ck {}", env!("CARGO_PKG_VERSION")); + emit(format!("ck {}\n", env!("CARGO_PKG_VERSION")).as_bytes()); 0 } cli::Invocation::Run(args) => run::run(&args).unwrap_or_else(|Fatal(e)| { @@ -48,17 +46,23 @@ pub fn main(args: impl IntoIterator) -> i32 { } /// One line on stderr, prefixed so it is never mistaken for the command's. +/// A closed stderr is not worth dying over, so a failed write is dropped. pub(crate) fn warn(message: &str) { - eprintln!("ck: {message}"); + let _ = writeln!(std::io::stderr(), "ck: {message}"); } -/// Write to stdout. A reader that has gone away (EPIPE) is normal in a -/// pipeline and stays silent; any other failure is reported. -pub(crate) fn emit(bytes: &[u8]) { +/// Write to stdout, and say whether it worked. A reader that has gone away +/// (EPIPE) is normal in a pipeline and stays silent; any other failure is +/// reported. +pub(crate) fn emit(bytes: &[u8]) -> bool { let mut out = std::io::stdout().lock(); - if let Err(e) = out.write_all(bytes).and_then(|()| out.flush()) { - if e.kind() != ErrorKind::BrokenPipe { - warn(&format!("cannot write stdout: {e}")); + match out.write_all(bytes).and_then(|()| out.flush()) { + Ok(()) => true, + Err(e) => { + if e.kind() != ErrorKind::BrokenPipe { + warn(&format!("cannot write stdout: {e}")); + } + false } } } diff --git a/src/run.rs b/src/run.rs index 12c90d8..a15c274 100644 --- a/src/run.rs +++ b/src/run.rs @@ -6,6 +6,7 @@ use std::fs; use std::io::IsTerminal; +use std::ops::ControlFlow; use std::os::fd::{AsFd, OwnedFd}; use std::os::unix::fs::{DirBuilderExt, FileTypeExt, MetadataExt}; use std::path::{Path, PathBuf}; @@ -25,7 +26,7 @@ pub fn run(args: &RunArgs) -> Result { warn(&format!( "stdin is {kind}, so this call is not cached. End the command with `< /dev/null` to cache it" )); - return uncached(&args.command); + return Ok(run_uncached(&Supervisor::install()?, &args.command)); } let home = std::env::var_os("HOME") @@ -68,15 +69,10 @@ impl Call<'_> { fn cached(&self) -> Result { let value = match self.store.value(&self.key) { Ok(v) => v, - Err(e) => return self.read_failed(e), + Err(e) => return self.read_failed(e, &Supervisor::install()?), }; - let stale = match value.map(|v| self.judge(v)) { - Some(Judged::Fresh(stdout)) => { - emit(&stdout); - return Ok(0); - } - Some(Judged::Stale(s)) => Some(s), - Some(Judged::Expired) | None => None, + let ControlFlow::Continue(stale) = self.fresh_or_stale(value) else { + return Ok(0); }; let supervisor = Supervisor::install()?; @@ -106,15 +102,10 @@ impl Call<'_> { self.store.marker(&self.marker_key), ) { (Ok(v), Ok(m)) => (v, m), - (Err(e), _) | (_, Err(e)) => return self.read_failed_with(e, &supervisor), + (Err(e), _) | (_, Err(e)) => return self.read_failed(e, &supervisor), }; - let stale = match value.map(|v| self.judge(v)) { - Some(Judged::Fresh(stdout)) => { - emit(&stdout); - return Ok(0); - } - Some(Judged::Stale(s)) => Some(s), - Some(Judged::Expired) | None => None, + let ControlFlow::Continue(stale) = self.fresh_or_stale(value) else { + return Ok(0); }; let now = now_ms(); if let Some(m) = marker.filter(|m| m.is_active(now)) { @@ -129,25 +120,22 @@ impl Call<'_> { return Ok(m.exit); } - let code = self.record(&supervisor, stale, marker, false); + let code = self.record(&supervisor, stale, marker); drop(lock); Ok(code) } - /// `--refresh`: run now, ignoring the value, the marker and the lock. - /// Never serves stale. + /// `--refresh`: run now, whatever the value, the marker and the lock say. + /// Never serves stale. The marker is read only to continue its count. fn refresh(&self) -> Result { let supervisor = Supervisor::install()?; - Ok(self.record(&supervisor, None, None, true)) + let marker = self.store.marker(&self.marker_key).ok().flatten(); + Ok(self.record(&supervisor, None, marker)) } - fn record( - &self, - supervisor: &Supervisor, - stale: Option, - marker: Option, - refresh: bool, - ) -> i32 { + /// Run the command and record the outcome: the value on success, the + /// bumped marker on failure. `marker` is the current one, if any. + fn record(&self, supervisor: &Supervisor, stale: Option, marker: Option) -> i32 { let (code, stdout) = match supervisor.run(&self.args.command, true) { Ran::SpawnFailed { code } => { // The command never started, so the origin was not reached: @@ -157,41 +145,35 @@ impl Call<'_> { None => code, }; } + Ran::Interrupted { signal, stdout } => { + if let Stdout::Captured(out) = &stdout { + emit(out); + } + return 128 + signal; + } Ran::Exited { code, stdout } => (code, stdout), }; - if let Some(sig) = supervisor.received() { - if let Stdout::Captured(out) = &stdout { - emit(out); - } - return 128 + sig; - } let now = now_ms(); if code == 0 { - let Stdout::Captured(out) = stdout else { - return 0; - }; - // Print before the write-back, so a pipeline sees output at once. - emit(&out); - let ttl = Duration::from_secs(self.args.ttl_secs + self.args.stale_secs); - let stored = self - .store - .set(&self.key, &Envelope::encode(now, &out), ttl) - .and_then(|()| self.store.delete(&self.marker_key)); - if let Err(e) = stored { - warn(&format!( - "the command succeeded but its output was not cached: {e}" - )); + if let Stdout::Captured(out) = &stdout { + // Print before the write-back, so a pipeline sees output at once. + emit(out); + let ttl = Duration::from_secs(self.args.ttl_secs + self.args.stale_secs); + if let Err(e) = self.store.set(&self.key, &Envelope::encode(now, out), ttl) { + warn(&format!( + "the command succeeded but its output was not cached: {e}" + )); + } + } + // A success ends the backoff, whether or not its output was stored. + if let Err(e) = self.store.delete(&self.marker_key) { + warn(&format!("cannot clear the recorded failure: {e}")); } return 0; } - let previous = match marker { - Some(m) => Some(m), - None if refresh => self.store.marker(&self.marker_key).ok().flatten(), - None => None, - }; - let next = Marker::bumped(previous.as_ref(), code, now); + let next = Marker::bumped(marker.as_ref(), code, now); if let Err(e) = self .store .set(&self.marker_key, &next.encode(), next.backend_ttl(now)) @@ -208,11 +190,19 @@ impl Call<'_> { } } - fn judge(&self, envelope: Envelope) -> Judged { + /// Print a fresh value and break, or continue with the stale run (if + /// `--stale` still allows one) for the caller to fall back on. + fn fresh_or_stale(&self, value: Option) -> ControlFlow<(), Option> { + let Some(envelope) = value else { + return ControlFlow::Continue(None); + }; match envelope.freshness(now_ms(), self.args.ttl_secs, self.args.stale_secs) { - Freshness::Fresh => Judged::Fresh(envelope.stdout), - Freshness::Stale { age_ms } => Judged::Stale(Stale { envelope, age_ms }), - Freshness::Expired => Judged::Expired, + Freshness::Fresh => { + emit(&envelope.stdout); + ControlFlow::Break(()) + } + Freshness::Stale { age_ms } => ControlFlow::Continue(Some(Stale { envelope, age_ms })), + Freshness::Expired => ControlFlow::Continue(None), } } @@ -237,14 +227,9 @@ impl Call<'_> { 0 } - fn read_failed(&self, e: ReadError) -> Result { - let supervisor = Supervisor::install()?; - self.read_failed_with(e, &supervisor) - } - /// Any backend error before the run disables caching for this call, never /// the command. No further backend calls are made. - fn read_failed_with(&self, e: ReadError, supervisor: &Supervisor) -> Result { + fn read_failed(&self, e: ReadError, supervisor: &Supervisor) -> Result { match e { ReadError::Local(fatal) => Err(fatal), ReadError::Backend(e) => { @@ -255,20 +240,10 @@ impl Call<'_> { } } -enum Judged { - Fresh(Vec), - Stale(Stale), - Expired, -} - -fn uncached(argv: &[std::ffi::OsString]) -> Result { - Ok(run_uncached(&Supervisor::install()?, argv)) -} - fn run_uncached(supervisor: &Supervisor, argv: &[std::ffi::OsString]) -> i32 { match supervisor.run(argv, false) { - Ran::SpawnFailed { code } => code, - Ran::Exited { code, .. } => supervisor.received().map_or(code, |sig| 128 + sig), + Ran::SpawnFailed { code } | Ran::Exited { code, .. } => code, + Ran::Interrupted { signal, .. } => 128 + signal, } } @@ -290,7 +265,11 @@ fn uncacheable_stdin() -> Option<&'static str> { }; let kind = meta.file_type(); if kind.is_char_device() { - let null = fs::metadata("/dev/null").ok()?; + let Ok(null) = fs::metadata("/dev/null") else { + // Without /dev/null to compare against, nothing proves stdin + // is empty. + return Some("not inspectable"); + }; if meta.rdev() == null.rdev() { return None; } diff --git a/tests/readme.rs b/tests/readme.rs index c6a7f75..caeb182 100644 --- a/tests/readme.rs +++ b/tests/readme.rs @@ -27,10 +27,13 @@ fn readme_examples_run() { let ck_dir = Path::new(BIN).parent().unwrap(); for example in examples { - assert!( - example.contains("< /dev/null"), - "an example without the redirect:\n{example}" - ); + for call in example.split("ck run").skip(1) { + let call = call.split([')', '\n']).next().unwrap_or_default(); + assert!( + call.trim_end().ends_with("< /dev/null"), + "a ck run call without the redirect:\n{example}" + ); + } let path = std::env::join_paths( [s.bin().as_path(), ck_dir] .into_iter() diff --git a/tests/run.rs b/tests/run.rs index 6299ed9..5511eae 100644 --- a/tests/run.rs +++ b/tests/run.rs @@ -5,7 +5,7 @@ mod common; use std::fs; use std::os::unix::fs::PermissionsExt; use std::os::unix::net::UnixStream; -use std::process::Stdio; +use std::process::{Command, Stdio}; use std::thread::sleep; use std::time::{Duration, Instant}; @@ -386,18 +386,67 @@ fn bad_flags_exit_125_before_anything_runs() { #[test] fn refresh_runs_and_never_serves_stale() { let s = Sandbox::new(); - s.run(&["--ttl", "1h", "--stale", "1h"]); + let flags = ["--ttl", "1s", "--stale", "1h"]; + s.run(&flags); + sleep(PAST_TTL); + // The stored run is now servable as stale; --refresh must not serve it. s.set("out", "new\n"); - assert_eq!(stdout(&s.run(&["--ttl", "1h", "--refresh"])), "new\n"); - assert_eq!(stdout(&s.run(&["--ttl", "1h"])), "new\n"); + assert_eq!( + stdout(&s.run(&["--ttl", "1s", "--stale", "1h", "--refresh"])), + "new\n" + ); + sleep(PAST_TTL); s.set("exit", "4"); s.set("out", "failure output\n"); - let out = s.run(&["--ttl", "1h", "--stale", "1h", "--refresh"]); + let out = s.run(&["--ttl", "1s", "--stale", "1h", "--refresh"]); assert_eq!((code(&out), stdout(&out)), (4, "failure output\n".into())); assert_eq!(s.runs(), 3); } +#[test] +fn a_success_clears_the_marker_even_when_not_stored() { + let s = Sandbox::new(); + s.set("exit", "1"); + assert_eq!(code(&s.run(&[])), 1); + // A success too large to store must still end the backoff. + s.set("exit", "0"); + s.set("out", &"x".repeat(21 * 1024 * 1024)); + assert_eq!(code(&s.run(&["--refresh"])), 0); + s.set("out", "small\n"); + let out = s.run(&[]); + assert_eq!( + (code(&out), stdout(&out)), + (0, "small\n".into()), + "{}", + stderr(&out) + ); + assert_eq!(s.runs(), 3); +} + +#[test] +fn a_corrupt_entry_is_a_miss_and_never_served() { + let s = Sandbox::new(); + s.run(&["--ttl", "1h", "--stale", "1h"]); + for name in entries(&s.data_dir()) { + let path = s.data_dir().join(name); + let mut bytes = fs::read(&path).unwrap(); + // Past the 14-byte file header: flip a ciphertext byte. + let last = bytes.len() - 1; + bytes[last] ^= 0xff; + fs::write(&path, bytes).unwrap(); + } + s.set("out", "rerun\n"); + let out = s.run(&["--ttl", "1h", "--stale", "1h"]); + assert_eq!( + (code(&out), stdout(&out)), + (0, "rerun\n".into()), + "{}", + stderr(&out) + ); + assert_eq!(s.runs(), 2); +} + #[test] fn output_past_20_mib_is_streamed_and_not_cached() { let s = Sandbox::new(); @@ -492,3 +541,92 @@ fn version_and_usage() { assert!(stdout(&out).starts_with("ck ")); assert_eq!(code(&s.ck(&[]).output().unwrap()), 125); } + +#[test] +fn a_process_group_interrupt_stores_nothing() { + use std::os::unix::process::CommandExt; + // ck and the command both get the signal, as from Ctrl-C at a terminal. + // ck must never read the dead child as an ordinary failure: that would + // serve stale with exit 0 and set a marker that suppresses later calls. + let s = Sandbox::new(); + let flags = ["run", "--ttl", "1s", "--stale", "1h", "--", "origin"]; + assert_eq!(code(&s.ck(&flags).output().unwrap()), 0); + sleep(PAST_TTL); + s.set("sleep", "5"); + for round in 0..40 { + let child = s.ck(&flags).process_group(0).spawn().unwrap(); + sleep(Duration::from_millis(100)); + let group = rustix::process::Pid::from_raw(child.id() as i32).unwrap(); + rustix::process::kill_process_group(group, rustix::process::Signal::INT).unwrap(); + let out = child.wait_with_output().unwrap(); + assert_eq!(code(&out), 128 + 2, "round {round}: {}", stderr(&out)); + } + assert_eq!(entries(&s.data_dir()).len(), 1, "a marker was stored"); +} + +#[test] +fn a_signal_while_writing_output_stops_ck() { + let s = Sandbox::new(); + // More than a pipe buffer, into a stdout nobody reads: ck blocks writing. + let child = s + .ck(&["run", "--", "head", "-c", "1000000", "/dev/zero"]) + .spawn() + .unwrap(); + sleep(Duration::from_millis(500)); + signal(&child, rustix::process::Signal::TERM); + let out = child.wait_with_output().unwrap(); + assert_eq!(code(&out), 128 + 15); + assert!(entries(&s.data_dir()).is_empty(), "the run was cached"); +} + +#[test] +fn a_closed_reader_ends_a_streamed_command() { + use std::io::Read; + let s = Sandbox::new(); + let mut child = s.ck(&["run", "--", "yes"]).spawn().unwrap(); + let mut head = [0u8; 10]; + child.stdout.take().unwrap().read_exact(&mut head).unwrap(); + // The read end is dropped here, as `| head -c 10` would. + let started = Instant::now(); + while child.try_wait().unwrap().is_none() { + assert!( + started.elapsed() < Duration::from_secs(10), + "ck kept draining a closed pipe" + ); + sleep(Duration::from_millis(50)); + } +} + +#[test] +fn a_signal_ignored_by_the_caller_stays_ignored() { + let s = Sandbox::new(); + s.set("sleep", "0.6"); + // Borrow the sandbox's environment (HOME, PATH, no CACHEKIT_*). + let sandbox = s.ck(&[]); + // nohup ignores SIGHUP, then execs ck in the same process. + let ck = std::path::Path::new(common::BIN); + let mut nohup = Command::new("nohup"); + nohup.arg(ck).args(["run", "--", "origin"]); + for (k, v) in sandbox.get_envs() { + match v { + Some(v) => nohup.env(k, v), + None => nohup.env_remove(k), + }; + } + let child = nohup + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + sleep(Duration::from_millis(250)); + signal(&child, rustix::process::Signal::HUP); + let out = child.wait_with_output().unwrap(); + assert_eq!( + (code(&out), stdout(&out)), + (0, "hello\n".into()), + "{}", + stderr(&out) + ); + assert_eq!(entries(&s.data_dir()).len(), 1, "the run was not cached"); +} From c126a69109e8cf52f7ba9778b9a341c310a09cc4 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Fri, 2 Oct 2026 20:09:09 +1000 Subject: [PATCH 03/16] fix(run): survive an inherited ignored SIGCHLD (LAB-5118) With SIGCHLD ignored, the kernel reaps the child itself and its exit status is lost, so a successful run read as exit 126 and set a failure marker. - When SIGCHLD is inherited as ignored, ck handles it so the status can be collected, and sets it back to ignored in the child before exec. - An exit status that cannot be collected is no evidence the origin failed: no marker, stale output if servable, otherwise exit 126 with a warning. - A waitid error other than EINTR is no longer swallowed. --- Cargo.toml | 5 ++-- src/child.rs | 70 +++++++++++++++++++++++++++++++++++++++++++--------- src/run.rs | 12 +++++++++ tests/run.rs | 45 +++++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 14 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7b04a0e..4b054f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,8 +15,9 @@ name = "ck" path = "src/main.rs" [lints.rust] -# deny, not forbid: one audited block in src/child.rs reads a signal's -# inherited disposition, which no dependency exposes safely. +# deny, not forbid: two audited blocks in src/child.rs read a signal's +# inherited disposition and restore an ignored SIGCHLD in the child, which +# no dependency does safely. unsafe_code = "deny" [dependencies] diff --git a/src/child.rs b/src/child.rs index d11a7c0..e0b5538 100644 --- a/src/child.rs +++ b/src/child.rs @@ -5,11 +5,11 @@ use std::io::{ErrorKind, Read}; use std::os::unix::ffi::OsStrExt; use std::os::unix::process::ExitStatusExt; use std::process::{Command, Stdio}; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; use rustix::process::{Pid, Signal, WaitId, WaitIdOptions}; -use signal_hook::consts::{SIGHUP, SIGINT, SIGQUIT, SIGTERM}; +use signal_hook::consts::{SIGCHLD, SIGHUP, SIGINT, SIGQUIT, SIGTERM}; use signal_hook::iterator::Signals; use crate::{emit, warn, Fatal}; @@ -30,6 +30,12 @@ pub enum Ran { code: i32, stdout: Stdout, }, + /// The command ran, but its exit status could not be collected. That is + /// no evidence the origin failed, so it is treated like a spawn failure: + /// no marker. + StatusUnknown { + stdout: Stdout, + }, /// ck received one of [`HANDLED`], or the command died of one. Nothing /// is stored: the caller prints any captured output and exits 128 + n. Interrupted { @@ -70,6 +76,10 @@ pub struct Supervisor { /// Set inside the signal handler itself, so it is visible before the /// main thread can reap a child killed by the same process-group signal. received: Arc, + /// SIGCHLD was inherited as ignored. ck handles it itself, or the kernel + /// would reap the child before ck could read its exit status, and gives + /// the child the ignored disposition back. + sigchld_ignored: bool, } impl Supervisor { @@ -85,6 +95,10 @@ impl Supervisor { .unwrap_or_else(|_| unreachable!("signal numbers are positive")); signal_hook::flag::register_usize(sig, Arc::clone(&received), value).map_err(fail)?; } + let sigchld_ignored = inherited_ignored(SIGCHLD); + if sigchld_ignored { + signal_hook::flag::register(SIGCHLD, Arc::new(AtomicBool::new(false))).map_err(fail)?; + } let mut signals = Signals::new(&handled).map_err(fail)?; let state = Arc::new(Mutex::new(State::Idle)); let thread_state = Arc::clone(&state); @@ -100,7 +114,11 @@ impl Supervisor { } }) .map_err(|e| Fatal(format!("cannot start the signal thread: {e}")))?; - Ok(Self { state, received }) + Ok(Self { + state, + received, + sigchld_ignored, + }) } /// The latest handled signal ck received, if any. @@ -126,6 +144,9 @@ impl Supervisor { if capture { command.stdout(Stdio::piped()); } + if self.sigchld_ignored { + ignore_sigchld_after_fork(&mut command); + } let mut child = { // Held across the spawn so a signal cannot fall between "no @@ -160,10 +181,16 @@ impl Supervisor { // signal forwarded in between reaches a zombie, never a recycled pid. let pid = pid_of(child.id()); let exited = WaitIdOptions::EXITED | WaitIdOptions::NOWAIT; - while let Err(rustix::io::Errno::INTR) = rustix::process::waitid(WaitId::Pid(pid), exited) { - } + let waited = loop { + match rustix::process::waitid(WaitId::Pid(pid), exited) { + Err(rustix::io::Errno::INTR) => {} + other => break other, + } + }; *self.state.lock().unwrap_or_else(PoisonError::into_inner) = State::Idle; - let status = child.wait(); + let status = waited + .map_err(std::io::Error::from) + .and_then(|_| child.wait()); let died_of = status .as_ref() .ok() @@ -172,15 +199,16 @@ impl Supervisor { if let Some(signal) = self.received().or(died_of) { return Ran::Interrupted { signal, stdout }; } - let code = match status { - Ok(status) => status - .code() - .unwrap_or_else(|| 128 + status.signal().unwrap_or(0)), + let status = match status { + Ok(status) => status, Err(e) => { warn(&format!("cannot collect the command's exit status: {e}")); - 126 + return Ran::StatusUnknown { stdout }; } }; + let code = status + .code() + .unwrap_or_else(|| 128 + status.signal().unwrap_or(0)); Ran::Exited { code, stdout } } } @@ -240,7 +268,7 @@ fn forward(pid: Pid, sig: i32) { /// Whether `sig` is ignored, as inherited from ck's parent. /// /// No dependency offers a safe query of a signal's disposition, so this is -/// ck's one `unsafe` block. +/// one of ck's two `unsafe` blocks. #[allow(unsafe_code)] fn inherited_ignored(sig: i32) -> bool { // SAFETY: an all-zero `sigaction` is a valid value of the C struct, and @@ -254,6 +282,24 @@ fn inherited_ignored(sig: i32) -> bool { rc == 0 && old.sa_sigaction == libc::SIG_IGN } +/// Set SIGCHLD back to ignored in the child, between fork and exec, so the +/// command inherits the disposition it would have had without ck. +#[allow(unsafe_code)] +fn ignore_sigchld_after_fork(command: &mut Command) { + use std::os::unix::process::CommandExt; + // SAFETY: the closure runs in the forked child before exec and calls + // only `signal`, which is async-signal-safe, allocating nothing and + // taking no lock. + unsafe { + command.pre_exec(|| { + if libc::signal(libc::SIGCHLD, libc::SIG_IGN) == libc::SIG_ERR { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } +} + fn pid_of(id: u32) -> Pid { i32::try_from(id) .ok() diff --git a/src/run.rs b/src/run.rs index a15c274..f8fb46d 100644 --- a/src/run.rs +++ b/src/run.rs @@ -151,6 +151,17 @@ impl Call<'_> { } return 128 + signal; } + Ran::StatusUnknown { stdout } => { + return match (stale, stdout) { + (Some(stale), Stdout::Captured(_)) => self.serve_stale(&stale, None, None), + (_, stdout) => { + if let Stdout::Captured(out) = &stdout { + emit(out); + } + 126 + } + }; + } Ran::Exited { code, stdout } => (code, stdout), }; @@ -243,6 +254,7 @@ impl Call<'_> { fn run_uncached(supervisor: &Supervisor, argv: &[std::ffi::OsString]) -> i32 { match supervisor.run(argv, false) { Ran::SpawnFailed { code } | Ran::Exited { code, .. } => code, + Ran::StatusUnknown { .. } => 126, Ran::Interrupted { signal, .. } => 128 + signal, } } diff --git a/tests/run.rs b/tests/run.rs index 5511eae..1f0a3a0 100644 --- a/tests/run.rs +++ b/tests/run.rs @@ -630,3 +630,48 @@ fn a_signal_ignored_by_the_caller_stays_ignored() { ); assert_eq!(entries(&s.data_dir()).len(), 1, "the run was not cached"); } + +/// `ck ` started with SIGCHLD ignored, as a parent can leave it. +/// Shells cannot do this (dash's `trap '' CHLD` keeps the default), so the +/// test sets the disposition itself between fork and exec. +#[allow(unsafe_code)] +fn with_sigchld_ignored(mut cmd: Command) -> std::process::Output { + use std::os::unix::process::CommandExt; + // SAFETY: only the async-signal-safe `signal` runs between fork and exec. + unsafe { + cmd.pre_exec(|| { + libc::signal(libc::SIGCHLD, libc::SIG_IGN); + Ok(()) + }); + } + cmd.output().unwrap() +} + +#[test] +fn an_inherited_ignored_sigchld_does_not_fake_a_failure() { + let s = Sandbox::new(); + for call in 1..=2 { + let out = with_sigchld_ignored(s.ck(&["run", "--", "origin"])); + assert_eq!( + (code(&out), stdout(&out)), + (0, "hello\n".into()), + "call {call}: {}", + stderr(&out) + ); + } + assert_eq!(s.runs(), 1, "the first run was not cached"); + + // The command itself still inherits SIGCHLD ignored. + #[cfg(target_os = "linux")] + { + let out = with_sigchld_ignored(s.ck(&["run", "--", "grep", "SigIgn", "/proc/self/status"])); + let mask = stdout(&out); + let bits = + u64::from_str_radix(mask.trim().trim_start_matches("SigIgn:").trim(), 16).unwrap(); + assert_ne!( + bits & (1 << (libc::SIGCHLD - 1)), + 0, + "SIGCHLD is not ignored in the command: {mask}" + ); + } +} From 11e0d481e8f4ac64e032ed18e492e7e1066f963b Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Fri, 2 Oct 2026 20:27:29 +1000 Subject: [PATCH 04/16] fix(run): decide interruption from ck's own signals only (LAB-5118) - A run is interrupted only when ck itself received the signal. A command that dies of a signal on its own (`kill -INT $$`, `pkill op`) is an ordinary failure, so a stale value is still served. - ck's helper threads (the signal thread and tokio's blocking pool) block the handled signals, so their handlers always run on the main thread and the flag is set before the main thread reads the child's status. - When ck's reader goes away mid-stream, the command's SIGPIPE death sets no failure marker. - A signal that arrives while only a background job holds the command's stdout ends ck at once. - file.key is opened O_NONBLOCK | O_NOCTTY, so a FIFO there exits 125 instead of hanging. --- Cargo.toml | 6 +-- src/child.rs | 82 ++++++++++++++++++++++++++------- src/keys.rs | 4 +- src/run.rs | 18 ++++---- src/store.rs | 1 + tests/common/mod.rs | 4 +- tests/run.rs | 107 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 191 insertions(+), 31 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4b054f2..7515eeb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,9 +15,9 @@ name = "ck" path = "src/main.rs" [lints.rust] -# deny, not forbid: two audited blocks in src/child.rs read a signal's -# inherited disposition and restore an ignored SIGCHLD in the child, which -# no dependency does safely. +# deny, not forbid: three audited blocks in src/child.rs read a signal's +# inherited disposition, restore an ignored SIGCHLD in the child, and block +# ck's handled signals on its helper threads. No dependency does these safely. unsafe_code = "deny" [dependencies] diff --git a/src/child.rs b/src/child.rs index e0b5538..9464aa0 100644 --- a/src/child.rs +++ b/src/child.rs @@ -36,8 +36,8 @@ pub enum Ran { StatusUnknown { stdout: Stdout, }, - /// ck received one of [`HANDLED`], or the command died of one. Nothing - /// is stored: the caller prints any captured output and exits 128 + n. + /// ck received one of [`HANDLED`]. Nothing is stored: the caller prints + /// any captured output and exits 128 + n. Interrupted { signal: i32, stdout: Stdout, @@ -49,6 +49,19 @@ pub enum Stdout { Captured(Vec), /// Already written to ck's stdout. Written, + /// Being streamed when ck's reader went away. The pipe was dropped, so + /// the command most likely died of SIGPIPE, which says nothing about + /// the origin. + ReaderGone, +} + +impl Stdout { + /// Print output that was held back; anything else is already out. + pub fn emit_captured(&self) { + if let Self::Captured(out) = self { + emit(out); + } + } } enum State { @@ -105,11 +118,23 @@ impl Supervisor { std::thread::Builder::new() .name("signals".into()) .spawn(move || { + block_handled_signals(); for sig in signals.forever() { let state = thread_state.lock().unwrap_or_else(PoisonError::into_inner); match *state { State::Idle => std::process::exit(128 + sig), - State::Running(pid) => forward(pid, sig), + State::Running(pid) => { + forward(pid, sig); + // A command that has exited while a background + // job still holds its stdout is only a zombie: + // nothing is left to wait for. + let exited = WaitIdOptions::EXITED + | WaitIdOptions::NOWAIT + | WaitIdOptions::NOHANG; + if let Ok(Some(_)) = rustix::process::waitid(WaitId::Pid(pid), exited) { + std::process::exit(128 + sig); + } + } } } }) @@ -191,12 +216,10 @@ impl Supervisor { let status = waited .map_err(std::io::Error::from) .and_then(|_| child.wait()); - let died_of = status - .as_ref() - .ok() - .and_then(|s| s.signal()) - .filter(|s| HANDLED.contains(s)); - if let Some(signal) = self.received().or(died_of) { + // Only a signal ck itself received is an interruption. A command that + // dies of a signal on its own (`kill -INT $$`, `pkill op`) failed, + // and may be answered with stale output. + if let Some(signal) = self.received() { return Ran::Interrupted { signal, stdout }; } let status = match status { @@ -233,25 +256,30 @@ fn collect(mut pipe: impl Read) -> Stdout { }; if buffer.len() + n > OUTPUT_CAP { warn("the command's output passed 20 MiB, so this run is not cached"); - if emit(&buffer) && emit(&chunk[..n]) { - stream(pipe, &mut chunk); + if emit(&buffer) && emit(&chunk[..n]) && stream(pipe, &mut chunk) { + return Stdout::Written; } - return Stdout::Written; + return Stdout::ReaderGone; } buffer.extend_from_slice(&chunk[..n]); } } -fn stream(mut pipe: impl Read, chunk: &mut [u8]) { +/// Copy the rest of the pipe to stdout. Returns `false` if ck's own stdout +/// failed first. +fn stream(mut pipe: impl Read, chunk: &mut [u8]) -> bool { loop { let n = match pipe.read(chunk) { - Ok(0) => return, + Ok(0) => return true, Ok(n) => n, Err(e) if e.kind() == ErrorKind::Interrupted => continue, - Err(e) => return warn(&format!("cannot read the command's output: {e}")), + Err(e) => { + warn(&format!("cannot read the command's output: {e}")); + return true; + } }; if !emit(&chunk[..n]) { - return; + return false; } } } @@ -265,10 +293,30 @@ fn forward(pid: Pid, sig: i32) { let _ = rustix::process::kill_process(pid, signal); } +/// Block [`HANDLED`] on the calling thread. ck calls this on every thread it +/// starts (the signal thread and tokio's blocking pool), so the kernel always +/// runs their handlers on the main thread. A handler there finishes before +/// the main thread's `read` or `waitid` returns, so `received` is always set +/// before the main thread decides whether the run was interrupted. +#[allow(unsafe_code)] +pub(crate) fn block_handled_signals() { + // SAFETY: `sigemptyset` and `sigaddset` initialise a local set, and + // `pthread_sigmask` changes only the calling thread's mask. Its one + // failure, EINVAL, needs an invalid `how`, and SIG_BLOCK is valid. + unsafe { + let mut set: libc::sigset_t = std::mem::zeroed(); + libc::sigemptyset(&mut set); + for sig in HANDLED { + libc::sigaddset(&mut set, sig); + } + libc::pthread_sigmask(libc::SIG_BLOCK, &set, std::ptr::null_mut()); + } +} + /// Whether `sig` is ignored, as inherited from ck's parent. /// /// No dependency offers a safe query of a signal's disposition, so this is -/// one of ck's two `unsafe` blocks. +/// one of ck's three `unsafe` blocks. #[allow(unsafe_code)] fn inherited_ignored(sig: i32) -> bool { // SAFETY: an all-zero `sigaction` is a valid value of the C struct, and diff --git a/src/keys.rs b/src/keys.rs index 7e2ac4d..2a1845d 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -64,7 +64,9 @@ pub fn file_key(config_dir: &Path) -> Result, Fatal> { let open = || { fs::OpenOptions::new() .read(true) - .custom_flags(libc::O_NOFOLLOW) + // O_NONBLOCK so a FIFO planted at the path cannot hang the open; + // the fstat below rejects anything but a regular file. + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_NOCTTY) .open(&path) }; let mut file = match open() { diff --git a/src/run.rs b/src/run.rs index f8fb46d..3060d67 100644 --- a/src/run.rs +++ b/src/run.rs @@ -146,18 +146,14 @@ impl Call<'_> { }; } Ran::Interrupted { signal, stdout } => { - if let Stdout::Captured(out) = &stdout { - emit(out); - } + stdout.emit_captured(); return 128 + signal; } Ran::StatusUnknown { stdout } => { return match (stale, stdout) { (Some(stale), Stdout::Captured(_)) => self.serve_stale(&stale, None, None), (_, stdout) => { - if let Stdout::Captured(out) = &stdout { - emit(out); - } + stdout.emit_captured(); 126 } }; @@ -184,6 +180,11 @@ impl Call<'_> { return 0; } + if matches!(stdout, Stdout::ReaderGone) { + // ck's reader left mid-stream and the command most likely died of + // SIGPIPE: no evidence about the origin, so no marker. + return code; + } let next = Marker::bumped(marker.as_ref(), code, now); if let Err(e) = self .store @@ -193,11 +194,10 @@ impl Call<'_> { } match (stdout, stale) { (Stdout::Captured(_), Some(stale)) => self.serve_stale(&stale, Some(&next), Some(code)), - (Stdout::Captured(out), None) => { - emit(&out); + (stdout, _) => { + stdout.emit_captured(); code } - (Stdout::Written, _) => code, } } diff --git a/src/store.rs b/src/store.rs index cfa96b1..e7b7bcc 100644 --- a/src/store.rs +++ b/src/store.rs @@ -42,6 +42,7 @@ impl Store { // The file backend runs its I/O on tokio's blocking pool; a // current-thread runtime is the cheapest one that provides it. let runtime = tokio::runtime::Builder::new_current_thread() + .on_thread_start(crate::child::block_handled_signals) .build() .map_err(|e| Fatal(format!("cannot start the async runtime: {e}")))?; Ok(Self { diff --git a/tests/common/mod.rs b/tests/common/mod.rs index d09a022..d6e4a35 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -43,13 +43,15 @@ impl Sandbox { /// A command on PATH that records each call, then prints the contents of /// `out` (default `hello`) and exits with the code in `exit` (default 0). - /// `sleep` and `env` files make it sleep first or dump its environment. + /// `sleep` and `env` files make it sleep first or dump its environment; + /// `selfkill` (holding a signal name) makes it send that signal to itself. pub fn install_shim(&self, name: &str) { let r = self.root.path().display(); let script = format!( "#!/bin/sh\n\ echo run >> '{r}/calls'\n\ [ -f '{r}/sleep' ] && sleep \"$(cat '{r}/sleep')\"\n\ + [ -f '{r}/selfkill' ] && kill -\"$(cat '{r}/selfkill')\" $$\n\ [ -f '{r}/env' ] && env > '{r}/env.out'\n\ if [ -f '{r}/out' ]; then cat '{r}/out'; else echo hello; fi\n\ exit \"$(cat '{r}/exit' 2>/dev/null || echo 0)\"\n" diff --git a/tests/run.rs b/tests/run.rs index 1f0a3a0..242bc48 100644 --- a/tests/run.rs +++ b/tests/run.rs @@ -444,6 +444,13 @@ fn a_corrupt_entry_is_a_miss_and_never_served() { "{}", stderr(&out) ); + assert!( + !stderr(&out).contains("cache is unavailable"), + "{}", + stderr(&out) + ); + // A miss refills the entry; a backend error would have run uncached. + assert_eq!(stdout(&s.run(&["--ttl", "1h", "--stale", "1h"])), "rerun\n"); assert_eq!(s.runs(), 2); } @@ -595,6 +602,8 @@ fn a_closed_reader_ends_a_streamed_command() { ); sleep(Duration::from_millis(50)); } + // The command died of SIGPIPE because the reader left: not a failure. + assert!(entries(&s.data_dir()).is_empty(), "a marker was stored"); } #[test] @@ -675,3 +684,101 @@ fn an_inherited_ignored_sigchld_does_not_fake_a_failure() { ); } } + +#[test] +fn an_interrupt_the_command_traps_still_stores_nothing() { + use std::os::unix::process::CommandExt; + // terraform-style: the command traps SIGINT and exits 1 instead of dying + // of it, so only ck's own handler can tell interrupted from failed. + let s = Sandbox::new(); + let flag = s.home().join("slow"); + let script = format!( + "trap 'exit 1' INT; [ -f '{}' ] && sleep 5; echo hi", + flag.display() + ); + let args = [ + "run", "--ttl", "1s", "--stale", "1h", "--", "sh", "-c", &script, + ]; + assert_eq!(code(&s.ck(&args).output().unwrap()), 0); + sleep(PAST_TTL); + fs::write(&flag, "").unwrap(); + for round in 0..20 { + let child = s.ck(&args).process_group(0).spawn().unwrap(); + sleep(Duration::from_millis(150)); + let group = rustix::process::Pid::from_raw(child.id() as i32).unwrap(); + rustix::process::kill_process_group(group, rustix::process::Signal::INT).unwrap(); + let out = child.wait_with_output().unwrap(); + assert_eq!(code(&out), 128 + 2, "round {round}: {}", stderr(&out)); + } + assert_eq!(entries(&s.data_dir()).len(), 1, "a marker was stored"); +} + +#[test] +fn a_command_that_signals_itself_is_a_failure() { + // ck received nothing, so a command dead of its own signal failed: the + // stale value is served with exit 0 and a marker is set. + for sig in ["TERM", "INT", "HUP", "QUIT"] { + let s = Sandbox::new(); + let flags = ["--ttl", "1s", "--stale", "1h"]; + s.run(&flags); + sleep(PAST_TTL); + s.set("selfkill", sig); + let out = s.run(&flags); + assert_eq!( + (code(&out), stdout(&out)), + (0, "hello\n".into()), + "{sig}: {}", + stderr(&out) + ); + assert_eq!(entries(&s.data_dir()).len(), 2, "{sig}: no marker was set"); + } +} + +#[test] +fn sigterm_stops_ck_when_only_a_background_job_holds_stdout() { + let s = Sandbox::new(); + let mut child = s + .ck(&["run", "--", "sh", "-c", "sleep 8 & echo hi"]) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + sleep(Duration::from_millis(400)); + let started = Instant::now(); + signal(&child, rustix::process::Signal::TERM); + // Time ck's own exit: the background sleep still holds the pipes. + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + assert!( + started.elapsed() < Duration::from_secs(3), + "ck waited for the background job" + ); + sleep(Duration::from_millis(20)); + }; + assert_eq!(status.code(), Some(128 + 15)); +} + +#[test] +fn a_fifo_file_key_exits_125_without_hanging() { + let s = Sandbox::new(); + fs::create_dir_all(s.config_dir()).unwrap(); + let path = s.config_dir().join("file.key"); + rustix::fs::mkfifoat( + rustix::fs::CWD, + &path, + rustix::fs::Mode::from_raw_mode(0o600), + ) + .unwrap(); + let mut child = s.ck(&["run", "--", "origin"]).spawn().unwrap(); + let started = Instant::now(); + while child.try_wait().unwrap().is_none() { + assert!( + started.elapsed() < Duration::from_secs(5), + "ck hung opening a FIFO key" + ); + sleep(Duration::from_millis(50)); + } + let out = child.wait_with_output().unwrap(); + assert_eq!((code(&out), s.runs()), (125, 0), "{}", stderr(&out)); +} From 06537f8b5a5e68ac45e83a1fc5393a4f3ef807ea Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Fri, 2 Oct 2026 20:30:18 +1000 Subject: [PATCH 05/16] test(run): create the FIFO with mkfifo, which builds on macOS (LAB-5118) --- tests/run.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/run.rs b/tests/run.rs index 242bc48..9aa214a 100644 --- a/tests/run.rs +++ b/tests/run.rs @@ -764,12 +764,13 @@ fn a_fifo_file_key_exits_125_without_hanging() { let s = Sandbox::new(); fs::create_dir_all(s.config_dir()).unwrap(); let path = s.config_dir().join("file.key"); - rustix::fs::mkfifoat( - rustix::fs::CWD, - &path, - rustix::fs::Mode::from_raw_mode(0o600), - ) - .unwrap(); + // The mkfifo utility: rustix has no mkfifoat on Apple targets. + assert!(Command::new("mkfifo") + .arg(&path) + .status() + .unwrap() + .success()); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); let mut child = s.ck(&["run", "--", "origin"]).spawn().unwrap(); let started = Instant::now(); while child.try_wait().unwrap().is_none() { From 4995883d1bcb072d076bdf7bd88eddb1ead020bd Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Fri, 2 Oct 2026 20:57:43 +1000 Subject: [PATCH 06/16] fix(run): wait for the command, not its output, on a signal (LAB-5118) - On a handled signal while the command runs, the signal thread forwards it (TERM and HUP), waits for the command to exit, and exits 128 + n. A background job left holding the command's stdout no longer keeps ck alive. - An interrupted run prints nothing, as well as storing nothing. - When the command dies of a handled signal before ck's own handler has run, wait up to 50 ms for it: kernels that signal a process group one member at a time give no order against the child's exit. - Rename Stdout::ReaderGone to OutputLost, since any stdout write failure sets it. - README: the Signals section now matches the behaviour. - Tests poll a readiness file instead of sleeping, and a Linux test checks that every helper thread blocks the handled signals. --- README.md | 14 ++++++-- src/child.rs | 99 ++++++++++++++++++++++++++++++++++++---------------- src/run.rs | 9 ++--- tests/run.rs | 88 +++++++++++++++++++++++++++++++++++++++++----- 4 files changed, 161 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 6100276..153777b 100644 --- a/README.md +++ b/README.md @@ -124,9 +124,17 @@ log or environment dump from the command does not carry your keys. ### Signals -ck forwards SIGTERM and SIGHUP to the command. On those and on SIGINT or -SIGQUIT it waits for the command, stores nothing and exits 128 plus the -signal number. +When ck itself receives SIGTERM, SIGHUP, SIGINT or SIGQUIT, the run is +interrupted. ck forwards SIGTERM and SIGHUP to the command; SIGINT and SIGQUIT +already reach it from the terminal. ck then waits for the command to exit, but +not for a background job it left holding the output. An interrupted run +prints nothing, stores nothing and exits 128 plus the signal number. + +A command that dies of a signal ck did not receive, such as one that kills +itself, has simply failed: ck backs off and serves stored output as for any +other failure. A signal ck inherits as ignored, as under `nohup`, stays +ignored: ck neither forwards it nor stops for it, and the command inherits it +ignored too. ### Platforms diff --git a/src/child.rs b/src/child.rs index 9464aa0..56f9e5b 100644 --- a/src/child.rs +++ b/src/child.rs @@ -7,6 +7,7 @@ use std::os::unix::process::ExitStatusExt; use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; +use std::time::{Duration, Instant}; use rustix::process::{Pid, Signal, WaitId, WaitIdOptions}; use signal_hook::consts::{SIGCHLD, SIGHUP, SIGINT, SIGQUIT, SIGTERM}; @@ -36,11 +37,10 @@ pub enum Ran { StatusUnknown { stdout: Stdout, }, - /// ck received one of [`HANDLED`]. Nothing is stored: the caller prints - /// any captured output and exits 128 + n. + /// ck received one of [`HANDLED`]. The run writes nothing: no stdout, + /// no entry and no marker. The caller exits 128 + n. Interrupted { signal: i32, - stdout: Stdout, }, } @@ -49,10 +49,10 @@ pub enum Stdout { Captured(Vec), /// Already written to ck's stdout. Written, - /// Being streamed when ck's reader went away. The pipe was dropped, so - /// the command most likely died of SIGPIPE, which says nothing about - /// the origin. - ReaderGone, + /// Being streamed when ck's own stdout failed: the reader went away, or + /// the write hit ENOSPC or EIO. The pipe was dropped, so the command most + /// likely died of SIGPIPE, which says nothing about the origin. + OutputLost, } impl Stdout { @@ -78,8 +78,8 @@ enum State { /// SIGTERM and SIGHUP are forwarded to the child. SIGINT and SIGQUIT are not: /// the child shares ck's foreground process group and has already had them /// from the terminal, and some programs treat a second interrupt as a hard -/// abort. On any of them ck waits for the child, stores nothing, and exits -/// 128 + n. +/// abort. On any of them ck waits for the child to exit (not for whatever +/// still holds its stdout), writes nothing, and exits 128 + n. /// /// A signal ck inherited as ignored (`nohup`, a background job) is left /// ignored, so the command inherits it ignored too and ck never forwards it: @@ -119,23 +119,29 @@ impl Supervisor { .name("signals".into()) .spawn(move || { block_handled_signals(); - for sig in signals.forever() { - let state = thread_state.lock().unwrap_or_else(PoisonError::into_inner); - match *state { - State::Idle => std::process::exit(128 + sig), - State::Running(pid) => { - forward(pid, sig); - // A command that has exited while a background - // job still holds its stdout is only a zombie: - // nothing is left to wait for. - let exited = WaitIdOptions::EXITED - | WaitIdOptions::NOWAIT - | WaitIdOptions::NOHANG; - if let Ok(Some(_)) = rustix::process::waitid(WaitId::Pid(pid), exited) { - std::process::exit(128 + sig); + // The first handled signal decides how ck ends; it never + // returns to the loop. + if let Some(sig) = signals.forever().next() { + let running = { + let state = thread_state.lock().unwrap_or_else(PoisonError::into_inner); + match *state { + State::Idle => std::process::exit(128 + sig), + State::Running(pid) => { + // Under the lock, so the pid cannot be reaped + // and reused before the signal lands. + forward(pid, sig); + pid } } - } + }; + // Wait for the command itself, not for EOF on its stdout: + // a background job it started may hold that open for + // ever. ECHILD means main has already reaped it. + let exited = WaitIdOptions::EXITED | WaitIdOptions::NOWAIT; + while let Err(rustix::io::Errno::INTR) = + rustix::process::waitid(WaitId::Pid(running), exited) + {} + std::process::exit(128 + sig); } }) .map_err(|e| Fatal(format!("cannot start the signal thread: {e}")))?; @@ -153,6 +159,35 @@ impl Supervisor { .filter(|&s| s != 0) } + /// [`Self::received`], judged once the child has exited. + /// + /// On Linux a process-group signal is queued to every member before any + /// of them can exit, so ck's flag is already set. Other kernels (XNU) + /// signal the members one by one, newest first, and promise no order + /// against the child's exit. So when the child died of a handled signal + /// and ck has not seen one, wait briefly: a group signal reaches ck + /// within microseconds, and a signal the command sent itself never does. + fn received_after(&self, status: Option<&std::process::ExitStatus>) -> Option { + const GRACE: Duration = Duration::from_millis(50); + if let Some(signal) = self.received() { + return Some(signal); + } + if !status + .and_then(|s| s.signal()) + .is_some_and(|s| HANDLED.contains(&s)) + { + return None; + } + let deadline = Instant::now() + GRACE; + while Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(1)); + if let Some(signal) = self.received() { + return Some(signal); + } + } + None + } + /// Run `argv` with every `CACHEKIT_*` variable removed from its /// environment. stdin and stderr pass straight through. stdout is /// captured up to [`OUTPUT_CAP`] when `capture` is set, else inherited. @@ -219,8 +254,9 @@ impl Supervisor { // Only a signal ck itself received is an interruption. A command that // dies of a signal on its own (`kill -INT $$`, `pkill op`) failed, // and may be answered with stale output. - if let Some(signal) = self.received() { - return Ran::Interrupted { signal, stdout }; + if let Some(signal) = self.received_after(status.as_ref().ok()) { + drop(stdout); + return Ran::Interrupted { signal }; } let status = match status { Ok(status) => status, @@ -259,7 +295,7 @@ fn collect(mut pipe: impl Read) -> Stdout { if emit(&buffer) && emit(&chunk[..n]) && stream(pipe, &mut chunk) { return Stdout::Written; } - return Stdout::ReaderGone; + return Stdout::OutputLost; } buffer.extend_from_slice(&chunk[..n]); } @@ -294,10 +330,11 @@ fn forward(pid: Pid, sig: i32) { } /// Block [`HANDLED`] on the calling thread. ck calls this on every thread it -/// starts (the signal thread and tokio's blocking pool), so the kernel always -/// runs their handlers on the main thread. A handler there finishes before -/// the main thread's `read` or `waitid` returns, so `received` is always set -/// before the main thread decides whether the run was interrupted. +/// starts (the signal thread and tokio's blocking pool), so their handlers +/// always run on the main thread: a signal delivered to ck is recorded before +/// the main thread's next `read` or `waitid` returns. Whether it is +/// delivered before the child exits depends on the kernel; see +/// [`Supervisor::received_after`]. #[allow(unsafe_code)] pub(crate) fn block_handled_signals() { // SAFETY: `sigemptyset` and `sigaddset` initialise a local set, and diff --git a/src/run.rs b/src/run.rs index 3060d67..9eab76d 100644 --- a/src/run.rs +++ b/src/run.rs @@ -145,10 +145,7 @@ impl Call<'_> { None => code, }; } - Ran::Interrupted { signal, stdout } => { - stdout.emit_captured(); - return 128 + signal; - } + Ran::Interrupted { signal } => return 128 + signal, Ran::StatusUnknown { stdout } => { return match (stale, stdout) { (Some(stale), Stdout::Captured(_)) => self.serve_stale(&stale, None, None), @@ -180,7 +177,7 @@ impl Call<'_> { return 0; } - if matches!(stdout, Stdout::ReaderGone) { + if matches!(stdout, Stdout::OutputLost) { // ck's reader left mid-stream and the command most likely died of // SIGPIPE: no evidence about the origin, so no marker. return code; @@ -255,7 +252,7 @@ fn run_uncached(supervisor: &Supervisor, argv: &[std::ffi::OsString]) -> i32 { match supervisor.run(argv, false) { Ran::SpawnFailed { code } | Ran::Exited { code, .. } => code, Ran::StatusUnknown { .. } => 126, - Ran::Interrupted { signal, .. } => 128 + signal, + Ran::Interrupted { signal } => 128 + signal, } } diff --git a/tests/run.rs b/tests/run.rs index 9aa214a..b98c16e 100644 --- a/tests/run.rs +++ b/tests/run.rs @@ -499,13 +499,18 @@ fn sigterm_is_forwarded_and_nothing_is_stored() { fn sigint_waits_for_the_child_and_stores_nothing() { let s = Sandbox::new(); s.set("sleep", "0.6"); + let started = Instant::now(); let child = s.spawn(&["--ttl", "1h"]); - sleep(Duration::from_millis(200)); + wait_until(|| s.runs() == 1); // Only ck gets it, as if the child ignored the terminal's interrupt. signal(&child, rustix::process::Signal::INT); let out = child.wait_with_output().unwrap(); assert_eq!(code(&out), 128 + 2); - assert_eq!(stdout(&out), "hello\n", "ck did not wait for the child"); + assert!( + started.elapsed() >= Duration::from_millis(550), + "ck did not wait for the child" + ); + assert_eq!(stdout(&out), "", "an interrupted run printed its output"); s.unset("sleep"); s.run(&["--ttl", "1h"]); @@ -562,7 +567,8 @@ fn a_process_group_interrupt_stores_nothing() { s.set("sleep", "5"); for round in 0..40 { let child = s.ck(&flags).process_group(0).spawn().unwrap(); - sleep(Duration::from_millis(100)); + // The shim records its call before it sleeps. + wait_until(|| s.runs() == round + 2); let group = rustix::process::Pid::from_raw(child.id() as i32).unwrap(); rustix::process::kill_process_group(group, rustix::process::Signal::INT).unwrap(); let out = child.wait_with_output().unwrap(); @@ -692,9 +698,11 @@ fn an_interrupt_the_command_traps_still_stores_nothing() { // of it, so only ck's own handler can tell interrupted from failed. let s = Sandbox::new(); let flag = s.home().join("slow"); + let ready = s.home().join("ready"); let script = format!( - "trap 'exit 1' INT; [ -f '{}' ] && sleep 5; echo hi", - flag.display() + "trap 'exit 1' INT; [ -f '{0}' ] && touch '{1}' && sleep 5; echo hi", + flag.display(), + ready.display() ); let args = [ "run", "--ttl", "1s", "--stale", "1h", "--", "sh", "-c", &script, @@ -703,8 +711,10 @@ fn an_interrupt_the_command_traps_still_stores_nothing() { sleep(PAST_TTL); fs::write(&flag, "").unwrap(); for round in 0..20 { + let _ = fs::remove_file(&ready); let child = s.ck(&args).process_group(0).spawn().unwrap(); - sleep(Duration::from_millis(150)); + // Signal only once the trap is set. + wait_until(|| ready.exists()); let group = rustix::process::Pid::from_raw(child.id() as i32).unwrap(); rustix::process::kill_process_group(group, rustix::process::Signal::INT).unwrap(); let out = child.wait_with_output().unwrap(); @@ -717,7 +727,8 @@ fn an_interrupt_the_command_traps_still_stores_nothing() { fn a_command_that_signals_itself_is_a_failure() { // ck received nothing, so a command dead of its own signal failed: the // stale value is served with exit 0 and a marker is set. - for sig in ["TERM", "INT", "HUP", "QUIT"] { + // One forwarded signal and one that is not: the rule is the same for all. + for sig in ["TERM", "INT"] { let s = Sandbox::new(); let flags = ["--ttl", "1s", "--stale", "1h"]; s.run(&flags); @@ -737,12 +748,16 @@ fn a_command_that_signals_itself_is_a_failure() { #[test] fn sigterm_stops_ck_when_only_a_background_job_holds_stdout() { let s = Sandbox::new(); + let ready = s.home().join("ready"); + // The signal lands while the command is alive; it then dies of it, + // leaving a background job holding its stdout. + let script = format!("sleep 8 & touch '{}'; sleep 20", ready.display()); let mut child = s - .ck(&["run", "--", "sh", "-c", "sleep 8 & echo hi"]) + .ck(&["run", "--", "sh", "-c", &script]) .stderr(Stdio::null()) .spawn() .unwrap(); - sleep(Duration::from_millis(400)); + wait_until(|| ready.exists()); let started = Instant::now(); signal(&child, rustix::process::Signal::TERM); // Time ck's own exit: the background sleep still holds the pipes. @@ -757,6 +772,10 @@ fn sigterm_stops_ck_when_only_a_background_job_holds_stdout() { sleep(Duration::from_millis(20)); }; assert_eq!(status.code(), Some(128 + 15)); + assert!( + entries(&s.data_dir()).is_empty(), + "the interrupted run was stored" + ); } #[test] @@ -783,3 +802,54 @@ fn a_fifo_file_key_exits_125_without_hanging() { let out = child.wait_with_output().unwrap(); assert_eq!((code(&out), s.runs()), (125, 0), "{}", stderr(&out)); } + +/// Poll `ready` until it holds, failing the test after 5 s. +fn wait_until(ready: impl Fn() -> bool) { + let started = Instant::now(); + while !ready() { + assert!( + started.elapsed() < Duration::from_secs(5), + "timed out waiting for readiness" + ); + sleep(Duration::from_millis(5)); + } +} + +/// Every thread but main blocks the handled signals, so their handlers run +/// on the main thread. Linux routes a group signal to the leader anyway, so +/// only the masks themselves can show a helper thread that does not block. +#[cfg(target_os = "linux")] +#[test] +fn helper_threads_block_the_handled_signals() { + let s = Sandbox::new(); + s.set("sleep", "2"); + let mut child = s.spawn(&["--ttl", "1h"]); + wait_until(|| s.runs() == 1); + let pid = child.id(); + let mask_of = |tid: &str| { + let status = fs::read_to_string(format!("/proc/{pid}/task/{tid}/status")).unwrap(); + let line = status.lines().find(|l| l.starts_with("SigBlk:")).unwrap(); + u64::from_str_radix(line["SigBlk:".len()..].trim(), 16).unwrap() + }; + // SIGHUP 1, SIGINT 2, SIGQUIT 3, SIGTERM 15. + let handled = (1 << 0) | (1 << 1) | (1 << 2) | (1 << 14); + let mut helpers = 0; + for task in fs::read_dir(format!("/proc/{pid}/task")).unwrap() { + let tid = task.unwrap().file_name().into_string().unwrap(); + let mask = mask_of(&tid); + if tid == pid.to_string() { + assert_eq!(mask & handled, 0, "the main thread blocks a handled signal"); + } else { + assert_eq!( + mask & handled, + handled, + "thread {tid} does not block every handled signal" + ); + helpers += 1; + } + } + // The signal thread and tokio's blocking-pool thread. + assert!(helpers >= 2, "found {helpers} helper threads"); + child.kill().unwrap(); + let _ = child.wait(); +} From 12932f443afccf70b9e201ccf5e0a4805ce638fd Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Fri, 2 Oct 2026 21:14:17 +1000 Subject: [PATCH 07/16] fix(run): keep forwarding signals after the first (LAB-5118) - The signal thread forwards every SIGTERM and SIGHUP while the command runs, so a command that traps the first one can still be stopped. On the first signal it records it and starts a waiter that ends ck once the command itself has exited. - Every path that ends ck on a signal exits 128 + the first signal, so two different signals give one deterministic exit code. - README and docs: a signal discards the output ck was holding back; output already written stays written. - Tests: INT then TERM and TERM then TERM against commands that trap the first signal, the exited-command case again, and readiness polls in place of fixed sleeps. --- README.md | 9 +++- src/child.rs | 107 ++++++++++++++++++++++++++++++------------ src/run.rs | 4 +- tests/run.rs | 128 ++++++++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 207 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 153777b..768f44d 100644 --- a/README.md +++ b/README.md @@ -127,8 +127,13 @@ log or environment dump from the command does not carry your keys. When ck itself receives SIGTERM, SIGHUP, SIGINT or SIGQUIT, the run is interrupted. ck forwards SIGTERM and SIGHUP to the command; SIGINT and SIGQUIT already reach it from the terminal. ck then waits for the command to exit, but -not for a background job it left holding the output. An interrupted run -prints nothing, stores nothing and exits 128 plus the signal number. +not for a background job it left holding the output. A signal that arrives +while the command runs discards the output ck was holding back, stores +nothing, and makes ck exit 128 plus the number of the first signal it +received. Output already written, by an uncached call or past 20 MiB, stays +written, and a cache entry being written is never left half-written. Later +SIGTERM and SIGHUP signals are still forwarded, so a command that handles the +first one gracefully can still be stopped. A command that dies of a signal ck did not receive, such as one that kills itself, has simply failed: ck backs off and serves stored output as for any diff --git a/src/child.rs b/src/child.rs index 56f9e5b..50a858b 100644 --- a/src/child.rs +++ b/src/child.rs @@ -37,8 +37,10 @@ pub enum Ran { StatusUnknown { stdout: Stdout, }, - /// ck received one of [`HANDLED`]. The run writes nothing: no stdout, - /// no entry and no marker. The caller exits 128 + n. + /// ck received one of [`HANDLED`] while the command ran. Output ck was + /// holding back is discarded and nothing is stored; output already + /// written (an uncached call, or past the cap) stays written. The caller + /// exits 128 + n, where n is the first signal ck received. Interrupted { signal: i32, }, @@ -79,7 +81,9 @@ enum State { /// the child shares ck's foreground process group and has already had them /// from the terminal, and some programs treat a second interrupt as a hard /// abort. On any of them ck waits for the child to exit (not for whatever -/// still holds its stdout), writes nothing, and exits 128 + n. +/// still holds its stdout), discards the output it held back, stores +/// nothing, and exits 128 + n for the first signal. Later TERM and HUP +/// signals are forwarded too. /// /// A signal ck inherited as ignored (`nohup`, a background job) is left /// ignored, so the command inherits it ignored too and ck never forwards it: @@ -88,7 +92,12 @@ pub struct Supervisor { state: Arc>, /// Set inside the signal handler itself, so it is visible before the /// main thread can reap a child killed by the same process-group signal. + /// It holds the latest signal. received: Arc, + /// The first signal, recorded once by the signal thread. Every path that + /// ends ck on a signal exits 128 + this, so two signals in a row give one + /// deterministic exit code. + first: Arc, /// SIGCHLD was inherited as ignored. ck handles it itself, or the kernel /// would reap the child before ck could read its exit status, and gives /// the child the ignored disposition back. @@ -114,40 +123,34 @@ impl Supervisor { } let mut signals = Signals::new(&handled).map_err(fail)?; let state = Arc::new(Mutex::new(State::Idle)); - let thread_state = Arc::clone(&state); + let first = Arc::new(AtomicUsize::new(0)); + let (thread_state, thread_first) = (Arc::clone(&state), Arc::clone(&first)); std::thread::Builder::new() .name("signals".into()) .spawn(move || { block_handled_signals(); - // The first handled signal decides how ck ends; it never - // returns to the loop. - if let Some(sig) = signals.forever().next() { - let running = { - let state = thread_state.lock().unwrap_or_else(PoisonError::into_inner); - match *state { - State::Idle => std::process::exit(128 + sig), - State::Running(pid) => { - // Under the lock, so the pid cannot be reaped - // and reused before the signal lands. - forward(pid, sig); - pid + for sig in signals.forever() { + let state = thread_state.lock().unwrap_or_else(PoisonError::into_inner); + match *state { + State::Idle => std::process::exit(128 + record_first(&thread_first, sig)), + State::Running(pid) => { + // Every TERM and HUP is forwarded, under the lock + // so the pid cannot be reaped and reused first: a + // command that traps the first one still gets the + // supervisor's next. + forward(pid, sig); + if record_first(&thread_first, sig) == sig { + wait_then_exit(pid, Arc::clone(&thread_first)); } } - }; - // Wait for the command itself, not for EOF on its stdout: - // a background job it started may hold that open for - // ever. ECHILD means main has already reaped it. - let exited = WaitIdOptions::EXITED | WaitIdOptions::NOWAIT; - while let Err(rustix::io::Errno::INTR) = - rustix::process::waitid(WaitId::Pid(running), exited) - {} - std::process::exit(128 + sig); + } } }) .map_err(|e| Fatal(format!("cannot start the signal thread: {e}")))?; Ok(Self { state, received, + first, sigchld_ignored, }) } @@ -188,6 +191,21 @@ impl Supervisor { None } + /// The signal that ends this run, once the child has exited: the first + /// one ck received. The signal thread records it, and may not have run + /// yet, so wait briefly for it before recording the latest one instead. + fn interrupted_by(&self, status: Option<&std::process::ExitStatus>) -> Option { + let latest = self.received_after(status)?; + let deadline = Instant::now() + Duration::from_millis(50); + while Instant::now() < deadline { + match i32::try_from(self.first.load(Ordering::SeqCst)) { + Ok(first) if first != 0 => return Some(first), + _ => std::thread::sleep(Duration::from_millis(1)), + } + } + Some(record_first(&self.first, latest)) + } + /// Run `argv` with every `CACHEKIT_*` variable removed from its /// environment. stdin and stderr pass straight through. stdout is /// captured up to [`OUTPUT_CAP`] when `capture` is set, else inherited. @@ -254,8 +272,7 @@ impl Supervisor { // Only a signal ck itself received is an interruption. A command that // dies of a signal on its own (`kill -INT $$`, `pkill op`) failed, // and may be answered with stale output. - if let Some(signal) = self.received_after(status.as_ref().ok()) { - drop(stdout); + if let Some(signal) = self.interrupted_by(status.as_ref().ok()) { return Ran::Interrupted { signal }; } let status = match status { @@ -273,9 +290,8 @@ impl Supervisor { } /// Read the child's stdout into memory, or, once it passes the cap, write -/// out what is held and stream the rest. Once ck's own stdout fails (the -/// reader has gone), the pipe is dropped, so the command gets SIGPIPE just as -/// it would without ck. +/// out what is held and stream the rest. Once ck's own stdout fails, the pipe +/// is dropped, so the command gets SIGPIPE just as it would without ck. fn collect(mut pipe: impl Read) -> Stdout { let mut buffer = Vec::new(); let mut chunk = vec![0u8; 64 * 1024]; @@ -320,6 +336,37 @@ fn stream(mut pipe: impl Read, chunk: &mut [u8]) -> bool { } } +/// Record `sig` as the first signal unless one already is; return the first. +fn record_first(first: &AtomicUsize, sig: i32) -> i32 { + let value = + usize::try_from(sig).unwrap_or_else(|_| unreachable!("signal numbers are positive")); + match first.compare_exchange(0, value, Ordering::SeqCst, Ordering::SeqCst) { + Ok(_) => sig, + Err(prev) => i32::try_from(prev).unwrap_or(sig), + } +} + +/// Wait on another thread for the command itself to exit, not for EOF on its +/// stdout, which a background job it started may hold open for ever; then +/// end ck with the first signal. ECHILD means main has already reaped it. +/// The thread inherits the signal thread's mask, so it takes no signal. +fn wait_then_exit(pid: Pid, first: Arc) { + let spawned = std::thread::Builder::new() + .name("waiter".into()) + .spawn(move || { + let exited = WaitIdOptions::EXITED | WaitIdOptions::NOWAIT; + while let Err(rustix::io::Errno::INTR) = + rustix::process::waitid(WaitId::Pid(pid), exited) + {} + let code = i32::try_from(first.load(Ordering::SeqCst)).unwrap_or(0); + std::process::exit(128 + code); + }); + if let Err(e) = spawned { + // Main still ends the run once the command's stdout closes. + warn(&format!("cannot start the waiter thread: {e}")); + } +} + fn forward(pid: Pid, sig: i32) { let signal = match sig { SIGTERM => Signal::TERM, diff --git a/src/run.rs b/src/run.rs index 9eab76d..b561e8c 100644 --- a/src/run.rs +++ b/src/run.rs @@ -178,8 +178,8 @@ impl Call<'_> { } if matches!(stdout, Stdout::OutputLost) { - // ck's reader left mid-stream and the command most likely died of - // SIGPIPE: no evidence about the origin, so no marker. + // ck's stdout failed mid-stream and the command most likely died + // of SIGPIPE: no evidence about the origin, so no marker. return code; } let next = Marker::bumped(marker.as_ref(), code, now); diff --git a/tests/run.rs b/tests/run.rs index b98c16e..5061758 100644 --- a/tests/run.rs +++ b/tests/run.rs @@ -474,13 +474,13 @@ fn signal(child: &std::process::Child, sig: rustix::process::Signal) { #[test] fn sigterm_is_forwarded_and_nothing_is_stored() { let s = Sandbox::new(); - // `sleep` itself, not the shim: a shell killed mid-`sleep` leaves the - // sleep holding stdout open, with or without ck. + let ready = s.home().join("ready"); + let script = format!("touch '{}'; exec sleep 5", ready.display()); let child = s - .ck(&["run", "--ttl", "1h", "--", "sleep", "5"]) + .ck(&["run", "--ttl", "1h", "--", "sh", "-c", &script]) .spawn() .unwrap(); - sleep(Duration::from_millis(300)); + wait_until(|| ready.exists()); let started = Instant::now(); signal(&child, rustix::process::Signal::TERM); let out = child.wait_with_output().unwrap(); @@ -499,15 +499,16 @@ fn sigterm_is_forwarded_and_nothing_is_stored() { fn sigint_waits_for_the_child_and_stores_nothing() { let s = Sandbox::new(); s.set("sleep", "0.6"); - let started = Instant::now(); let child = s.spawn(&["--ttl", "1h"]); wait_until(|| s.runs() == 1); + // The shim has 0.6 s of sleep left from here. + let started = Instant::now(); // Only ck gets it, as if the child ignored the terminal's interrupt. signal(&child, rustix::process::Signal::INT); let out = child.wait_with_output().unwrap(); assert_eq!(code(&out), 128 + 2); assert!( - started.elapsed() >= Duration::from_millis(550), + started.elapsed() >= Duration::from_millis(500), "ck did not wait for the child" ); assert_eq!(stdout(&out), "", "an interrupted run printed its output"); @@ -634,7 +635,8 @@ fn a_signal_ignored_by_the_caller_stays_ignored() { .stderr(Stdio::piped()) .spawn() .unwrap(); - sleep(Duration::from_millis(250)); + // The shim records its call after ck has installed its handlers. + wait_until(|| s.runs() == 1); signal(&child, rustix::process::Signal::HUP); let out = child.wait_with_output().unwrap(); assert_eq!( @@ -853,3 +855,115 @@ fn helper_threads_block_the_handled_signals() { child.kill().unwrap(); let _ = child.wait(); } + +/// Run `script` under ck in its own process group, wait for its ready file, +/// send `first` to the group (as Ctrl-C does) or to ck alone, then `then` to +/// ck alone, as a supervisor would. Returns ck's exit code and how long it +/// took after the second signal. +fn two_signals( + script: &str, + first: rustix::process::Signal, + to_group: bool, + then: rustix::process::Signal, +) -> (Option, Duration) { + use std::os::unix::process::CommandExt; + let s = Sandbox::new(); + let ready = s.home().join("ready"); + let script = script.replace("READY", &ready.display().to_string()); + let mut child = s + .ck(&["run", "--", "sh", "-c", &script]) + .stderr(Stdio::null()) + .process_group(0) + .spawn() + .unwrap(); + wait_until(|| ready.exists()); + let pid = rustix::process::Pid::from_raw(child.id() as i32).unwrap(); + if to_group { + rustix::process::kill_process_group(pid, first).unwrap(); + } else { + rustix::process::kill_process(pid, first).unwrap(); + } + sleep(Duration::from_millis(200)); + assert!( + child.try_wait().unwrap().is_none(), + "the first signal alone ended ck" + ); + let started = Instant::now(); + rustix::process::kill_process(pid, then).unwrap(); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + assert!( + started.elapsed() < Duration::from_secs(5), + "the second signal was not forwarded" + ); + sleep(Duration::from_millis(20)); + }; + assert!( + entries(&s.data_dir()).is_empty(), + "the interrupted run was stored" + ); + (status.code(), started.elapsed()) +} + +#[test] +fn a_term_after_a_trapped_interrupt_is_still_forwarded() { + use rustix::process::Signal; + // The command ignores the terminal's interrupt; the supervisor's TERM + // must still reach it. The exit code names the first signal. + let (code, took) = two_signals( + "trap '' INT; touch 'READY'; sleep 10 & wait", + Signal::INT, + true, + Signal::TERM, + ); + assert_eq!(code, Some(128 + 2)); + assert!(took < Duration::from_secs(3), "took {took:?}"); +} + +#[test] +fn a_second_term_reaches_a_command_that_trapped_the_first() { + use rustix::process::Signal; + let (code, took) = two_signals( + "trap 'trap - TERM' TERM; touch 'READY'; sleep 10 & wait; wait", + Signal::TERM, + false, + Signal::TERM, + ); + assert_eq!(code, Some(128 + 15)); + assert!(took < Duration::from_secs(3), "took {took:?}"); +} + +#[test] +fn a_signal_after_the_command_exited_stops_ck() { + let s = Sandbox::new(); + let ready = s.home().join("ready"); + // The command exits at once; only its background job holds stdout. + let script = format!("sleep 8 & echo hi; touch '{}'", ready.display()); + let mut child = s + .ck(&["run", "--", "sh", "-c", &script]) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + wait_until(|| ready.exists()); + // Let the shell finish exiting after the touch. + sleep(Duration::from_millis(200)); + let started = Instant::now(); + signal(&child, rustix::process::Signal::TERM); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + assert!( + started.elapsed() < Duration::from_secs(3), + "ck waited for the background job" + ); + sleep(Duration::from_millis(20)); + }; + assert_eq!(status.code(), Some(128 + 15)); + assert!( + entries(&s.data_dir()).is_empty(), + "the interrupted run was stored" + ); +} From 0865656e013b89cc9ff11a69c3f9b2abeeb9dac7 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Fri, 2 Oct 2026 21:14:56 +1000 Subject: [PATCH 08/16] test(run): poll for the lock holder instead of sleeping (LAB-5118) --- tests/run.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/run.rs b/tests/run.rs index 5061758..c2ef3ec 100644 --- a/tests/run.rs +++ b/tests/run.rs @@ -523,8 +523,11 @@ fn a_waiter_blocked_on_the_lock_exits_on_a_signal() { let s = Sandbox::new(); s.set("sleep", "3"); let holder = s.spawn(&["--ttl", "1h"]); - sleep(Duration::from_millis(300)); + // The holder's command is running, so it holds the lock. + wait_until(|| s.runs() == 1); let waiter = s.spawn(&["--ttl", "1h"]); + // A waiter blocked in flock shows nothing a test can poll, so this one + // pause stays: long enough for ck to install its handlers and block. sleep(Duration::from_millis(300)); let started = Instant::now(); signal(&waiter, rustix::process::Signal::TERM); From 9b584524a2e030cc2b37c018cca5b85466d0a7fb Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Fri, 2 Oct 2026 22:00:59 +1000 Subject: [PATCH 09/16] fix(run): start one waiter, not one per repeated signal (LAB-5118) - record_first reports whether it recorded the signal, and only that call starts the waiter, so repeated TERM or HUP signals add no threads. - A signal that arrived while the spawn path held the state lock ends the run before the command starts. - README: signals that arrive together count in signal-number order; a streamed run can lose its tail on a signal; a signal inside the spawn itself is honoured when the command exits. - Tests: a Linux thread-count check, readiness polls in place of the last fixed pauses, and one wait_exit helper. --- README.md | 19 ++++--- src/child.rs | 28 ++++++--- tests/run.rs | 156 +++++++++++++++++++++++++++++++++------------------ 3 files changed, 134 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 768f44d..f55269c 100644 --- a/README.md +++ b/README.md @@ -127,13 +127,18 @@ log or environment dump from the command does not carry your keys. When ck itself receives SIGTERM, SIGHUP, SIGINT or SIGQUIT, the run is interrupted. ck forwards SIGTERM and SIGHUP to the command; SIGINT and SIGQUIT already reach it from the terminal. ck then waits for the command to exit, but -not for a background job it left holding the output. A signal that arrives -while the command runs discards the output ck was holding back, stores -nothing, and makes ck exit 128 plus the number of the first signal it -received. Output already written, by an uncached call or past 20 MiB, stays -written, and a cache entry being written is never left half-written. Later -SIGTERM and SIGHUP signals are still forwarded, so a command that handles the -first one gracefully can still be stopped. +not for a background job it left holding the output. Later SIGTERM and SIGHUP +signals are still forwarded, so a command that handles the first one +gracefully can still be stopped. + +An interrupted run discards the output ck was holding back, stores nothing, +and exits 128 plus the number of the first signal ck received. Signals that +arrive together count in signal-number order. Output already written, by an +uncached call or past 20 MiB, stays written, and a cache entry being written +is never left half-written. On a run streamed past 20 MiB, ck ends as soon as +the command does, so the last part of the output it was still relaying can be +cut off. A signal that lands in the millisecond or so while ck is starting the +command is honoured only when the command exits. A command that dies of a signal ck did not receive, such as one that kills itself, has simply failed: ck backs off and serves stored output as for any diff --git a/src/child.rs b/src/child.rs index 50a858b..756186f 100644 --- a/src/child.rs +++ b/src/child.rs @@ -94,7 +94,7 @@ pub struct Supervisor { /// main thread can reap a child killed by the same process-group signal. /// It holds the latest signal. received: Arc, - /// The first signal, recorded once by the signal thread. Every path that + /// The first signal, recorded once, normally by the signal thread. Every path that /// ends ck on a signal exits 128 + this, so two signals in a row give one /// deterministic exit code. first: Arc, @@ -132,14 +132,16 @@ impl Supervisor { for sig in signals.forever() { let state = thread_state.lock().unwrap_or_else(PoisonError::into_inner); match *state { - State::Idle => std::process::exit(128 + record_first(&thread_first, sig)), + State::Idle => std::process::exit(128 + record_first(&thread_first, sig).0), State::Running(pid) => { // Every TERM and HUP is forwarded, under the lock // so the pid cannot be reaped and reused first: a // command that traps the first one still gets the // supervisor's next. forward(pid, sig); - if record_first(&thread_first, sig) == sig { + // Only the call that records the first signal + // starts the waiter: repeats must not add threads. + if record_first(&thread_first, sig).1 { wait_then_exit(pid, Arc::clone(&thread_first)); } } @@ -203,7 +205,7 @@ impl Supervisor { _ => std::thread::sleep(Duration::from_millis(1)), } } - Some(record_first(&self.first, latest)) + Some(record_first(&self.first, latest).0) } /// Run `argv` with every `CACHEKIT_*` variable removed from its @@ -230,6 +232,15 @@ impl Supervisor { // Held across the spawn so a signal cannot fall between "no // child yet" and "child running". let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + // A signal that arrived while this thread held the lock found the + // signal thread blocked on it: honour it now, before any command + // starts. One that lands inside `spawn` itself is honoured only + // when the command exits; see the README. + if let Some(signal) = self.received() { + return Ran::Interrupted { + signal: record_first(&self.first, signal).0, + }; + } match command.spawn() { Ok(child) => { *state = State::Running(pid_of(child.id())); @@ -336,13 +347,14 @@ fn stream(mut pipe: impl Read, chunk: &mut [u8]) -> bool { } } -/// Record `sig` as the first signal unless one already is; return the first. -fn record_first(first: &AtomicUsize, sig: i32) -> i32 { +/// Record `sig` as the first signal unless one already is. Returns the first +/// signal, and whether this call is the one that recorded it. +fn record_first(first: &AtomicUsize, sig: i32) -> (i32, bool) { let value = usize::try_from(sig).unwrap_or_else(|_| unreachable!("signal numbers are positive")); match first.compare_exchange(0, value, Ordering::SeqCst, Ordering::SeqCst) { - Ok(_) => sig, - Err(prev) => i32::try_from(prev).unwrap_or(sig), + Ok(_) => (sig, true), + Err(prev) => (i32::try_from(prev).unwrap_or(sig), false), } } diff --git a/tests/run.rs b/tests/run.rs index c2ef3ec..a3b22d7 100644 --- a/tests/run.rs +++ b/tests/run.rs @@ -92,7 +92,8 @@ fn a_contended_lock_with_a_stale_value_serves_stale_at_once() { s.set("sleep", "2"); s.set("out", "new\n"); let filler = s.spawn(&flags); - sleep(Duration::from_millis(300)); + // The filler's command is running, so it holds the lock. + wait_until(|| s.runs() == 2); let started = Instant::now(); let out = s.run(&flags); @@ -508,7 +509,7 @@ fn sigint_waits_for_the_child_and_stores_nothing() { let out = child.wait_with_output().unwrap(); assert_eq!(code(&out), 128 + 2); assert!( - started.elapsed() >= Duration::from_millis(500), + started.elapsed() >= Duration::from_millis(300), "ck did not wait for the child" ); assert_eq!(stdout(&out), "", "an interrupted run printed its output"); @@ -526,9 +527,7 @@ fn a_waiter_blocked_on_the_lock_exits_on_a_signal() { // The holder's command is running, so it holds the lock. wait_until(|| s.runs() == 1); let waiter = s.spawn(&["--ttl", "1h"]); - // A waiter blocked in flock shows nothing a test can poll, so this one - // pause stays: long enough for ck to install its handlers and block. - sleep(Duration::from_millis(300)); + wait_handlers_installed(&waiter); let started = Instant::now(); signal(&waiter, rustix::process::Signal::TERM); let out = waiter.wait_with_output().unwrap(); @@ -589,7 +588,9 @@ fn a_signal_while_writing_output_stops_ck() { .ck(&["run", "--", "head", "-c", "1000000", "/dev/zero"]) .spawn() .unwrap(); - sleep(Duration::from_millis(500)); + wait_handlers_installed(&child); + // Give it time to fill the pipe and block writing. + sleep(Duration::from_millis(200)); signal(&child, rustix::process::Signal::TERM); let out = child.wait_with_output().unwrap(); assert_eq!(code(&out), 128 + 15); @@ -604,14 +605,11 @@ fn a_closed_reader_ends_a_streamed_command() { let mut head = [0u8; 10]; child.stdout.take().unwrap().read_exact(&mut head).unwrap(); // The read end is dropped here, as `| head -c 10` would. - let started = Instant::now(); - while child.try_wait().unwrap().is_none() { - assert!( - started.elapsed() < Duration::from_secs(10), - "ck kept draining a closed pipe" - ); - sleep(Duration::from_millis(50)); - } + wait_exit( + &mut child, + Duration::from_secs(10), + "ck kept draining a closed pipe", + ); // The command died of SIGPIPE because the reader left: not a failure. assert!(entries(&s.data_dir()).is_empty(), "a marker was stored"); } @@ -763,19 +761,13 @@ fn sigterm_stops_ck_when_only_a_background_job_holds_stdout() { .spawn() .unwrap(); wait_until(|| ready.exists()); - let started = Instant::now(); signal(&child, rustix::process::Signal::TERM); // Time ck's own exit: the background sleep still holds the pipes. - let status = loop { - if let Some(status) = child.try_wait().unwrap() { - break status; - } - assert!( - started.elapsed() < Duration::from_secs(3), - "ck waited for the background job" - ); - sleep(Duration::from_millis(20)); - }; + let status = wait_exit( + &mut child, + Duration::from_secs(3), + "ck waited for the background job", + ); assert_eq!(status.code(), Some(128 + 15)); assert!( entries(&s.data_dir()).is_empty(), @@ -796,14 +788,11 @@ fn a_fifo_file_key_exits_125_without_hanging() { .success()); fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); let mut child = s.ck(&["run", "--", "origin"]).spawn().unwrap(); - let started = Instant::now(); - while child.try_wait().unwrap().is_none() { - assert!( - started.elapsed() < Duration::from_secs(5), - "ck hung opening a FIFO key" - ); - sleep(Duration::from_millis(50)); - } + wait_exit( + &mut child, + Duration::from_secs(5), + "ck hung opening a FIFO key", + ); let out = child.wait_with_output().unwrap(); assert_eq!((code(&out), s.runs()), (125, 0), "{}", stderr(&out)); } @@ -893,16 +882,11 @@ fn two_signals( ); let started = Instant::now(); rustix::process::kill_process(pid, then).unwrap(); - let status = loop { - if let Some(status) = child.try_wait().unwrap() { - break status; - } - assert!( - started.elapsed() < Duration::from_secs(5), - "the second signal was not forwarded" - ); - sleep(Duration::from_millis(20)); - }; + let status = wait_exit( + &mut child, + Duration::from_secs(5), + "the second signal was not forwarded", + ); assert!( entries(&s.data_dir()).is_empty(), "the interrupted run was stored" @@ -952,21 +936,85 @@ fn a_signal_after_the_command_exited_stops_ck() { wait_until(|| ready.exists()); // Let the shell finish exiting after the touch. sleep(Duration::from_millis(200)); - let started = Instant::now(); signal(&child, rustix::process::Signal::TERM); - let status = loop { - if let Some(status) = child.try_wait().unwrap() { - break status; - } - assert!( - started.elapsed() < Duration::from_secs(3), - "ck waited for the background job" - ); - sleep(Duration::from_millis(20)); - }; + let status = wait_exit( + &mut child, + Duration::from_secs(3), + "ck waited for the background job", + ); assert_eq!(status.code(), Some(128 + 15)); assert!( entries(&s.data_dir()).is_empty(), "the interrupted run was stored" ); } + +/// Wait for `child` to exit, failing the test with `msg` after `limit`. +fn wait_exit( + child: &mut std::process::Child, + limit: Duration, + msg: &str, +) -> std::process::ExitStatus { + let started = Instant::now(); + loop { + if let Some(status) = child.try_wait().unwrap() { + return status; + } + assert!(started.elapsed() < limit, "{msg}"); + sleep(Duration::from_millis(20)); + } +} + +/// Wait until ck has installed its SIGTERM handler, so a signal sent now is +/// ck's to handle. Linux shows it in `SigCgt`; elsewhere, wait a moment. +fn wait_handlers_installed(child: &std::process::Child) { + #[cfg(target_os = "linux")] + wait_until(|| { + let status = fs::read_to_string(format!("/proc/{}/status", child.id())).unwrap_or_default(); + status + .lines() + .find_map(|l| l.strip_prefix("SigCgt:")) + .and_then(|m| u64::from_str_radix(m.trim(), 16).ok()) + .is_some_and(|m| m & (1 << (libc::SIGTERM - 1)) != 0) + }); + #[cfg(not(target_os = "linux"))] + { + let _ = child; + sleep(Duration::from_millis(300)); + } +} + +/// Repeated signals to a command that ignores them add no threads to ck. +#[cfg(target_os = "linux")] +#[test] +fn repeated_signals_start_one_waiter() { + let s = Sandbox::new(); + let ready = s.home().join("ready"); + let script = format!("trap '' TERM; touch '{}'; sleep 3", ready.display()); + let mut child = s + .ck(&["run", "--", "sh", "-c", &script]) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + wait_until(|| ready.exists()); + let threads = || { + fs::read_dir(format!("/proc/{}/task", child.id())) + .unwrap() + .count() + }; + signal(&child, rustix::process::Signal::TERM); + sleep(Duration::from_millis(100)); + let after_one = threads(); + for _ in 0..50 { + signal(&child, rustix::process::Signal::TERM); + sleep(Duration::from_millis(5)); + } + sleep(Duration::from_millis(100)); + assert_eq!( + threads(), + after_one, + "repeated signals started more threads" + ); + let status = wait_exit(&mut child, Duration::from_secs(10), "ck did not exit"); + assert_eq!(status.code(), Some(128 + 15)); +} From 5205a2a4fff9e3ec282eca098332c5cca2559a7b Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Fri, 2 Oct 2026 22:02:15 +1000 Subject: [PATCH 10/16] test(run): kill the child when wait_exit times out (LAB-5118) --- tests/run.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/run.rs b/tests/run.rs index a3b22d7..18cc99f 100644 --- a/tests/run.rs +++ b/tests/run.rs @@ -949,7 +949,8 @@ fn a_signal_after_the_command_exited_stops_ck() { ); } -/// Wait for `child` to exit, failing the test with `msg` after `limit`. +/// Wait for `child` to exit, failing the test with `msg` after `limit`; the +/// child is killed first, so a failure leaves no process behind. fn wait_exit( child: &mut std::process::Child, limit: Duration, @@ -960,7 +961,12 @@ fn wait_exit( if let Some(status) = child.try_wait().unwrap() { return status; } - assert!(started.elapsed() < limit, "{msg}"); + if started.elapsed() >= limit { + // Never leave a failing test's process running. + let _ = child.kill(); + let _ = child.wait(); + panic!("{msg}"); + } sleep(Duration::from_millis(20)); } } From 37a45dd2b29febdbdda23cc4d3a0868ef835b0a6 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Fri, 2 Oct 2026 22:26:37 +1000 Subject: [PATCH 11/16] fix(run): install the iterator handlers before the flag handlers (LAB-5118) - Signals::new now runs before the flag handlers, so a signal landing between the two still reaches the signal thread, which exits 128 + n. - A signal found before spawn releases the lock and lets the signal thread decide the first signal, so signals keep signal-number order there too. - README: only SIGINT and SIGQUIT can miss a command that is starting, and only an interrupted streamed run can lose its tail. - Tests: ck runs as a process-group leader and wait_exit kills the whole group on timeout; the thread-count test waits on a baseline. --- README.md | 9 +++++---- src/child.rs | 29 +++++++++++++++++++++-------- tests/common/mod.rs | 9 +++++++-- tests/run.rs | 15 +++++++++++---- 4 files changed, 44 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index f55269c..b674b96 100644 --- a/README.md +++ b/README.md @@ -135,10 +135,11 @@ An interrupted run discards the output ck was holding back, stores nothing, and exits 128 plus the number of the first signal ck received. Signals that arrive together count in signal-number order. Output already written, by an uncached call or past 20 MiB, stays written, and a cache entry being written -is never left half-written. On a run streamed past 20 MiB, ck ends as soon as -the command does, so the last part of the output it was still relaying can be -cut off. A signal that lands in the millisecond or so while ck is starting the -command is honoured only when the command exits. +is never left half-written. On an interrupted run streamed past 20 MiB, ck +ends as soon as the command does, so the last part of the output it was still +relaying can be cut off. A SIGINT or SIGQUIT that lands while ck is starting +the command may not reach it; ck still exits 128 plus the signal number when +the command exits. A command that dies of a signal ck did not receive, such as one that kills itself, has simply failed: ck backs off and serves stored output as for any diff --git a/src/child.rs b/src/child.rs index 756186f..ebc13c5 100644 --- a/src/child.rs +++ b/src/child.rs @@ -111,6 +111,10 @@ impl Supervisor { .filter(|&s| !inherited_ignored(s)) .collect(); let fail = |e: std::io::Error| Fatal(format!("cannot install signal handlers: {e}")); + // The iterator's handlers go in first: a signal that lands before the + // flag handlers below then still reaches the signal thread, which + // finds `Idle` and exits 128 + n, instead of setting only the flag. + let mut signals = Signals::new(&handled).map_err(fail)?; let received = Arc::new(AtomicUsize::new(0)); for &sig in &handled { let value = usize::try_from(sig) @@ -121,7 +125,6 @@ impl Supervisor { if sigchld_ignored { signal_hook::flag::register(SIGCHLD, Arc::new(AtomicBool::new(false))).map_err(fail)?; } - let mut signals = Signals::new(&handled).map_err(fail)?; let state = Arc::new(Mutex::new(State::Idle)); let first = Arc::new(AtomicUsize::new(0)); let (thread_state, thread_first) = (Arc::clone(&state), Arc::clone(&first)); @@ -198,14 +201,20 @@ impl Supervisor { /// yet, so wait briefly for it before recording the latest one instead. fn interrupted_by(&self, status: Option<&std::process::ExitStatus>) -> Option { let latest = self.received_after(status)?; + Some(self.settle_first(latest)) + } + + /// The first signal, as the signal thread records it. That thread may not + /// have run yet, so wait briefly for it before recording `latest`. + fn settle_first(&self, latest: i32) -> i32 { let deadline = Instant::now() + Duration::from_millis(50); while Instant::now() < deadline { match i32::try_from(self.first.load(Ordering::SeqCst)) { - Ok(first) if first != 0 => return Some(first), + Ok(first) if first != 0 => return first, _ => std::thread::sleep(Duration::from_millis(1)), } } - Some(record_first(&self.first, latest).0) + record_first(&self.first, latest).0 } /// Run `argv` with every `CACHEKIT_*` variable removed from its @@ -233,12 +242,16 @@ impl Supervisor { // child yet" and "child running". let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); // A signal that arrived while this thread held the lock found the - // signal thread blocked on it: honour it now, before any command - // starts. One that lands inside `spawn` itself is honoured only - // when the command exits; see the README. - if let Some(signal) = self.received() { + // signal thread blocked on it: start no command. Release the lock + // so the signal thread, which sees signals in order, decides the + // exit code. A SIGINT or SIGQUIT that lands inside `spawn` itself + // may not reach the command; ck still exits 128 + n when the + // command exits. A TERM or HUP there is forwarded once `spawn` + // returns. + if let Some(latest) = self.received() { + drop(state); return Ran::Interrupted { - signal: record_first(&self.first, signal).0, + signal: self.settle_first(latest), }; } match command.spawn() { diff --git a/tests/common/mod.rs b/tests/common/mod.rs index d6e4a35..b337ca8 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -78,10 +78,15 @@ impl Sandbox { self.read("calls").lines().count() } - /// `ck ` in this sandbox, with stdin on /dev/null and no CacheKit - /// variables inherited from the machine running the tests. + /// `ck ` in this sandbox, in its own process group, with stdin on + /// /dev/null and no CacheKit variables inherited from the machine running + /// the tests. pub fn ck(&self, args: &[&str]) -> Command { + use std::os::unix::process::CommandExt; let mut cmd = Command::new(BIN); + // Its own process group, so a failing test can kill ck together with + // its command and any background job (see `wait_exit`). + cmd.process_group(0); cmd.args(args); for (name, _) in std::env::vars_os() { if name.to_string_lossy().starts_with("CACHEKIT_") { diff --git a/tests/run.rs b/tests/run.rs index 18cc99f..005458a 100644 --- a/tests/run.rs +++ b/tests/run.rs @@ -949,8 +949,10 @@ fn a_signal_after_the_command_exited_stops_ck() { ); } -/// Wait for `child` to exit, failing the test with `msg` after `limit`; the -/// child is killed first, so a failure leaves no process behind. +/// Wait for `child` to exit, failing the test with `msg` after `limit`. +/// Before failing it kills the child's whole process group, so ck, its +/// command and any background job all go: `Sandbox::ck` makes ck a group +/// leader. fn wait_exit( child: &mut std::process::Child, limit: Duration, @@ -962,7 +964,10 @@ fn wait_exit( return status; } if started.elapsed() >= limit { - // Never leave a failing test's process running. + // Never leave a failing test's processes running. + if let Some(group) = rustix::process::Pid::from_raw(child.id() as i32) { + let _ = rustix::process::kill_process_group(group, rustix::process::Signal::KILL); + } let _ = child.kill(); let _ = child.wait(); panic!("{msg}"); @@ -1008,8 +1013,10 @@ fn repeated_signals_start_one_waiter() { .unwrap() .count() }; + let baseline = threads(); signal(&child, rustix::process::Signal::TERM); - sleep(Duration::from_millis(100)); + // The first signal starts exactly one waiter. + wait_until(|| threads() == baseline + 1); let after_one = threads(); for _ in 0..50 { signal(&child, rustix::process::Signal::TERM); From 681862e89fa810812eaa2123825b70bfe103e188 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Fri, 2 Oct 2026 22:52:18 +1000 Subject: [PATCH 12/16] fix(run): hold handled signals while installing handlers (LAB-5118) signal-hook-registry installs a signal's OS handler before it publishes the action list, so a signal in that window reached neither the iterator nor the flag. install now blocks the handled signals on the main thread for the whole of installation and the signal-thread spawn, then restores the caller's mask with SIG_SETMASK. A signal sent meanwhile is delivered after every action is published, to both handlers. The mask code is one sigmask helper, still the crate's single unsafe site for it. settle_first waits up to 5 s for the signal thread's record, now that every flagged signal is guaranteed to reach that thread. Redundant per-test process_group calls are gone. --- src/child.rs | 61 +++++++++++++++++++++++++++++++++++++++------------- tests/run.rs | 8 ++----- 2 files changed, 48 insertions(+), 21 deletions(-) diff --git a/src/child.rs b/src/child.rs index ebc13c5..542ef0f 100644 --- a/src/child.rs +++ b/src/child.rs @@ -111,9 +111,13 @@ impl Supervisor { .filter(|&s| !inherited_ignored(s)) .collect(); let fail = |e: std::io::Error| Fatal(format!("cannot install signal handlers: {e}")); - // The iterator's handlers go in first: a signal that lands before the - // flag handlers below then still reaches the signal thread, which - // finds `Idle` and exits 128 + n, instead of setting only the flag. + // Hold the handled signals on this thread until every handler below + // is installed and published and the signal thread runs. A signal + // sent meanwhile stays pending, and is delivered once the guard + // restores the caller's mask, to both the iterator and the flag. + // Without this, one landing between signal-hook's `sigaction` and + // its publishing of the action list reached neither. + let _held = MaskGuard::block_handled(); let mut signals = Signals::new(&handled).map_err(fail)?; let received = Arc::new(AtomicUsize::new(0)); for &sig in &handled { @@ -197,17 +201,17 @@ impl Supervisor { } /// The signal that ends this run, once the child has exited: the first - /// one ck received. The signal thread records it, and may not have run - /// yet, so wait briefly for it before recording the latest one instead. + /// one ck received. fn interrupted_by(&self, status: Option<&std::process::ExitStatus>) -> Option { let latest = self.received_after(status)?; Some(self.settle_first(latest)) } - /// The first signal, as the signal thread records it. That thread may not - /// have run yet, so wait briefly for it before recording `latest`. + /// The first signal, as the signal thread records it. Every signal that + /// set the flag also reached that thread, so wait for its record; record + /// `latest` only as a backstop, if the thread has died. fn settle_first(&self, latest: i32) -> i32 { - let deadline = Instant::now() + Duration::from_millis(50); + let deadline = Instant::now() + Duration::from_secs(5); while Instant::now() < deadline { match i32::try_from(self.first.load(Ordering::SeqCst)) { Ok(first) if first != 0 => return first, @@ -407,18 +411,45 @@ fn forward(pid: Pid, sig: i32) { /// the main thread's next `read` or `waitid` returns. Whether it is /// delivered before the child exits depends on the kernel; see /// [`Supervisor::received_after`]. -#[allow(unsafe_code)] pub(crate) fn block_handled_signals() { - // SAFETY: `sigemptyset` and `sigaddset` initialise a local set, and + sigmask(libc::SIG_BLOCK, None); +} + +/// Blocks [`HANDLED`] on the calling thread while it lives, then restores +/// the mask it found with SIG_SETMASK, so a signal the caller already had +/// blocked stays blocked. +struct MaskGuard(libc::sigset_t); + +impl MaskGuard { + fn block_handled() -> Self { + Self(sigmask(libc::SIG_BLOCK, None)) + } +} + +impl Drop for MaskGuard { + fn drop(&mut self) { + sigmask(libc::SIG_SETMASK, Some(&self.0)); + } +} + +/// `pthread_sigmask(how, set)` on the calling thread, where `None` means the +/// [`HANDLED`] set. Returns the previous mask. +#[allow(unsafe_code)] +fn sigmask(how: libc::c_int, set: Option<&libc::sigset_t>) -> libc::sigset_t { + // SAFETY: `sigemptyset` and `sigaddset` initialise a local set, an + // all-zero `sigset_t` is a valid value to receive the old mask, and // `pthread_sigmask` changes only the calling thread's mask. Its one - // failure, EINVAL, needs an invalid `how`, and SIG_BLOCK is valid. + // failure, EINVAL, needs an invalid `how`; callers pass SIG_BLOCK or + // SIG_SETMASK. unsafe { - let mut set: libc::sigset_t = std::mem::zeroed(); - libc::sigemptyset(&mut set); + let mut handled: libc::sigset_t = std::mem::zeroed(); + libc::sigemptyset(&mut handled); for sig in HANDLED { - libc::sigaddset(&mut set, sig); + libc::sigaddset(&mut handled, sig); } - libc::pthread_sigmask(libc::SIG_BLOCK, &set, std::ptr::null_mut()); + let mut old: libc::sigset_t = std::mem::zeroed(); + libc::pthread_sigmask(how, set.unwrap_or(&handled), &mut old); + old } } diff --git a/tests/run.rs b/tests/run.rs index 005458a..e20eaf1 100644 --- a/tests/run.rs +++ b/tests/run.rs @@ -559,7 +559,6 @@ fn version_and_usage() { #[test] fn a_process_group_interrupt_stores_nothing() { - use std::os::unix::process::CommandExt; // ck and the command both get the signal, as from Ctrl-C at a terminal. // ck must never read the dead child as an ordinary failure: that would // serve stale with exit 0 and set a marker that suppresses later calls. @@ -569,7 +568,7 @@ fn a_process_group_interrupt_stores_nothing() { sleep(PAST_TTL); s.set("sleep", "5"); for round in 0..40 { - let child = s.ck(&flags).process_group(0).spawn().unwrap(); + let child = s.ck(&flags).spawn().unwrap(); // The shim records its call before it sleeps. wait_until(|| s.runs() == round + 2); let group = rustix::process::Pid::from_raw(child.id() as i32).unwrap(); @@ -696,7 +695,6 @@ fn an_inherited_ignored_sigchld_does_not_fake_a_failure() { #[test] fn an_interrupt_the_command_traps_still_stores_nothing() { - use std::os::unix::process::CommandExt; // terraform-style: the command traps SIGINT and exits 1 instead of dying // of it, so only ck's own handler can tell interrupted from failed. let s = Sandbox::new(); @@ -715,7 +713,7 @@ fn an_interrupt_the_command_traps_still_stores_nothing() { fs::write(&flag, "").unwrap(); for round in 0..20 { let _ = fs::remove_file(&ready); - let child = s.ck(&args).process_group(0).spawn().unwrap(); + let child = s.ck(&args).spawn().unwrap(); // Signal only once the trap is set. wait_until(|| ready.exists()); let group = rustix::process::Pid::from_raw(child.id() as i32).unwrap(); @@ -858,14 +856,12 @@ fn two_signals( to_group: bool, then: rustix::process::Signal, ) -> (Option, Duration) { - use std::os::unix::process::CommandExt; let s = Sandbox::new(); let ready = s.home().join("ready"); let script = script.replace("READY", &ready.display().to_string()); let mut child = s .ck(&["run", "--", "sh", "-c", &script]) .stderr(Stdio::null()) - .process_group(0) .spawn() .unwrap(); wait_until(|| ready.exists()); From c0ab3984b8ded8db4f6ac7ed390639f5bebf38ee Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Fri, 2 Oct 2026 23:21:52 +1000 Subject: [PATCH 13/16] fix(run): answer a received signal before serving without a command (LAB-5118) Every return in cached() that runs no command (stale on a contended lock, a fresh value read under the lock, stale or suppression under an active marker) and the spawn-failure path now checks Supervisor::interrupted() first. A signal ck has already received ends the call with 128 + n and no output, instead of being ignored or answered alongside served output. A signal that arrives after the check is still answered by the signal thread, so ck stays killable while it blocks writing a served value. README and docs: ck exits 128 plus the number of a signal it received; which of several close together is unspecified. --- README.md | 4 ++-- src/child.rs | 30 ++++++++++++++++++++---------- src/run.rs | 40 ++++++++++++++++++++++++++++++---------- tests/run.rs | 28 ++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index b674b96..7773080 100644 --- a/README.md +++ b/README.md @@ -132,8 +132,8 @@ signals are still forwarded, so a command that handles the first one gracefully can still be stopped. An interrupted run discards the output ck was holding back, stores nothing, -and exits 128 plus the number of the first signal ck received. Signals that -arrive together count in signal-number order. Output already written, by an +and exits 128 plus the number of a signal it received; when several arrive +close together, which one is unspecified. Output already written, by an uncached call or past 20 MiB, stays written, and a cache entry being written is never left half-written. On an interrupted run streamed past 20 MiB, ck ends as soon as the command does, so the last part of the output it was still diff --git a/src/child.rs b/src/child.rs index 542ef0f..8c9f9df 100644 --- a/src/child.rs +++ b/src/child.rs @@ -40,7 +40,7 @@ pub enum Ran { /// ck received one of [`HANDLED`] while the command ran. Output ck was /// holding back is discarded and nothing is stored; output already /// written (an uncached call, or past the cap) stays written. The caller - /// exits 128 + n, where n is the first signal ck received. + /// exits 128 + n, where n is a signal ck received. Interrupted { signal: i32, }, @@ -82,7 +82,7 @@ enum State { /// from the terminal, and some programs treat a second interrupt as a hard /// abort. On any of them ck waits for the child to exit (not for whatever /// still holds its stdout), discards the output it held back, stores -/// nothing, and exits 128 + n for the first signal. Later TERM and HUP +/// nothing, and exits 128 + n for a signal it received. Later TERM and HUP /// signals are forwarded too. /// /// A signal ck inherited as ignored (`nohup`, a background job) is left @@ -94,9 +94,10 @@ pub struct Supervisor { /// main thread can reap a child killed by the same process-group signal. /// It holds the latest signal. received: Arc, - /// The first signal, recorded once, normally by the signal thread. Every path that - /// ends ck on a signal exits 128 + this, so two signals in a row give one - /// deterministic exit code. + /// The signal ck reports, recorded once, normally by the signal thread. + /// Every path that ends ck on a signal exits 128 + this, so they agree. + /// Which of several signals arriving close together it is, is + /// unspecified. first: Arc, /// SIGCHLD was inherited as ignored. ck handles it itself, or the kernel /// would reap the child before ck could read its exit status, and gives @@ -164,6 +165,15 @@ impl Supervisor { }) } + /// For a path that returns without running the command: the exit code + /// for a signal ck has already received, if any, so that the caller + /// prints nothing. A signal that arrives after this check is answered by + /// the signal thread, which ends ck even while it is still writing. + pub fn interrupted(&self) -> Option { + self.received() + .map(|latest| 128 + self.settle_first(latest)) + } + /// The latest handled signal ck received, if any. fn received(&self) -> Option { i32::try_from(self.received.load(Ordering::SeqCst)) @@ -200,16 +210,16 @@ impl Supervisor { None } - /// The signal that ends this run, once the child has exited: the first - /// one ck received. + /// The signal that ends this run, once the child has exited, if ck + /// received one. fn interrupted_by(&self, status: Option<&std::process::ExitStatus>) -> Option { let latest = self.received_after(status)?; Some(self.settle_first(latest)) } - /// The first signal, as the signal thread records it. Every signal that - /// set the flag also reached that thread, so wait for its record; record - /// `latest` only as a backstop, if the thread has died. + /// The signal to report, as the signal thread records it. Every signal + /// that set the flag also reached that thread, so wait for its record; + /// record `latest` only as a backstop, if the thread has died. fn settle_first(&self, latest: i32) -> i32 { let deadline = Instant::now() + Duration::from_secs(5); while Instant::now() < deadline { diff --git a/src/run.rs b/src/run.rs index b561e8c..1099a2e 100644 --- a/src/run.rs +++ b/src/run.rs @@ -71,11 +71,18 @@ impl Call<'_> { Ok(v) => v, Err(e) => return self.read_failed(e, &Supervisor::install()?), }; - let ControlFlow::Continue(stale) = self.fresh_or_stale(value) else { - return Ok(0); + let stale = match self.fresh_or_stale(value) { + ControlFlow::Break(fresh) => { + emit(&fresh); + return Ok(0); + } + ControlFlow::Continue(stale) => stale, }; let supervisor = Supervisor::install()?; + // From here a signal is ck's to answer. Each return below that runs + // no command first checks for one already received, so it is never + // ignored or answered alongside served output. // Another process holding the lock is filling. Serve what we have // rather than wait; with nothing servable, wait for its result. let lock = match fill_lock(&self.lock_path, stale.is_none()) { @@ -85,6 +92,9 @@ impl Call<'_> { unreachable!("only a call with stale output skips the wait") }); let marker = self.store.marker(&self.marker_key).ok().flatten(); + if let Some(code) = supervisor.interrupted() { + return Ok(code); + } return Ok(self.serve_stale(&stale, marker.as_ref(), None)); } Err(e) => { @@ -104,11 +114,21 @@ impl Call<'_> { (Ok(v), Ok(m)) => (v, m), (Err(e), _) | (_, Err(e)) => return self.read_failed(e, &supervisor), }; - let ControlFlow::Continue(stale) = self.fresh_or_stale(value) else { - return Ok(0); + let stale = match self.fresh_or_stale(value) { + ControlFlow::Break(fresh) => { + if let Some(code) = supervisor.interrupted() { + return Ok(code); + } + emit(&fresh); + return Ok(0); + } + ControlFlow::Continue(stale) => stale, }; let now = now_ms(); if let Some(m) = marker.filter(|m| m.is_active(now)) { + if let Some(code) = supervisor.interrupted() { + return Ok(code); + } if let Some(stale) = stale { return Ok(self.serve_stale(&stale, Some(&m), None)); } @@ -140,6 +160,9 @@ impl Call<'_> { Ran::SpawnFailed { code } => { // The command never started, so the origin was not reached: // no marker is set or bumped. + if let Some(code) = supervisor.interrupted() { + return code; + } return match stale { Some(stale) => self.serve_stale(&stale, None, None), None => code, @@ -198,17 +221,14 @@ impl Call<'_> { } } - /// Print a fresh value and break, or continue with the stale run (if + /// Break with a fresh value to print, or continue with the stale run (if /// `--stale` still allows one) for the caller to fall back on. - fn fresh_or_stale(&self, value: Option) -> ControlFlow<(), Option> { + fn fresh_or_stale(&self, value: Option) -> ControlFlow, Option> { let Some(envelope) = value else { return ControlFlow::Continue(None); }; match envelope.freshness(now_ms(), self.args.ttl_secs, self.args.stale_secs) { - Freshness::Fresh => { - emit(&envelope.stdout); - ControlFlow::Break(()) - } + Freshness::Fresh => ControlFlow::Break(envelope.stdout), Freshness::Stale { age_ms } => ControlFlow::Continue(Some(Stale { envelope, age_ms })), Freshness::Expired => ControlFlow::Continue(None), } diff --git a/tests/run.rs b/tests/run.rs index e20eaf1..5cb28db 100644 --- a/tests/run.rs +++ b/tests/run.rs @@ -1027,3 +1027,31 @@ fn repeated_signals_start_one_waiter() { let status = wait_exit(&mut child, Duration::from_secs(10), "ck did not exit"); assert_eq!(status.code(), Some(128 + 15)); } + +/// A signal that arrives while ck serves stored output into a full pipe still +/// ends ck: a served value must never make ck unkillable. +#[test] +fn a_signal_while_serving_stale_output_stops_ck() { + let s = Sandbox::new(); + let flags = ["--ttl", "1s", "--stale", "1h"]; + // More than a pipe buffer, so the serve blocks on a reader that never reads. + s.set("out", &"x".repeat(1024 * 1024)); + s.run(&flags); + sleep(PAST_TTL); + s.set("sleep", "5"); + let mut filler = s.spawn(&flags); + wait_until(|| s.runs() == 2); + // The lock is contended and the value is stale: ck serves it at once. + let mut server = s.spawn(&flags); + wait_handlers_installed(&server); + sleep(Duration::from_millis(200)); + signal(&server, rustix::process::Signal::TERM); + let status = wait_exit( + &mut server, + Duration::from_secs(3), + "ck kept blocking on the write", + ); + assert_eq!(status.code(), Some(128 + 15)); + let _ = filler.kill(); + let _ = filler.wait(); +} From f6a52665a34cef43af253983630cfaab05138147 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 3 Oct 2026 00:05:39 +1000 Subject: [PATCH 14/16] fix(run): a received signal wins over a spawn failure (LAB-5118) - Supervisor::run's spawn-failure arm now releases the state lock and returns Interrupted when ck has already received a signal, so the uncached path (stdin not /dev/null, lock or backend failure) exits 128 + n instead of racing to 127 or 126. The separate check in record() is gone: one site covers both callers. - README: a signal while ck prints stored output can cut it off, and ck still exits 128 + n, so $(...) callers must check the exit code. - Comments no longer claim every no-command return checks, or that the signal thread sees signals in order. --- README.md | 12 +++++++----- src/child.rs | 26 +++++++++++++++++--------- src/run.rs | 12 +++++------- 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 7773080..7c48acf 100644 --- a/README.md +++ b/README.md @@ -135,11 +135,13 @@ An interrupted run discards the output ck was holding back, stores nothing, and exits 128 plus the number of a signal it received; when several arrive close together, which one is unspecified. Output already written, by an uncached call or past 20 MiB, stays written, and a cache entry being written -is never left half-written. On an interrupted run streamed past 20 MiB, ck -ends as soon as the command does, so the last part of the output it was still -relaying can be cut off. A SIGINT or SIGQUIT that lands while ck is starting -the command may not reach it; ck still exits 128 plus the signal number when -the command exits. +is never left half-written. A signal while ck is printing stored output can +cut that output off; ck still exits 128 plus the signal number, so a caller +that captures output with `$(…)` must check the exit code. On an interrupted +run streamed past 20 MiB, ck ends as soon as the command does, so the last +part of the output it was still relaying can be cut off. A SIGINT or SIGQUIT +that lands while ck is starting the command may not reach it; ck still exits +128 plus the signal number when the command exits. A command that dies of a signal ck did not receive, such as one that kills itself, has simply failed: ck backs off and serves stored output as for any diff --git a/src/child.rs b/src/child.rs index 8c9f9df..e6110b7 100644 --- a/src/child.rs +++ b/src/child.rs @@ -257,7 +257,7 @@ impl Supervisor { let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); // A signal that arrived while this thread held the lock found the // signal thread blocked on it: start no command. Release the lock - // so the signal thread, which sees signals in order, decides the + // so the signal thread decides the // exit code. A SIGINT or SIGQUIT that lands inside `spawn` itself // may not reach the command; ck still exits 128 + n when the // command exits. A TERM or HUP there is forwarded once `spawn` @@ -275,15 +275,23 @@ impl Supervisor { } Err(e) => { let name = argv[0].to_string_lossy(); - return Ran::SpawnFailed { - code: if e.kind() == ErrorKind::NotFound { - warn(&format!("{name}: command not found")); - 127 - } else { - warn(&format!("{name}: cannot run it: {e}")); - 126 - }, + let code = if e.kind() == ErrorKind::NotFound { + warn(&format!("{name}: command not found")); + 127 + } else { + warn(&format!("{name}: cannot run it: {e}")); + 126 }; + // A signal ck already received wins over the spawn + // failure. Release the lock first: the signal thread + // records the signal under it. + drop(state); + if let Some(latest) = self.received() { + return Ran::Interrupted { + signal: self.settle_first(latest), + }; + } + return Ran::SpawnFailed { code }; } } }; diff --git a/src/run.rs b/src/run.rs index 1099a2e..05d9837 100644 --- a/src/run.rs +++ b/src/run.rs @@ -80,9 +80,9 @@ impl Call<'_> { }; let supervisor = Supervisor::install()?; - // From here a signal is ck's to answer. Each return below that runs - // no command first checks for one already received, so it is never - // ignored or answered alongside served output. + // From here a signal is ck's to answer. Each return below that serves + // stored output or is suppressed first checks for one already + // received, so it is never ignored or answered alongside output. // Another process holding the lock is filling. Serve what we have // rather than wait; with nothing servable, wait for its result. let lock = match fill_lock(&self.lock_path, stale.is_none()) { @@ -159,10 +159,8 @@ impl Call<'_> { let (code, stdout) = match supervisor.run(&self.args.command, true) { Ran::SpawnFailed { code } => { // The command never started, so the origin was not reached: - // no marker is set or bumped. - if let Some(code) = supervisor.interrupted() { - return code; - } + // no marker is set or bumped. `Supervisor::run` has already + // turned a received signal into `Interrupted`. return match stale { Some(stale) => self.serve_stale(&stale, None, None), None => code, From eae24bb42d13f2f95248a0ae1d46154739980e69 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 3 Oct 2026 01:00:36 +1000 Subject: [PATCH 15/16] fix(deps): require cachekit-rs 0.9 for the hit-path budget (LAB-5118) cachekit-rs 0.9.0 sweeps orphaned temp files on a file backend's first set instead of in FileBackend::build. A hit no longer reads the whole cache directory, so the 10,000-entry hit-path bench drops from 3.8 ms to 0.5 ms added on Linux. 0.9.0 also makes EncryptionLayer::new reject any master key that is not exactly 32 bytes. ck accepted 32 or more, so a longer CACHEKIT_MASTER_KEY would now fail inside the library with advice meant for Rust callers. ck checks for exactly 32 bytes itself and names `openssl rand -hex 32`, and the README says the key is exactly 64 hex characters. --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- README.md | 2 +- src/keys.rs | 7 +++++-- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 174a7d4..d40b650 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -192,9 +192,9 @@ dependencies = [ [[package]] name = "cachekit-rs" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "986848f4b095fae7897d045305a2ccca9a62c4f5a33e50cfc9c7a100d3b3d941" +checksum = "792ee2d3467ba052d5d5c75192dc6c3d234eddaa348f3f4af82fdbcb213c775e" dependencies = [ "async-trait", "blake2", diff --git a/Cargo.toml b/Cargo.toml index 7515eeb..5acd0fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ unsafe_code = "deny" [dependencies] blake2 = "0.10" cachekit-core = { version = "0.6", default-features = false, features = ["encryption"] } -cachekit-rs = { version = "0.8", default-features = false, features = ["encryption", "file"] } +cachekit-rs = { version = "0.9", default-features = false, features = ["encryption", "file"] } getrandom = "0.2" hex = "0.4" libc = "0.2" diff --git a/README.md b/README.md index 7c48acf..4e9a25a 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ To stop serving old output: Entries are encrypted with AES-256-GCM, and their file names are keyed hashes, so neither the output nor the command line appears on disk. The key comes from -`CACHEKIT_MASTER_KEY` (hex, an even number of characters and at least 64) if +`CACHEKIT_MASTER_KEY` (exactly 64 hex characters, from `openssl rand -hex 32`) if it is set. Otherwise ck creates a random key in `~/.config/ck/file.key` on first use, mode 0600. If that file is damaged, ck exits 125 and tells you to delete it; it never diff --git a/src/keys.rs b/src/keys.rs index 2a1845d..b9e2686 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -35,11 +35,13 @@ pub fn master_key(config_dir: &Path) -> Result, Fatal> { fn decode_master_key(hex_key: &OsString) -> Result, Fatal> { let bad = || { Fatal(format!( - "{MASTER_KEY_ENV} must be at least 32 bytes, hex-encoded (generate one with `openssl rand -hex 32`)" + "{MASTER_KEY_ENV} must be exactly 32 bytes, hex-encoded (generate one with `openssl rand -hex 32`)" )) }; let bytes = hex::decode(hex_key.as_bytes()).map_err(|_| bad())?; - if bytes.len() < 32 { + // Exactly 32: the one length every SDK accepts, and the only one + // `EncryptionLayer::new` takes since cachekit-rs 0.9. + if bytes.len() != 32 { return Err(bad()); } Ok(bytes) @@ -243,6 +245,7 @@ mod tests { fn master_key_hex() { assert!(decode_master_key(&"ab".repeat(32).into()).is_ok()); assert!(decode_master_key(&"ab".repeat(31).into()).is_err()); + assert!(decode_master_key(&"ab".repeat(33).into()).is_err()); assert!(decode_master_key(&"zz".repeat(32).into()).is_err()); } } From 8a859430021587c77de8417c6472adb22a5be0bd Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 3 Oct 2026 11:31:33 +1000 Subject: [PATCH 16/16] fix(store): report an entry that fails to decrypt (LAB-5118) A decrypt or AAD failure stays a miss that is never served, but it now prints one stderr line per call. A new master key also changes the keyed file name, so key rotation never reaches this path: a failure here means a corrupted or tampered entry, which should not pass silently. The message carries the library's error text and no key material. --- README.md | 4 +++- src/store.rs | 21 ++++++++++++++++++--- tests/run.rs | 7 +++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4e9a25a..917d591 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,9 @@ so neither the output nor the command line appears on disk. The key comes from it is set. Otherwise ck creates a random key in `~/.config/ck/file.key` on first use, mode 0600. If that file is damaged, ck exits 125 and tells you to delete it; it never -overwrites it. +overwrites it. A stored entry that fails to decrypt, because it was corrupted +or tampered with, is never served: ck treats it as a miss and prints one +warning. ck removes every `CACHEKIT_*` variable from the command's environment, so a log or environment dump from the command does not carry your keys. diff --git a/src/store.rs b/src/store.rs index e7b7bcc..0da43dd 100644 --- a/src/store.rs +++ b/src/store.rs @@ -1,5 +1,6 @@ //! Encrypted reads and writes against a cachekit-rs `Backend`. +use std::cell::Cell; use std::path::Path; use std::time::Duration; @@ -9,7 +10,7 @@ use cachekit::{BackendErrorKind, CachekitError, EncryptionLayer}; use crate::entry::{Envelope, Marker}; use crate::keys::TENANT; -use crate::Fatal; +use crate::{warn, Fatal}; /// Why a read produced no answer. pub enum ReadError { @@ -23,6 +24,7 @@ pub struct Store { runtime: tokio::runtime::Runtime, backend: Box, layer: EncryptionLayer, + decrypt_warned: Cell, } impl Store { @@ -49,6 +51,7 @@ impl Store { runtime, backend, layer, + decrypt_warned: Cell::new(false), }) } @@ -93,8 +96,20 @@ impl Store { "encryption is misconfigured: {e}" )))), // A decrypt or AAD failure is a miss: it is never served, not even - // as stale, and a marker that fails to decrypt is absent. - Err(_) => Ok(None), + // as stale, and a marker that fails to decrypt is absent. A new + // master key changes the keyed file name too, so a rotation never + // lands here: this means a corrupted or tampered entry, which is + // worth one line. The error names no key material. + Err(e) => { + // A call reads an entry twice (before and under the fill + // lock), so report it once. + if !self.decrypt_warned.replace(true) { + warn(&format!( + "a stored entry failed to decrypt ({e}); treating it as a miss" + )); + } + Ok(None) + } } } } diff --git a/tests/run.rs b/tests/run.rs index 5cb28db..bdc100e 100644 --- a/tests/run.rs +++ b/tests/run.rs @@ -450,6 +450,13 @@ fn a_corrupt_entry_is_a_miss_and_never_served() { "{}", stderr(&out) ); + // The tampered entry is reported once, on stderr. + assert_eq!( + stderr(&out).matches("failed to decrypt").count(), + 1, + "{}", + stderr(&out) + ); // A miss refills the entry; a backend error would have run uncached. assert_eq!(stdout(&s.run(&["--ttl", "1h", "--stale", "1h"])), "rerun\n"); assert_eq!(s.runs(), 2);