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..d40b650 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,1412 @@ # 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", + "libc", + "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.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "792ee2d3467ba052d5d5c75192dc6c3d234eddaa348f3f4af82fdbcb213c775e" +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..5acd0fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,32 @@ 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" +# 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] +blake2 = "0.10" +cachekit-core = { version = "0.6", default-features = false, features = ["encryption"] } +cachekit-rs = { version = "0.9", 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"] } + +[dev-dependencies] +rustix = { version = "1", features = ["pty"] } +tempfile = "3" diff --git a/README.md b/README.md index 2f907eb..917d591 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,161 @@ # 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 +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. 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...] +``` + +| 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` (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 +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. + +### Signals + +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. 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 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. 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 +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 + +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..e6110b7 --- /dev/null +++ b/src/child.rs @@ -0,0 +1,514 @@ +//! 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::{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}; +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; + +pub enum Ran { + /// ck could not start the command, so the origin was never reached. + SpawnFailed { + code: i32, + }, + Exited { + 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`] 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 a signal ck received. + Interrupted { + signal: i32, + }, +} + +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, + /// 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 { + /// 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 { + /// 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), +} + +/// 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 to exit (not for whatever +/// still holds its stdout), discards the output it held back, stores +/// 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 +/// 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>, + /// 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 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 + /// the child the ignored disposition back. + sigchld_ignored: bool, +} + +impl Supervisor { + pub fn install() -> Result { + 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}")); + // 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 { + 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 sigchld_ignored = inherited_ignored(SIGCHLD); + if sigchld_ignored { + signal_hook::flag::register(SIGCHLD, Arc::new(AtomicBool::new(false))).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)); + 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 + 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); + // 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)); + } + } + } + } + }) + .map_err(|e| Fatal(format!("cannot start the signal thread: {e}")))?; + Ok(Self { + state, + received, + first, + sigchld_ignored, + }) + } + + /// 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)) + .ok() + .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 + } + + /// 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 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 { + match i32::try_from(self.first.load(Ordering::SeqCst)) { + Ok(first) if first != 0 => return first, + _ => std::thread::sleep(Duration::from_millis(1)), + } + } + record_first(&self.first, latest).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()); + } + if self.sigchld_ignored { + ignore_sigchld_after_fork(&mut command); + } + + 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); + // 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 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: self.settle_first(latest), + }; + } + match command.spawn() { + Ok(child) => { + *state = State::Running(pid_of(child.id())); + child + } + Err(e) => { + let name = argv[0].to_string_lossy(); + 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 }; + } + } + }; + + 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; + 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 = waited + .map_err(std::io::Error::from) + .and_then(|_| child.wait()); + // 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.interrupted_by(status.as_ref().ok()) { + return Ran::Interrupted { signal }; + } + let status = match status { + Ok(status) => status, + Err(e) => { + warn(&format!("cannot collect the command's exit status: {e}")); + return Ran::StatusUnknown { stdout }; + } + }; + let code = status + .code() + .unwrap_or_else(|| 128 + status.signal().unwrap_or(0)); + 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. 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]; + 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"); + if emit(&buffer) && emit(&chunk[..n]) && stream(pipe, &mut chunk) { + return Stdout::Written; + } + return Stdout::OutputLost; + } + buffer.extend_from_slice(&chunk[..n]); + } +} + +/// 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 true, + Ok(n) => n, + Err(e) if e.kind() == ErrorKind::Interrupted => continue, + Err(e) => { + warn(&format!("cannot read the command's output: {e}")); + return true; + } + }; + if !emit(&chunk[..n]) { + return false; + } + } +} + +/// 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, true), + Err(prev) => (i32::try_from(prev).unwrap_or(sig), false), + } +} + +/// 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, + SIGHUP => Signal::HUP, + _ => return, + }; + 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 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`]. +pub(crate) fn block_handled_signals() { + 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`; callers pass SIG_BLOCK or + // SIG_SETMASK. + unsafe { + let mut handled: libc::sigset_t = std::mem::zeroed(); + libc::sigemptyset(&mut handled); + for sig in HANDLED { + libc::sigaddset(&mut handled, sig); + } + let mut old: libc::sigset_t = std::mem::zeroed(); + libc::pthread_sigmask(how, set.unwrap_or(&handled), &mut old); + old + } +} + +/// 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 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 + // 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 +} + +/// 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() + .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..2338b5e --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,216 @@ +//! 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 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()); + } + 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", + "5é", + "é", + ] { + 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..b9e2686 --- /dev/null +++ b/src/keys.rs @@ -0,0 +1,251 @@ +//! The master key, the auto-created file key, and the cache key. + +use std::ffi::OsString; +use std::fs; +use std::io::{ErrorKind, Read, 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 exactly 32 bytes, hex-encoded (generate one with `openssl rand -hex 32`)" + )) + }; + let bytes = hex::decode(hex_key.as_bytes()).map_err(|_| bad())?; + // 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) +} + +/// 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"); + 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() + )) + }; + // 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) + // 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() { + 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")); + } + 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 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")), + } +} + +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 key from [`KeyHasher::value_key`], used to name +/// its fill lock. +pub fn key_hash(value_key: &str) -> &str { + &value_key[KEY_PREFIX.len()..] +} + +#[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(&"ab".repeat(33).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..464fb74 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,68 @@ +//! `ck`, the CacheKit command-line client. +//! +//! This library exists to serve the `ck` binary and its tests. It is not a +//! stable API. + +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); + let _ = writeln!(std::io::stderr(), "{}", cli::USAGE); + return EXIT_CK_ERROR; + } + }; + match invocation { + cli::Invocation::Help => { + emit(format!("{}\n", cli::USAGE).as_bytes()); + 0 + } + cli::Invocation::Version => { + emit(format!("ck {}\n", env!("CARGO_PKG_VERSION")).as_bytes()); + 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. +/// A closed stderr is not worth dying over, so a failed write is dropped. +pub(crate) fn warn(message: &str) { + let _ = writeln!(std::io::stderr(), "ck: {message}"); +} + +/// 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(); + 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/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..05d9837 --- /dev/null +++ b/src/run.rs @@ -0,0 +1,350 @@ +//! `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::ops::ControlFlow; +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 Ok(run_uncached(&Supervisor::install()?, &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, &Supervisor::install()?), + }; + 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 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()) { + 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(); + if let Some(code) = supervisor.interrupted() { + return Ok(code); + } + 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(e, &supervisor), + }; + 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)); + } + 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); + drop(lock); + Ok(code) + } + + /// `--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()?; + let marker = self.store.marker(&self.marker_key).ok().flatten(); + Ok(self.record(&supervisor, None, marker)) + } + + /// 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: + // 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, + }; + } + Ran::Interrupted { signal } => return 128 + signal, + Ran::StatusUnknown { stdout } => { + return match (stale, stdout) { + (Some(stale), Stdout::Captured(_)) => self.serve_stale(&stale, None, None), + (_, stdout) => { + stdout.emit_captured(); + 126 + } + }; + } + Ran::Exited { code, stdout } => (code, stdout), + }; + + let now = now_ms(); + if code == 0 { + 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; + } + + if matches!(stdout, Stdout::OutputLost) { + // 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); + 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, _) => { + stdout.emit_captured(); + code + } + } + } + + /// 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> { + let Some(envelope) = value else { + return ControlFlow::Continue(None); + }; + match envelope.freshness(now_ms(), self.args.ttl_secs, self.args.stale_secs) { + Freshness::Fresh => ControlFlow::Break(envelope.stdout), + Freshness::Stale { age_ms } => ControlFlow::Continue(Some(Stale { envelope, age_ms })), + Freshness::Expired => ControlFlow::Continue(None), + } + } + + /// 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 + } + + /// Any backend error before the run disables caching for this call, never + /// the command. No further backend calls are made. + fn read_failed(&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)) + } + } + } +} + +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, + } +} + +/// 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 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; + } + 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..0da43dd --- /dev/null +++ b/src/store.rs @@ -0,0 +1,115 @@ +//! Encrypted reads and writes against a cachekit-rs `Backend`. + +use std::cell::Cell; +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::{warn, 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, + decrypt_warned: Cell, +} + +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() + .on_thread_start(crate::child::block_handled_signals) + .build() + .map_err(|e| Fatal(format!("cannot start the async runtime: {e}")))?; + Ok(Self { + runtime, + backend, + layer, + decrypt_warned: Cell::new(false), + }) + } + + 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. 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/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..b337ca8 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,154 @@ +//! 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; + /// `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" + ); + 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, 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_") { + 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..caeb182 --- /dev/null +++ b/tests/readme.rs @@ -0,0 +1,74 @@ +//! 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 { + 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() + .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..bdc100e --- /dev/null +++ b/tests/run.rs @@ -0,0 +1,1064 @@ +//! `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::{Command, 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); + // 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); + 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(); + 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", "1s", "--stale", "1h", "--refresh"])), + "new\n" + ); + + sleep(PAST_TTL); + s.set("exit", "4"); + s.set("out", "failure output\n"); + 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!( + !stderr(&out).contains("cache is unavailable"), + "{}", + 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); +} + +#[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(); + let ready = s.home().join("ready"); + let script = format!("touch '{}'; exec sleep 5", ready.display()); + let child = s + .ck(&["run", "--ttl", "1h", "--", "sh", "-c", &script]) + .spawn() + .unwrap(); + wait_until(|| ready.exists()); + 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"]); + 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(300), + "ck did not wait for the child" + ); + assert_eq!(stdout(&out), "", "an interrupted run printed its output"); + + 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"]); + // The holder's command is running, so it holds the lock. + wait_until(|| s.runs() == 1); + let waiter = s.spawn(&["--ttl", "1h"]); + wait_handlers_installed(&waiter); + 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); +} + +#[test] +fn a_process_group_interrupt_stores_nothing() { + // 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).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(); + 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(); + 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); + 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. + 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"); +} + +#[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(); + // 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!( + (code(&out), stdout(&out)), + (0, "hello\n".into()), + "{}", + stderr(&out) + ); + 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}" + ); + } +} + +#[test] +fn an_interrupt_the_command_traps_still_stores_nothing() { + // 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 ready = s.home().join("ready"); + let script = format!( + "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, + ]; + assert_eq!(code(&s.ck(&args).output().unwrap()), 0); + sleep(PAST_TTL); + fs::write(&flag, "").unwrap(); + for round in 0..20 { + let _ = fs::remove_file(&ready); + 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(); + 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. + // 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); + 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 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", &script]) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + wait_until(|| ready.exists()); + signal(&child, rustix::process::Signal::TERM); + // Time ck's own exit: the background sleep still holds the pipes. + 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" + ); +} + +#[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"); + // 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(); + 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)); +} + +/// 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(); +} + +/// 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) { + 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()) + .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 = 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" + ); + (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)); + signal(&child, rustix::process::Signal::TERM); + 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`. +/// 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, + msg: &str, +) -> std::process::ExitStatus { + let started = Instant::now(); + loop { + if let Some(status) = child.try_wait().unwrap() { + return status; + } + if started.elapsed() >= limit { + // 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}"); + } + 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() + }; + let baseline = threads(); + signal(&child, rustix::process::Signal::TERM); + // 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); + 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)); +} + +/// 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(); +}