diff --git a/.github/workflows/shell-e2e.yml b/.github/workflows/shell-e2e.yml new file mode 100644 index 0000000000..b19ce5a9b8 --- /dev/null +++ b/.github/workflows/shell-e2e.yml @@ -0,0 +1,78 @@ +name: shell E2E + +on: + pull_request: + paths: + - 'shell/**' + - '.github/workflows/shell-e2e.yml' + workflow_dispatch: + +concurrency: + group: shell-e2e-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + e2e: + name: Harness (default + jailed) + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + + - name: Rewrite SSH to HTTPS for public deps + run: git config --global url."https://github.com/".insteadOf "ssh://git@github.com/" + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry & build + uses: Swatinem/rust-cache@v2 + with: + workspaces: shell + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: shell/tests/e2e/workers/harness/package-lock.json + + - name: Install iii engine (latest from main) + run: | + curl -fsSL --retry 3 --retry-connrefused --retry-delay 5 \ + https://install.iii.dev/iii/main/install.sh | sh + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Verify engine + run: iii --version + + # The jailed suite (cases-vuln-repro-jailed.ts) hardcodes JAIL_ROOT to + # /private/tmp/iii-shell-jailed-root — that's the macOS layout where + # /private/tmp is the canonical location of /tmp. On Linux runners + # /private doesn't exist, so create the parents world-writable so the + # non-root harness can mkdir under it. + - name: Pre-create jail root for run-tests-jailed.sh + run: | + sudo mkdir -p /private/tmp + sudo chmod 1777 /private/tmp + + - name: Run default harness (144 cases) + working-directory: shell/tests/e2e + run: ./run-tests.sh + + # Reuse the binary built by the default suite — saves ~40s of cargo build. + - name: Run jailed harness (2 cases, S-C1 regression) + working-directory: shell/tests/e2e + run: ./run-tests-jailed.sh --no-build + + - name: Upload report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: shell-e2e-report + path: | + shell/tests/e2e/reports/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 92245e39bc..5434be87b8 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,6 @@ package-lock.json pnpm-lock.yaml yarn.lock !iii-database/tests/e2e/workers/harness/package-lock.json -iii_workers/ \ No newline at end of file +!shell/tests/e2e/workers/harness/package-lock.json +iii_workers/ +graphify-out \ No newline at end of file diff --git a/shell/Cargo.lock b/shell/Cargo.lock new file mode 100644 index 0000000000..cae23410b5 --- /dev/null +++ b/shell/Cargo.lock @@ -0,0 +1,2710 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-hex" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20d9a563d167a9cce0f94153382b33cb6eded6dfabff03c69ad65a28ea1514e0" +dependencies = [ + "cfg-if", + "cpufeatures", + "proptest", + "serde_core", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[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 = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[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-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "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.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "iii-sdk" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0226f7ce0d9071f9cb75ea7b7ac1241b15282915ccd41d9bbd2ee0db94f90c6" +dependencies = [ + "async-trait", + "futures-util", + "hostname", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost", + "reqwest", + "schemars", + "serde", + "serde_json", + "sysinfo", + "thiserror", + "tokio", + "tokio-tungstenite", + "tracing", + "uuid", +] + +[[package]] +name = "iii-shell" +version = "0.3.0" +dependencies = [ + "anyhow", + "async-trait", + "base64", + "clap", + "iii-sdk", + "libc", + "once_cell", + "regex", + "schemars", + "serde", + "serde_json", + "serde_yaml", + "shell-words", + "thiserror", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", + "walkdir", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[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.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[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 = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "opentelemetry" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror", + "tracing", +] + +[[package]] +name = "opentelemetry-http" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f" +dependencies = [ + "base64", + "const-hex", + "opentelemetry", + "opentelemetry_sdk", + "prost", + "serde", + "serde_json", + "tonic", + "tonic-prost", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "rand", + "thiserror", + "tokio", + "tokio-stream", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "unarray", +] + +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[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 = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[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.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[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.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" +dependencies = [ + "async-trait", + "base64", + "bytes", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[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 = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[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.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/shell/Cargo.toml b/shell/Cargo.toml new file mode 100644 index 0000000000..8aeee58b97 --- /dev/null +++ b/shell/Cargo.toml @@ -0,0 +1,32 @@ +[workspace] + +[package] +name = "iii-shell" +version = "0.3.0" +edition = "2021" +publish = false + +[[bin]] +name = "iii-shell" +path = "src/main.rs" + +[dependencies] +iii-sdk = "=0.11.3" +schemars = { version = "0.8", features = ["uuid1"] } +libc = "0.2" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal", "process", "time", "io-util", "fs"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +anyhow = "1" +thiserror = "2" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +clap = { version = "4", features = ["derive"] } +uuid = { version = "1", features = ["v4"] } +once_cell = "1" +regex = "1" +shell-words = "1" +async-trait = "0.1" +base64 = "0.22" +walkdir = "2" diff --git a/shell/README.md b/shell/README.md new file mode 100644 index 0000000000..26f77b71df --- /dev/null +++ b/shell/README.md @@ -0,0 +1,350 @@ +# iii-shell + +Unix shell + filesystem worker for iii agents. Every agent that needs to touch +the OS (run a build, read a file, list a directory, call a CLI) goes through +this worker so allowlists, timeouts, output caps, and jail/denylist enforcement +live in one place. Filesystem operations are exposed under `shell::fs::*` with +the same enforcement surface plus optional sandbox-target forwarding. + +Current crate version: **0.3.0**. Wire shape last broken in 0.3.0 (channel-based +`shell::fs::write` / `shell::fs::read`). + +## Contents + +- [Quickstart](#quickstart) — go from nothing to a working `shell::exec` in 4 commands. +- [Functions](#functions) — the 15 function ids and their request/response shapes. +- [Filesystem operations: `shell::fs::*`](#filesystem-operations-shellfs) +- [Configuration](#configuration) — full defaults table + `fs` and `sandbox` sections. +- [Safety](#safety) — what is enforced, what is advisory, what the threat model is. +- [Troubleshooting](#troubleshooting) +- [Tests](#tests) +- [What this is NOT](#what-this-is-not) +- [Deferred](#deferred) + +## Quickstart + +```bash +# 1. Install the iii engine (drops the binary at $HOME/.local/bin/iii by default) +curl -fsSL https://install.iii.dev/iii/main/install.sh | sh + +# 2. Build this worker +cargo build --release --bin iii-shell + +# 3. Wire the binary where the engine looks (registered worker name = `shell`, +# per iii.worker.yaml — fall back to ~/.iii/workers/iii-shell if your +# engine resolves by binary name) +mkdir -p ~/.iii/workers +ln -sfn $(pwd)/target/release/iii-shell ~/.iii/workers/shell + +# 4. Start the engine (it spawns the worker). Pin a host_root or set +# fs.allow_unjailed: true in config.yaml first — the worker refuses to +# start unjailed by default; see Configuration below. +iii -c ./config.yaml +``` + +From a separate process (TS shown — Rust caller flow appears in the streaming +sections below): + +```ts +import { registerWorker } from 'iii-sdk'; + +// `registerWorker` returns a dual handle: it can register your own functions +// AND trigger functions registered elsewhere. Here we use it client-side. +const iii = registerWorker('ws://127.0.0.1:49134'); +const out = await iii.trigger({ + function_id: 'shell::exec', + payload: { command: 'echo', args: ['hello'] }, +}); +// → { exit_code: 0, stdout: 'hello\n', stderr: '', duration_ms: 12, +// timed_out: false, stdout_truncated: false, stderr_truncated: false } +``` + +End-to-end harness (`tests/e2e/run-tests.sh`) exercises every function and is +the canonical reference for wire shapes if anything below feels under-specified. + +## Functions + +15 function ids: 5 `shell::*` (process lifecycle), 10 `shell::fs::*` +(filesystem). The fs surface is documented in its own section below. + +### `shell::*` — process lifecycle + +| id | request | response | +|----|---------|----------| +| `shell::exec` | `{ command: string, args?: string[], timeout_ms?: number }` | `{ exit_code, stdout, stderr, duration_ms, timed_out, stdout_truncated, stderr_truncated }` | +| `shell::exec_bg` | `{ command: string, args?: string[] }` | `{ job_id: string, argv: string[] }` | +| `shell::kill` | `{ job_id: string }` | `{ job_id, killed, status, reason? }` | +| `shell::status` | `{ job_id: string }` | `{ job: JobRecord }` (full record — argv + captured stdout/stderr) | +| `shell::list` | `{}` | `{ jobs: JobSummary[], count }` (see redaction note below) | + +`command` accepts either a bare argv0 (`"ls"`) plus a separate `args` array, or +a shell-words string (`"ls -la /tmp"`) when `args` is omitted. `parse_argv` +splits the latter via `shell_words`; no expansion is performed (no `$VAR`, +globs, or command substitution). + +`timeout_ms` is clamped to `max_timeout_ms` from config. Negative or non-numeric +values silently fall back to `default_timeout_ms`. `0` triggers an immediate +timeout (returns with `timed_out: true`). + +#### `shell::list` returns summaries, not full records + +`shell::list` returns a `JobSummary` per record: +```jsonc +{ + "id": "job-", + "status": "running", // running | finished | killed | failed + "started_at_ms": 1714780800000, + "finished_at_ms": 1714780801000, // null while running + "exit_code": 0, // null until terminated + "stdout_truncated": false, + "stderr_truncated": false +} +``` + +`argv`, `stdout`, and `stderr` are deliberately omitted: the global JOBS map is +process-wide and has no per-caller scope, so any caller could otherwise read +every other caller's command line and captured output (which may embed +credentials). The full record stays reachable via `shell::status ` — +the random `job_id` UUID acts as an unguessable per-record capability. + +## Filesystem operations: `shell::fs::*` + +Ten functions covering both the host filesystem and a sandbox VM via the same +trigger surface. Every request carries a `target` field selecting the backend; +`target` defaults to `{ "kind": "host" }` when omitted. + +### Target envelope + +```jsonc +// Host (default — equivalent to omitting `target`): +{ "target": { "kind": "host" }, "path": "/abs/path" } + +// Sandbox (engine forwards to sandbox::fs::* via iii.trigger): +{ "target": { "kind": "sandbox", "sandbox_id": "" }, "path": "/foo" } +``` + +`sandbox_id` comes from whatever started the sandbox VM — typically the engine +or a sibling `sandbox-*` worker. `shell` does not start sandboxes; it only +forwards into existing ones. + +### Functions + +- `shell::fs::ls` — list directory entries +- `shell::fs::stat` — file/dir metadata +- `shell::fs::mkdir` — create directory (`mode`, `parents`) +- `shell::fs::rm` — remove path (`recursive`) +- `shell::fs::chmod` — change mode (`mode`, `recursive`); supports `uid`/`gid` if running as root. Recursive walks **skip symlink entries** so chmod(2)/chown(2) don't deref to a target outside the walk root. +- `shell::fs::mv` — rename (`overwrite`); same-fs rename or cross-fs (EXDEV) copy+rename+unlink +- `shell::fs::grep` — regex search with gitignore-style globs, binary-file skip, line truncation +- `shell::fs::sed` — find-and-replace, atomic temp+rename per file, capture refs in regex mode; caller passes either `files` (explicit list) **or** `path` (walk a tree) +- `shell::fs::write` — write via `StreamChannelRef` (`mode`, `parents`; capped mid-stream by `max_write_bytes` when non-zero) +- `shell::fs::read` — read via `StreamChannelRef` (capped pre-flight by `max_read_bytes` when non-zero) + +Response shapes and `S2xx` error codes match the engine daemon's +`sandbox::fs::*` exactly. Codes the host backend can produce: `S210`, `S211`, +`S212`, `S213`, `S214`, `S215`, `S216`, `S217`, `S218`, `S219`. Sandbox-target +calls additionally surface engine-level codes forwarded verbatim — most +commonly `S002` (sandbox not found) and `S001` (invalid request). + +### Sandbox forwarding + +Sandbox-target `shell::fs::write|read` are pure passthroughs. The worker +forwards `{ sandbox_id, path, mode, parents, content }` to the engine's +`sandbox::fs::write` (or `{ sandbox_id, path }` to `sandbox::fs::read`) via +`iii.trigger`. The caller's `StreamChannelRef` flows through the worker +verbatim — engine reads/writes the caller's channel directly, the worker is +never in the byte path. Caps configured under `fs.*` are NOT applied on the +sandbox path; engine policy applies instead. + +### Wire: `shell::fs::write` and `shell::fs::read` (changed in 0.3.0) + +Both ops use `iii_sdk::channels::StreamChannelRef` on the wire instead of a +base64 JSON field. The other 8 ops (`ls`, `stat`, `mkdir`, `rm`, `chmod`, `mv`, +`grep`, `sed`) are unchanged. + +#### `shell::fs::write` — request + +```jsonc +{ + "target": { "kind": "host" }, // default; or { "kind": "sandbox", "sandbox_id": "" } + "path": "/abs/path", + "mode": "0644", + "parents": false, + "content": { // caller-allocated StreamChannelRef + "channel_id": "...", + "access_key": "...", + "direction": "read" + } +} +``` + +Caller flow (Rust): +```rust +let ch = iii.create_channel(Some(64)).await?; +let reader_ref = ch.reader_ref.clone(); +let writer = ch.writer; +let bytes = my_bytes; +let writer_task = tokio::spawn(async move { + writer.write(&bytes).await?; + writer.close().await +}); +let resp: WriteResponse = iii.trigger("shell::fs::write", json!({ + "target": { "kind": "host" }, + "path": "/tmp/x", + "mode": "0644", + "parents": false, + "content": reader_ref, +})).await?; +writer_task.await??; +``` + +Response: `{ "bytes_written": N, "path": "/abs/path" }`. + +#### `shell::fs::read` — response + +Request: `{ "target": ..., "path": "/abs/path" }`. + +Response: +```jsonc +{ + "content": { "channel_id": "...", "access_key": "...", "direction": "read" }, + "size": 1234, + "mode": "0644", + "mtime": 1714780800 +} +``` + +Caller flow (Rust): +```rust +let resp: ReadResponse = iii.trigger("shell::fs::read", json!({ + "target": { "kind": "host" }, "path": "/tmp/x" +})).await?; +let reader = ChannelReader::new(iii.address(), &resp.content); +let bytes = reader.read_all().await?; +``` + +## Configuration + +The CLI takes three flags: + +| flag | default | purpose | +|------|---------|---------| +| `--config ` | `./config.yaml` | YAML config (shape below) | +| `--url ` | `ws://127.0.0.1:49134` | iii engine WebSocket | +| `--manifest` | off | print the JSON function manifest and exit (use for tooling/introspection) | + +### YAML defaults + +| key | default | enforced where | +|-----|---------|----------------| +| `max_timeout_ms` | `30000` | hard cap; per-call `timeout_ms` clamped to this | +| `default_timeout_ms` | `10000` | applied when caller omits `timeout_ms` | +| `max_output_bytes` | `1048576` (1 MiB) | stdout/stderr truncated; `*_truncated` flagged | +| `working_dir` | `null` | pins cwd for spawned commands when set | +| `inherit_env` | `false` | when `false`, only `allowed_env` keys are forwarded | +| `allowed_env` | `[PATH, HOME, LANG, LC_ALL, TERM]` | env passthrough allowlist | +| `allowlist` | `[]` (open) | command basename allowlist; empty = open | +| `denylist_patterns` | `[]` | advisory regex tripwire on `argv.join(" ")` (see Safety) | +| `max_concurrent_jobs` | `16` | rejects new `exec_bg` past the cap | +| `job_retention_secs` | `3600` | finished jobs pruned on every `shell::list` | +| `fs.host_root` | `null` | jail root; required unless `fs.allow_unjailed: true` | +| `fs.allow_unjailed` | `false` | explicit opt-in to running with `host_root: null` | +| `fs.max_read_bytes` | `0` (unlimited) | pre-flight cap via `fs::metadata` (`S218`) | +| `fs.max_write_bytes` | `0` (unlimited) | mid-stream cap during write (`S218`) | +| `fs.denylist_paths` | `[]` | absolute-prefix denylist; rejected with `S215` | +| `sandbox.enabled` | `true` | `false` → every sandbox-target call returns `S210` | + +### `fs` section + +```yaml +fs: + # SET this to a directory you intend to expose to shell::fs::*. The worker + # refuses to start when host_root is null AND allow_unjailed is false — + # the alternative is "the entire host filesystem is reachable behind only + # the advisory denylist", which is rarely intended. + host_root: /var/lib/iii-shell + allow_unjailed: false + + max_read_bytes: 16777216 # 16 MiB; 0 = unlimited + max_write_bytes: 16777216 + + denylist_paths: + - /etc/passwd + - /etc/shadow +``` + +`max_write_bytes` is enforced **mid-stream**: the worker aborts and unlinks the +temp file when the running byte total would exceed the cap (→ `S218`). +`max_read_bytes` is enforced **pre-flight** via `fs::metadata` — `S218` is +returned before any channel is allocated. + +`denylist_paths` is enforced even with `host_root` set: a denylisted absolute +prefix is always rejected with `S215`. Paths are canonicalized through every +existing ancestor before the prefix check, so symlinks pointing at denylisted +targets — or symlinks whose targets escape `host_root` — are blocked. + +### `sandbox` section + +```yaml +sandbox: + enabled: true # set false to refuse target.kind == sandbox +``` + +When `sandbox.enabled = false`, every sandbox-target request returns +`S210 "sandbox target disabled in config"`. + +## Safety + +- `allowlist` — if non-empty, command (basename) must be present. Empty list = open. +- `denylist_patterns` — regex patterns tested against `argv.join(" ")`. **Advisory tripwires, not a security boundary**: any allowlisted shell or interpreter (`sh`, `node`, `python3`, …) can construct the forbidden token at runtime via shell variables, `eval`, `${IFS}`, base64, etc., evading any literal-form pattern. Useful for catching honest typos (operator runs `rm -rf /` directly), useless against a determined caller. The real boundary is the sandbox backend. + ```yaml + denylist_patterns: + - "rm\\s+-rf\\s+/" # honest-typo guard, not enforcement + - ":\\(\\)\\s*\\{\\s*:\\|" # fork bomb + - "mkfs" + ``` +- `max_timeout_ms` — hard cap; per-call `timeout_ms` is clamped. +- `max_output_bytes` — stdout/stderr truncated at this size, flagged via `*_truncated`. +- `inherit_env: false` by default. Only variables in `allowed_env` are forwarded. +- `working_dir` — pins cwd. +- `max_concurrent_jobs` — rejects new `exec_bg` requests past the cap. +- `job_retention_secs` — old finished jobs are pruned on every `shell::list` call. +- `shell::list` returns summaries only (no argv / stdout / stderr); full records require the `job_id` capability via `shell::status`. + +### Threat model + +The host backend's path-validation gate is check-then-use: there is a TOCTOU +window between validation and the `std::fs::*` call. Validation walks to the +longest existing ancestor, canonicalizes that (resolving symlinks in the +existing portion), and then lexically collapses the non-existent tail before +the `starts_with(host_root)` check — so a symlink whose target escapes the +jail can't slip through the lexical fallback. But the worker is intended for +trusted caller pipelines; for untrusted input, use the sandbox backend. + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `fs.host_root is unset and fs.allow_unjailed is false — refusing to start unjailed` | Default config no longer permits running unjailed. | Set `fs.host_root` to a directory, OR set `fs.allow_unjailed: true` to accept the unjailed surface. | +| `command 'xyz' not in allowlist` | `allowlist` is non-empty and doesn't include the binary's basename. | Add it to `allowlist`, or empty the list to allow anything. | +| Worker never connects to engine | Engine isn't running or isn't bound on the URL the worker is configured for. | Start the engine first; check `--url` matches. The default WS port is 49134. | +| Engine started but doesn't see the worker | Binary isn't symlinked at `~/.iii/workers/shell` (or `~/.iii/workers/iii-shell` if your engine resolves by binary name). | `ln -sfn $(pwd)/target/release/iii-shell ~/.iii/workers/shell` | +| `S215 path escapes host_root` on a path inside the jail | A symlink in the path resolves outside the jail. | Resolve the symlink yourself, or move the target inside `host_root`. | + +## Tests + +- `tests/e2e/` — TypeScript harness, **143 default cases + 1 jailed case**. The default suite (`run-tests.sh`) covers happy paths, safety guardrails, jobs lifecycle, fs across host and sandbox targets, adversarial protocol-break suites for streaming/exec/jobs/encoding/concurrency, plus 4 vulnerability-regression cases under `cases-vuln-repro.ts`. The jailed suite (`run-tests-jailed.sh`, against `config-jailed.yaml`) covers the symlink-parent jail-escape regression. +- `tests/*.rs` — Rust integration tests (`jobs_lifecycle`, `host_fs_branches`, `sandbox_dispatch`, `function_handlers`) covering the host backend branches, sandbox forwarder, and every typed-registration handler. Run with `cargo test`. +- Line coverage measured with `cargo tarpaulin` sits around 65%; `jobs.rs` is at 100% and the sandbox dispatch path is fully exercised. + +## What this is NOT + +- **Not a PTY.** Interactive shells, TUIs, password prompts all break. +- **Not a sandbox.** For isolation use `sandbox-docker` / `sandbox-firecracker` and call through `shell` only for trusted commands. +- **Not a streaming surface.** Foreground `shell::exec` returns once the process exits and stdout/stderr are captured whole. Live streaming is `shell::exec_stream` (deferred). +- **Not per-caller-isolated.** The JOBS registry is a process-wide singleton. `shell::list` redacts argv/stdout/stderr to limit the blast radius; full records are cap-gated by `job_id`. + +## Deferred + +- `shell::exec_stream` — live stdout/stderr via iii Streams (for long-running commands). Next iteration. +- Per-caller JOBS scoping (would replace the redact-summary-only mitigation in `shell::list` with proper isolation). diff --git a/shell/build.rs b/shell/build.rs new file mode 100644 index 0000000000..33143a5e04 --- /dev/null +++ b/shell/build.rs @@ -0,0 +1,6 @@ +fn main() { + println!( + "cargo:rustc-env=TARGET={}", + std::env::var("TARGET").unwrap_or_default() + ); +} diff --git a/shell/config.yaml b/shell/config.yaml new file mode 100644 index 0000000000..6744b3fc27 --- /dev/null +++ b/shell/config.yaml @@ -0,0 +1,83 @@ +max_timeout_ms: 30000 +default_timeout_ms: 10000 +max_output_bytes: 1048576 +working_dir: null +inherit_env: false +allowed_env: + - PATH + - HOME + - LANG + - LC_ALL + - TERM +# Default allowlist is intentionally read-only. Tools that can shell out +# (git hooks, curl -o/file://, find -exec, awk system(), sed e/-i, cargo +# build.rs, node -e, python3 -c, npm run, env ) are left out on +# purpose — add them per deployment after you've decided on the threat +# model. This worker is NOT a sandbox. Use `printenv` for read-only env +# inspection; `env` is excluded because `env ` execs arbitrary +# programs while passing argv[0]=="env" through the allowlist gate. +allowlist: + - ls + - cat + - pwd + - echo + - grep + - wc + - head + - tail + - sort + - uniq + - cut + - date + - whoami + - hostname + - which + - jq + - uname + - df + - du + - ps + - printenv + - basename + - dirname +# Denylist patterns are advisory, not a security boundary. They run as +# regex against `argv.join(" ")`, so a caller invoking an allowlisted +# shell or interpreter (sh, node, python, etc.) can bypass any pattern +# by constructing the forbidden token at runtime — variables, eval, +# IFS tricks, base64, etc. Treat these as a tripwire for honest +# mistakes; the actual security boundary is the sandbox backend. +denylist_patterns: + - "rm\\s+-rf\\s+/" + - ":\\(\\)\\s*\\{\\s*:\\|" + - "mkfs" + - "dd\\s+if=" + - "shutdown" + - "reboot" + - "/etc/passwd" + - "/etc/shadow" + # Sub-execution / write escapes for commonly-added tools + - "\\bfind\\b[^|;&]*-exec(dir)?\\b" + - "\\bawk\\b[^|;&]*system\\s*\\(" + - "\\bsed\\b[^|;&]*(-i\\b|\\be\\b)" + - "\\bcurl\\b[^|;&]*(file://|-o\\s|--output-dir\\b|-F\\s+@)" + - "\\bgit\\b[^|;&]*(--upload-pack|--receive-pack|core\\.pager|core\\.hooksPath|GIT_SSH_COMMAND)" + - "\\b(node|python3?)\\b[^|;&]*\\s-(e|c)\\b" + - "\\bnpm\\b[^|;&]*\\brun\\b" +max_concurrent_jobs: 16 +job_retention_secs: 3600 + +fs: + # SET host_root to a directory you intend to expose to shell::fs::*. + # When unset, the worker refuses to start unless allow_unjailed is true + # (because the alternative is "the entire filesystem is reachable + # behind only the advisory denylist", which is rarely intended). + host_root: null + allow_unjailed: false + max_read_bytes: 16777216 + max_write_bytes: 16777216 + denylist_paths: + - /etc/passwd + - /etc/shadow + +sandbox: + enabled: true diff --git a/shell/iii.worker.yaml b/shell/iii.worker.yaml new file mode 100644 index 0000000000..0e7a4329f2 --- /dev/null +++ b/shell/iii.worker.yaml @@ -0,0 +1,7 @@ +iii: v1 +name: shell +language: rust +deploy: binary +manifest: Cargo.toml +bin: iii-shell +description: Unix shell + filesystem worker — exec with allowlist/denylist/timeout/output caps and background jobs; fs::ls|stat|mkdir|rm|chmod|mv|grep|sed|read|write with host jail, denylist, size caps, and sandbox-target forwarding diff --git a/shell/src/config.rs b/shell/src/config.rs new file mode 100644 index 0000000000..37ce2c0b83 --- /dev/null +++ b/shell/src/config.rs @@ -0,0 +1,374 @@ +use anyhow::{Context, Result}; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ShellConfig { + #[serde(default = "default_max_timeout_ms")] + pub max_timeout_ms: u64, + + #[serde(default = "default_default_timeout_ms")] + pub default_timeout_ms: u64, + + #[serde(default = "default_max_output_bytes")] + pub max_output_bytes: usize, + + #[serde(default)] + pub working_dir: Option, + + #[serde(default)] + pub inherit_env: bool, + + #[serde(default = "default_allowed_env")] + pub allowed_env: Vec, + + #[serde(default)] + pub allowlist: Vec, + + #[serde(default)] + pub denylist_patterns: Vec, + + #[serde(default = "default_max_concurrent_jobs")] + pub max_concurrent_jobs: usize, + + #[serde(default = "default_job_retention_secs")] + pub job_retention_secs: u64, + + #[serde(default)] + pub fs: FsConfig, + + #[serde(default)] + pub sandbox: SandboxConfig, + + #[serde(default, skip)] + pub compiled_denylist: Vec, +} + +fn default_max_timeout_ms() -> u64 { + 30_000 +} +fn default_default_timeout_ms() -> u64 { + 10_000 +} +fn default_max_output_bytes() -> usize { + 1_048_576 +} +fn default_allowed_env() -> Vec { + vec!["PATH", "HOME", "LANG", "LC_ALL", "TERM"] + .into_iter() + .map(String::from) + .collect() +} +fn default_max_concurrent_jobs() -> usize { + 16 +} +fn default_job_retention_secs() -> u64 { + 3600 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FsConfig { + #[serde(default)] + pub host_root: Option, + /// Operator opt-in for running with `host_root: null`. When false (the + /// default) the worker refuses to start unjailed — the entire host + /// filesystem is reachable through `shell::fs::*` aside from the small + /// denylist, which is rarely what the operator actually wants. Setting + /// this to true is equivalent to acknowledging that fact (test + /// harnesses, sandbox-only deployments). + #[serde(default)] + pub allow_unjailed: bool, + #[serde(default = "default_max_read_bytes")] + pub max_read_bytes: usize, + #[serde(default = "default_max_write_bytes")] + pub max_write_bytes: usize, + #[serde(default)] + pub denylist_paths: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SandboxConfig { + #[serde(default = "default_sandbox_enabled")] + pub enabled: bool, +} + +fn default_max_read_bytes() -> usize { + 0 +} +fn default_max_write_bytes() -> usize { + 0 +} +fn default_sandbox_enabled() -> bool { + true +} + +impl Default for FsConfig { + fn default() -> Self { + Self { + host_root: None, + allow_unjailed: false, + max_read_bytes: default_max_read_bytes(), + max_write_bytes: default_max_write_bytes(), + denylist_paths: Vec::new(), + } + } +} + +impl Default for SandboxConfig { + fn default() -> Self { + Self { + enabled: default_sandbox_enabled(), + } + } +} + +impl Default for ShellConfig { + fn default() -> Self { + Self { + max_timeout_ms: default_max_timeout_ms(), + default_timeout_ms: default_default_timeout_ms(), + max_output_bytes: default_max_output_bytes(), + working_dir: None, + inherit_env: false, + allowed_env: default_allowed_env(), + allowlist: Vec::new(), + denylist_patterns: Vec::new(), + max_concurrent_jobs: default_max_concurrent_jobs(), + job_retention_secs: default_job_retention_secs(), + fs: FsConfig::default(), + sandbox: SandboxConfig::default(), + compiled_denylist: Vec::new(), + } + } +} + +pub fn load_config(path: &str) -> Result { + let content = fs::read_to_string(path).with_context(|| format!("read {}", path))?; + let mut cfg: ShellConfig = + serde_yaml::from_str(&content).with_context(|| format!("parse {}", path))?; + cfg.compile_denylist()?; + cfg.validate_fs_jail()?; + Ok(cfg) +} + +impl ShellConfig { + pub fn compile_denylist(&mut self) -> Result<()> { + self.compiled_denylist = self + .denylist_patterns + .iter() + .map(|p| Regex::new(p).with_context(|| format!("bad denylist pattern: {}", p))) + .collect::>>()?; + Ok(()) + } + + /// Refuse to start with the host backend exposing the entire filesystem + /// behind only the (advisory) denylist — the operator must either pin a + /// host_root jail or explicitly opt in via `fs.allow_unjailed: true`. + pub fn validate_fs_jail(&self) -> Result<()> { + if self.fs.host_root.is_none() && !self.fs.allow_unjailed { + anyhow::bail!( + "fs.host_root is unset and fs.allow_unjailed is false — refusing to start \ + unjailed. Set fs.host_root to a directory you intend to expose, or set \ + fs.allow_unjailed: true to accept that the entire host filesystem is \ + reachable through shell::fs::* (subject only to the advisory denylist)." + ); + } + Ok(()) + } + + pub fn is_command_allowed(&self, argv: &[String]) -> Result<(), String> { + let cmd = argv + .first() + .ok_or_else(|| "empty command".to_string())? + .clone(); + + if !self.allowlist.is_empty() { + let base = std::path::Path::new(&cmd) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(&cmd); + if !self.allowlist.iter().any(|a| a == base || a == &cmd) { + return Err(format!("command '{}' not in allowlist", base)); + } + } + + let joined = argv.join(" "); + for re in &self.compiled_denylist { + if re.is_match(&joined) { + return Err(format!("command matches denylist: {}", re.as_str())); + } + } + Ok(()) + } + + pub fn resolve_timeout(&self, requested: Option) -> u64 { + let t = requested.unwrap_or(self.default_timeout_ms); + t.min(self.max_timeout_ms) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg_with(allow: Vec<&str>, deny: Vec<&str>) -> ShellConfig { + let mut c = ShellConfig { + allowlist: allow.into_iter().map(String::from).collect(), + denylist_patterns: deny.into_iter().map(String::from).collect(), + ..Default::default() + }; + c.compile_denylist().unwrap(); + c + } + + #[test] + fn test_defaults() { + let c = ShellConfig::default(); + assert_eq!(c.max_timeout_ms, 30_000); + assert_eq!(c.default_timeout_ms, 10_000); + assert!(!c.inherit_env); + assert_eq!(c.max_concurrent_jobs, 16); + } + + #[test] + fn test_allowlist_permits() { + let c = cfg_with(vec!["ls", "cat"], vec![]); + assert!(c.is_command_allowed(&["ls".into(), "-la".into()]).is_ok()); + } + + #[test] + fn test_allowlist_rejects() { + let c = cfg_with(vec!["ls"], vec![]); + let err = c + .is_command_allowed(&["nmap".into()]) + .expect_err("must reject"); + assert!(err.contains("not in allowlist")); + } + + #[test] + fn test_allowlist_empty_means_open() { + let c = cfg_with(vec![], vec![]); + assert!(c.is_command_allowed(&["anything".into()]).is_ok()); + } + + #[test] + fn test_allowlist_basename_match() { + let c = cfg_with(vec!["ls"], vec![]); + assert!(c + .is_command_allowed(&["/usr/bin/ls".into(), "-la".into()]) + .is_ok()); + } + + #[test] + fn test_denylist_blocks() { + let c = cfg_with(vec![], vec![r"rm\s+-rf\s+/"]); + let err = c + .is_command_allowed(&["rm".into(), "-rf".into(), "/".into()]) + .expect_err("must reject"); + assert!(err.contains("denylist")); + } + + #[test] + fn test_empty_argv_rejected() { + let c = ShellConfig::default(); + assert!(c.is_command_allowed(&[]).is_err()); + } + + /// Loads the shipped `config.yaml` and asserts the default allowlist + /// preserves read-only env inspection (`printenv`) while rejecting the + /// `env ` exec-escape. `env` was removed from the default allowlist + /// because `is_command_allowed` only checks argv[0]; with `env` + /// allowlisted, `env nmap target` would have argv[0]=="env" and pass. + /// Loads the shipped `config.yaml` and asserts the default allowlist + /// preserves read-only env inspection (`printenv`) while rejecting the + /// `env ` exec-escape. `env` was removed from the default allowlist + /// because `is_command_allowed` only checks argv[0]; with `env` + /// allowlisted, `env nmap target` would have argv[0]=="env" and pass. + /// Parses the YAML directly (skipping `load_config`'s fs-jail check, + /// which is unrelated to the allowlist policy under test). + #[test] + fn shipped_config_blocks_env_exec_escape() { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/config.yaml"); + let content = fs::read_to_string(path).expect("read config.yaml"); + let mut c: ShellConfig = serde_yaml::from_str(&content).expect("config.yaml parses"); + c.compile_denylist().expect("denylist compiles"); + assert!(c.is_command_allowed(&["printenv".into()]).is_ok()); + let err = c + .is_command_allowed(&["env".into(), "nmap".into(), "host".into()]) + .expect_err("env must be rejected"); + assert!(err.contains("not in allowlist")); + } + + #[test] + fn test_resolve_timeout_caps_at_max() { + let c = ShellConfig::default(); + assert_eq!(c.resolve_timeout(Some(60_000)), 30_000); + assert_eq!(c.resolve_timeout(Some(5_000)), 5_000); + assert_eq!(c.resolve_timeout(None), 10_000); + } + + #[test] + fn defaults_include_fs_and_sandbox_sections() { + let c = ShellConfig::default(); + assert_eq!(c.fs.max_read_bytes, 0); + assert_eq!(c.fs.max_write_bytes, 0); + assert!(c.sandbox.enabled); + assert!(c.fs.host_root.is_none()); + } + + #[test] + fn yaml_with_fs_section_parses() { + let yaml = r#" +allowlist: [] +fs: + host_root: /tmp/iii-shell + max_read_bytes: 1024 + denylist_paths: + - /etc +sandbox: + enabled: false +"#; + let c: ShellConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!( + c.fs.host_root.as_deref(), + Some(std::path::Path::new("/tmp/iii-shell")) + ); + assert_eq!(c.fs.max_read_bytes, 1024); + assert!(!c.sandbox.enabled); + assert_eq!(c.fs.denylist_paths.len(), 1); + } + + #[test] + fn missing_fs_section_uses_defaults() { + let yaml = "allowlist: []\n"; + let c: ShellConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(c.fs.max_read_bytes, 0); + assert_eq!(c.fs.max_write_bytes, 0); + assert!(c.sandbox.enabled); + } + + #[test] + fn validate_fs_jail_rejects_default_unjailed_config() { + let c = ShellConfig::default(); + let err = c.validate_fs_jail().expect_err("must reject default"); + let msg = format!("{err}"); + assert!(msg.contains("host_root")); + assert!(msg.contains("allow_unjailed")); + } + + #[test] + fn validate_fs_jail_accepts_explicit_opt_in() { + let mut c = ShellConfig::default(); + c.fs.allow_unjailed = true; + c.validate_fs_jail().expect("explicit opt-in is valid"); + } + + #[test] + fn validate_fs_jail_accepts_pinned_host_root() { + let mut c = ShellConfig::default(); + c.fs.host_root = Some(std::path::PathBuf::from("/tmp/something")); + c.validate_fs_jail().expect("pinned host_root is valid"); + } +} diff --git a/shell/src/exec.rs b/shell/src/exec.rs new file mode 100644 index 0000000000..d604b8b1f1 --- /dev/null +++ b/shell/src/exec.rs @@ -0,0 +1,204 @@ +use crate::config::ShellConfig; +use anyhow::Result; +use std::process::Stdio; +use std::time::Duration; +use tokio::io::AsyncReadExt; +use tokio::process::Command; + +pub struct ExecOutcome { + pub stdout: String, + pub stderr: String, + pub exit_code: Option, + pub duration_ms: u64, + pub timed_out: bool, + pub stdout_truncated: bool, + pub stderr_truncated: bool, +} + +pub fn parse_argv(command: &str, args: Option<&Vec>) -> Result, String> { + if let Some(args) = args { + let mut v = vec![command.to_string()]; + v.extend(args.iter().cloned()); + Ok(v) + } else { + shell_words::split(command).map_err(|e| format!("parse command: {}", e)) + } +} + +pub fn build_command(argv: &[String], cfg: &ShellConfig) -> Result { + let program = argv.first().ok_or_else(|| "empty command".to_string())?; + let mut cmd = Command::new(program); + if argv.len() > 1 { + cmd.args(&argv[1..]); + } + if !cfg.inherit_env { + cmd.env_clear(); + for k in &cfg.allowed_env { + if let Ok(v) = std::env::var(k) { + cmd.env(k, v); + } + } + } + if let Some(dir) = &cfg.working_dir { + cmd.current_dir(dir); + } + cmd.stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + Ok(cmd) +} + +pub async fn run_to_completion( + argv: &[String], + cfg: &ShellConfig, + timeout_ms: u64, +) -> Result { + let started = std::time::Instant::now(); + let mut cmd = build_command(argv, cfg)?; + let mut child = cmd.spawn().map_err(|e| format!("spawn: {}", e))?; + + let mut stdout_reader = child.stdout.take().ok_or("no stdout pipe")?; + let mut stderr_reader = child.stderr.take().ok_or("no stderr pipe")?; + + let limit = cfg.max_output_bytes; + let timeout = Duration::from_millis(timeout_ms); + + let stdout_task = tokio::spawn(async move { read_bounded(&mut stdout_reader, limit).await }); + let stderr_task = tokio::spawn(async move { read_bounded(&mut stderr_reader, limit).await }); + + let wait_res = tokio::time::timeout(timeout, child.wait()).await; + + let (exit_code, timed_out) = match wait_res { + Ok(Ok(status)) => (status.code(), false), + Ok(Err(e)) => return Err(format!("wait: {}", e)), + Err(_) => { + let _ = child.start_kill(); + let _ = child.wait().await; + (None, true) + } + }; + + let (stdout_bytes, stdout_truncated) = + stdout_task.await.map_err(|e| format!("stdout: {}", e))?; + let (stderr_bytes, stderr_truncated) = + stderr_task.await.map_err(|e| format!("stderr: {}", e))?; + + Ok(ExecOutcome { + stdout: String::from_utf8_lossy(&stdout_bytes).into_owned(), + stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(), + exit_code, + duration_ms: started.elapsed().as_millis() as u64, + timed_out, + stdout_truncated, + stderr_truncated, + }) +} + +async fn read_bounded(reader: &mut R, limit: usize) -> (Vec, bool) { + let mut buf = Vec::with_capacity(limit.min(8192)); + let mut chunk = [0u8; 8192]; + let mut truncated = false; + loop { + match reader.read(&mut chunk).await { + Ok(0) => break, + Ok(n) => { + if !truncated { + if buf.len() + n > limit { + let take = limit.saturating_sub(buf.len()); + buf.extend_from_slice(&chunk[..take]); + truncated = true; + // Fall through and continue draining; do NOT break. + // Dropping the reader here closes the pipe, and the + // child's next write() raises SIGPIPE — making + // ExitStatus::code() report None on what should have + // been a normal-exit-with-truncated-output run. + } else { + buf.extend_from_slice(&chunk[..n]); + } + } + // truncated == true: discard everything past `limit`. + } + Err(_) => break, + } + } + (buf, truncated) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_cfg() -> ShellConfig { + let mut c = ShellConfig { + inherit_env: true, + max_output_bytes: 4096, + ..Default::default() + }; + c.compile_denylist().unwrap(); + c + } + + #[test] + fn test_parse_argv_with_args_field() { + let got = parse_argv("echo", Some(&vec!["hello".into(), "world".into()])).unwrap(); + assert_eq!(got, vec!["echo", "hello", "world"]); + } + + #[test] + fn test_parse_argv_from_shell_words() { + let got = parse_argv(r#"echo "hello world""#, None).unwrap(); + assert_eq!(got, vec!["echo", "hello world"]); + } + + #[test] + fn test_parse_argv_bad_quoting() { + assert!(parse_argv(r#"echo "unterminated"#, None).is_err()); + } + + #[tokio::test] + async fn test_run_echo() { + let cfg = test_cfg(); + let out = run_to_completion(&["echo".into(), "hi".into()], &cfg, 5000) + .await + .unwrap(); + assert_eq!(out.exit_code, Some(0)); + assert_eq!(out.stdout.trim(), "hi"); + assert!(!out.timed_out); + } + + #[tokio::test] + async fn test_run_nonexistent_command() { + let cfg = test_cfg(); + let err = run_to_completion(&["_nope_no_exist_".into()], &cfg, 1000).await; + assert!(err.is_err()); + } + + #[tokio::test] + async fn test_timeout_kills() { + let cfg = test_cfg(); + let out = run_to_completion(&["sleep".into(), "5".into()], &cfg, 200) + .await + .unwrap(); + assert!(out.timed_out); + assert_eq!(out.exit_code, None); + } + + #[tokio::test] + async fn test_output_truncation() { + let mut cfg = test_cfg(); + cfg.max_output_bytes = 16; + let out = run_to_completion( + &[ + "sh".into(), + "-c".into(), + "printf 'x%.0s' $(seq 1 100)".into(), + ], + &cfg, + 3000, + ) + .await + .unwrap(); + assert!(out.stdout_truncated); + assert_eq!(out.stdout.len(), 16); + } +} diff --git a/shell/src/fs/error.rs b/shell/src/fs/error.rs new file mode 100644 index 0000000000..4e9e32e3ed --- /dev/null +++ b/shell/src/fs/error.rs @@ -0,0 +1,99 @@ +//! `FsError` carries an S2xx code + message, serializing as the same +//! `{ "code": "...", "message": "..." }` shape the engine daemon emits +//! so callers don't need to branch on backend. + +use serde::Serialize; +use std::io; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct FsError { + pub code: &'static str, + pub message: String, +} + +impl FsError { + pub fn new(code: &'static str, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } + + /// Mirrors the engine daemon's `SandboxError::from_io` so wire codes + /// match across host and sandbox backends. + pub fn from_io(path: &str, err: io::Error) -> Self { + match err.kind() { + io::ErrorKind::NotFound => Self::new("S211", format!("path not found: {path}")), + io::ErrorKind::AlreadyExists => { + Self::new("S213", format!("path already exists: {path}")) + } + io::ErrorKind::PermissionDenied => Self::new("S215", format!("{path}: {err}")), + // S212 (wrong file type) and S214 (dir not empty) are checked + // at the op level — they don't map to stable `ErrorKind` variants. + _ => Self::new("S216", format!("{path}: {err}")), + } + } + + /// `serde_json::to_string` on `&'static str` + `String` fields is + /// effectively infallible (OOM only); `expect` so future changes that + /// break the invariant fail loudly instead of producing malformed JSON. + pub fn to_json(&self) -> String { + serde_json::to_string(self).expect("FsError is always serializable") + } +} + +pub fn parse_mode(mode: &str) -> Result { + if mode.is_empty() { + return Err(FsError::new("S210", "invalid octal mode: (empty)")); + } + let stripped = mode.trim_start_matches('0'); + let s = if stripped.is_empty() { "0" } else { stripped }; + u32::from_str_radix(s, 8) + .map_err(|_| FsError::new("S210", format!("invalid octal mode: {mode}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::ErrorKind; + + #[test] + fn from_io_maps_kinds() { + let e = FsError::from_io("/x", io::Error::new(ErrorKind::NotFound, "nope")); + assert_eq!(e.code, "S211"); + let e = FsError::from_io("/x", io::Error::new(ErrorKind::AlreadyExists, "dupe")); + assert_eq!(e.code, "S213"); + let e = FsError::from_io("/x", io::Error::new(ErrorKind::PermissionDenied, "no")); + assert_eq!(e.code, "S215"); + let e = FsError::from_io("/x", io::Error::other("io")); + assert_eq!(e.code, "S216"); + } + + #[test] + fn parse_mode_accepts_common_forms() { + assert_eq!(parse_mode("0755").unwrap(), 0o755); + assert_eq!(parse_mode("755").unwrap(), 0o755); + assert_eq!(parse_mode("0").unwrap(), 0); + } + + #[test] + fn parse_mode_rejects_garbage() { + let err = parse_mode("xyz").unwrap_err(); + assert_eq!(err.code, "S210"); + } + + #[test] + fn parse_mode_rejects_empty_string() { + let err = parse_mode("").unwrap_err(); + assert_eq!(err.code, "S210"); + assert!(err.message.contains("(empty)")); + } + + #[test] + fn to_json_round_trips() { + let e = FsError::new("S211", "nope"); + let j = e.to_json(); + assert!(j.contains("\"code\":\"S211\"")); + assert!(j.contains("\"message\":\"nope\"")); + } +} diff --git a/shell/src/fs/host.rs b/shell/src/fs/host.rs new file mode 100644 index 0000000000..3f08751bb2 --- /dev/null +++ b/shell/src/fs/host.rs @@ -0,0 +1,2149 @@ +//! Host-filesystem backend. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use async_trait::async_trait; +use iii_sdk::{Channel, IIIError}; + +use crate::fs::error::FsError; + +/// RAII guard that unlinks a temp path on drop unless `commit()` is called. +/// Sync `remove_file` in `Drop` is deliberate — best-effort cleanup on +/// panic/early-return without awaiting. +struct TempGuard { + path: Option, +} +impl TempGuard { + fn new(path: PathBuf) -> Self { + Self { path: Some(path) } + } + fn commit(mut self) { + self.path = None; + } + fn path(&self) -> &std::path::Path { + self.path.as_deref().expect("path always Some until commit") + } +} +impl Drop for TempGuard { + fn drop(&mut self) { + if let Some(p) = self.path.take() { + let _ = std::fs::remove_file(p); + } + } +} + +#[async_trait] +pub trait ChannelMaker: Send + Sync + std::fmt::Debug { + async fn create_channel(&self, buffer: usize) -> Result; + fn engine_address(&self) -> String; +} + +pub struct IiiChannelMaker { + iii: iii_sdk::III, +} + +impl std::fmt::Debug for IiiChannelMaker { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("IiiChannelMaker") + .field("address", &self.iii.address()) + .finish() + } +} + +impl IiiChannelMaker { + pub fn new(iii: iii_sdk::III) -> Self { + Self { iii } + } +} + +#[async_trait] +impl ChannelMaker for IiiChannelMaker { + async fn create_channel(&self, buffer: usize) -> Result { + self.iii.create_channel(Some(buffer)).await + } + fn engine_address(&self) -> String { + self.iii.address().to_string() + } +} + +#[derive(Debug, Clone, Default)] +pub struct HostFsConfig { + pub host_root: Option, + pub max_read_bytes: usize, + pub max_write_bytes: usize, + pub denylist_paths: Vec, +} + +#[derive(Debug, Clone)] +pub struct HostFsBackend { + cfg: Arc, + chan: Arc, + /// Canonical form of `cfg.host_root`, computed once at construction. + /// Pre-fix `validate_path` recanonicalized this on every fs op (and + /// every denylist entry) — a real perf hit on hot paths and an + /// operator-config-error vector that surfaced silently. Caching here + /// also fails loudly at startup if `host_root` is unreachable. + host_root_canon: Option, + /// Canonical form of each `cfg.denylist_paths` entry. Same rationale + /// as `host_root_canon`; an entry that can't canonicalize is a config + /// error and the worker refuses to start. + denylist_canon: Vec, +} + +impl HostFsBackend { + pub fn new(cfg: Arc, chan: Arc) -> Self { + match Self::try_new(cfg.clone(), chan.clone()) { + Ok(b) => b, + Err(e) => panic!( + "HostFsBackend init failed: code={} message={}. Either fix \ + the config or call try_new directly to handle the error.", + e.code, e.message + ), + } + } + + /// Resolve `host_root` and every `denylist_paths` entry to canonical form + /// once at startup. Errors here are operator config bugs (path doesn't + /// exist, can't be canonicalized, etc.) and the worker should refuse to + /// start instead of degrading to lexical fallback per-call. + pub fn try_new(cfg: Arc, chan: Arc) -> Result { + let host_root_canon = match &cfg.host_root { + Some(root) => Some(std::fs::canonicalize(root).map_err(|e| { + FsError::new( + "S216", + format!("host_root unreachable ({}): {e}", root.display()), + ) + })?), + None => None, + }; + let mut denylist_canon = Vec::with_capacity(cfg.denylist_paths.len()); + for deny in &cfg.denylist_paths { + let canon = canonicalize_with_fallback(Path::new(deny)).map_err(|e| { + FsError::new( + "S216", + format!("denylist entry unreachable ({}): {e}", deny.display()), + ) + })?; + denylist_canon.push(canon); + } + Ok(Self { + cfg, + chan, + host_root_canon, + denylist_canon, + }) + } + + /// TOCTOU: check-then-use gate, open to a race against an attacker who + /// can mutate the filesystem between validation and subsequent syscalls. + /// Use the sandbox backend for untrusted input. + pub(crate) fn validate_path(&self, path: &str) -> Result { + let p = Path::new(path); + if !p.is_absolute() { + return Err(FsError::new( + "S210", + format!("path must be absolute: {path}"), + )); + } + let canon = canonicalize_with_fallback(p).map_err(|e| { + // Dangling-symlink errors are structurally jail violations + // (the path would otherwise resolve through a link that + // pre-fix slipped past the lexical fallback). Map them to + // S215 so wire telemetry treats them as such. + let msg = format!("{e}"); + if msg.contains("dangling symlink in path") { + FsError::new("S215", format!("{path}: {msg}")) + } else { + FsError::new("S210", format!("{path}: {msg}")) + } + })?; + if let Some(root_canon) = &self.host_root_canon { + if !canon.starts_with(root_canon) { + return Err(FsError::new( + "S215", + format!("path escapes host_root: {path}"), + )); + } + } + for deny_canon in &self.denylist_canon { + if canon.starts_with(deny_canon) { + return Err(FsError::new("S215", format!("path is denylisted: {path}"))); + } + } + Ok(canon) + } +} + +/// Resolve `p` to a canonical path that is symlink-free for every existing +/// ancestor, even when `p` itself doesn't yet exist. The naive fallback — +/// "canonicalize, on ENOENT use the lexical path" — is a jail-escape vector +/// when the path traverses a symlink whose target is outside the jail: the +/// lexical form still `starts_with(host_root)`, but the kernel will follow +/// the link on the subsequent syscall. Walking up to the longest existing +/// ancestor and canonicalizing *that* forces every symlink in the existing +/// portion to be resolved; the non-existent tail can't itself contain +/// symlinks (it doesn't exist) but can still contain `..`/`.`, which we +/// then collapse lexically against the canonical prefix so the +/// `starts_with(host_root)` check is sound. +fn canonicalize_with_fallback(p: &Path) -> std::io::Result { + if let Ok(c) = std::fs::canonicalize(p) { + return Ok(c); + } + // Walk ancestors top-down (longest prefix first) until canonicalize + // succeeds. ancestors() yields p first; skip it because we already + // tried it. After we find the longest canonicalizable ancestor, walk + // forward through each tail component and reject if any of them is a + // *dangling* symlink: canonicalize fails on dangling symlinks (target + // doesn't exist), so they'd otherwise survive into the lexical tail + // and let `starts_with(host_root)` succeed against a path the kernel + // would resolve outside the jail. Existing-but-resolvable symlinks + // are caught by the top-of-function canonicalize. + for anc in p.ancestors().skip(1) { + if let Ok(canon_anc) = std::fs::canonicalize(anc) { + let suffix = match p.strip_prefix(anc) { + Ok(s) => s, + Err(_) => continue, + }; + let mut walk = canon_anc; + for component in suffix.components() { + walk.push(component); + if let Ok(md) = std::fs::symlink_metadata(&walk) { + if md.file_type().is_symlink() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("dangling symlink in path: {}", walk.display()), + )); + } + } + } + return Ok(normalize_lexical(&walk)); + } + } + Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "no existing ancestor to canonicalize", + )) +} + +fn normalize_lexical(p: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for c in p.components() { + match c { + std::path::Component::ParentDir => { + out.pop(); + } + std::path::Component::CurDir => {} + other => out.push(other), + } + } + out +} + +/// Mirrors `iii-init/fs_handler/ops.rs::temp_sibling`. +fn temp_sibling(target: &Path) -> PathBuf { + let mut t = target.as_os_str().to_os_string(); + t.push(".iii-tmp-"); + t.push(uuid::Uuid::new_v4().to_string()); + t.into() +} + +/// Mirrors `iii-init/fs_handler/ops.rs::glob_matches_path`. +fn glob_matches_path(pattern: &str, relpath: &str) -> bool { + if pattern.contains('/') { + glob_match(pattern, relpath) + } else { + let base = relpath.rsplit('/').next().unwrap_or(relpath); + glob_match(pattern, base) + } +} + +fn glob_match(pattern: &str, path: &str) -> bool { + if pattern == "**" { + return true; + } + if let Some(rest) = pattern.strip_prefix("**/") { + let base = path.rsplit('/').next().unwrap_or(path); + if glob_match_simple(rest, base) { + return true; + } + return glob_match_simple(rest, path); + } + glob_match_simple(pattern, path) +} + +fn glob_match_simple(pattern: &str, path: &str) -> bool { + let p = pattern.as_bytes(); + let t = path.as_bytes(); + let mut pi = 0usize; + let mut ti = 0usize; + let mut star_pi: Option = None; + let mut star_ti = 0usize; + while ti < t.len() { + let pc = p.get(pi).copied(); + let tc = t[ti]; + match pc { + Some(b'*') => { + star_pi = Some(pi); + star_ti = ti; + pi += 1; + } + Some(b'?') if tc != b'/' => { + pi += 1; + ti += 1; + } + Some(c) if c == tc => { + pi += 1; + ti += 1; + } + _ => { + if let Some(sp) = star_pi { + if t[star_ti] == b'/' { + return false; + } + pi = sp + 1; + star_ti += 1; + ti = star_ti; + } else { + return false; + } + } + } + } + while pi < p.len() && p[pi] == b'*' { + pi += 1; + } + pi == p.len() +} + +fn looks_binary(path: &Path) -> bool { + use std::io::Read; + let Ok(mut f) = std::fs::File::open(path) else { + return false; + }; + let mut buf = [0u8; 8192]; + let n = f.read(&mut buf).unwrap_or(0); + buf[..n].contains(&0) +} + +fn expand_regex_replacement(caps: ®ex::Captures, template: &str) -> String { + let mut out = String::new(); + caps.expand(template, &mut out); + out +} + +/// When `case_insensitive` is true, delegates to regex with `(?i)` and an +/// escaped pattern. Hand-rolling case-fold over UTF-8 is unsound (e.g. +/// `'İ'` U+0130 folds to a length-changing sequence). +fn literal_replace_line( + line: &str, + needle: &str, + case_insensitive: bool, + replacement: &str, + first_only: bool, +) -> (String, u64) { + if line.is_empty() || needle.is_empty() { + return (line.to_string(), 0); + } + if case_insensitive { + let escaped_needle = regex::escape(needle); + let pattern = format!("(?i){escaped_needle}"); + let re = match regex::Regex::new(&pattern) { + Ok(r) => r, + Err(_) => return (line.to_string(), 0), + }; + // Closures returning `String` to `Regex::replacen`/`replace_all` + // satisfy `Replacer` via the FnMut blanket impl, which inserts the + // returned string verbatim — no `$N` capture substitution. So the + // literal replacement must NOT be pre-escaped; doing so would + // double user-supplied `$` characters. + let owned = replacement.to_string(); + let mut count = 0u64; + let out = if first_only { + re.replacen(line, 1, |_caps: ®ex::Captures| { + count += 1; + owned.clone() + }) + .into_owned() + } else { + re.replace_all(line, |_caps: ®ex::Captures| { + count += 1; + owned.clone() + }) + .into_owned() + }; + return (out, count); + } + let mut out = String::with_capacity(line.len()); + let mut n = 0u64; + let mut i = 0usize; + let bytes = line.as_bytes(); + let needle_bytes = needle.as_bytes(); + while i < line.len() { + if i + needle_bytes.len() <= bytes.len() + && &bytes[i..i + needle_bytes.len()] == needle_bytes + { + out.push_str(replacement); + i += needle_bytes.len(); + n += 1; + if first_only { + out.push_str(&line[i..]); + return (out, n); + } + } else { + let next = line[i..] + .char_indices() + .nth(1) + .map(|(b, _)| i + b) + .unwrap_or(line.len()); + out.push_str(&line[i..next]); + i = next; + } + } + (out, n) +} + +fn collect_files_to_sed( + root: &Path, + recursive: bool, + include_glob: &[String], + exclude_glob: &[String], +) -> Vec { + let mut out = Vec::new(); + let lmd = match root.symlink_metadata() { + Ok(m) => m, + Err(_) => return out, + }; + let passes = |rel: &str| -> bool { + if !include_glob.is_empty() && !include_glob.iter().any(|g| glob_matches_path(g, rel)) { + return false; + } + if exclude_glob.iter().any(|g| glob_matches_path(g, rel)) { + return false; + } + true + }; + let target_is_dir = if lmd.file_type().is_symlink() { + match std::fs::metadata(root) { + Ok(m) => m.is_dir(), + Err(_) => return out, + } + } else { + lmd.is_dir() + }; + if !target_is_dir { + let rel = root + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + if passes(&rel) { + out.push(root.to_path_buf()); + } + return out; + } + let resolved_root: PathBuf = if lmd.file_type().is_symlink() { + match std::fs::canonicalize(root) { + Ok(p) => p, + Err(_) => return out, + } + } else { + root.to_path_buf() + }; + let walker = walkdir::WalkDir::new(&resolved_root).follow_links(false); + let walker = if recursive { + walker + } else { + walker.max_depth(1) + }; + for entry in walker + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + { + let rel = entry + .path() + .strip_prefix(&resolved_root) + .unwrap_or(entry.path()) + .to_string_lossy() + .into_owned(); + if passes(&rel) { + out.push(entry.path().to_path_buf()); + } + } + out +} + +use crate::fs::wire::FsEntry; +use crate::fs::{FsBackend, FsCallResult, LsArgs, LsResponse, StatArgs, StatResponse}; +use std::os::unix::fs::PermissionsExt; + +/// Mode formatted as 4-octal-digit zero-padded, matching engine daemon's +/// `entry_from_metadata`. +fn fs_entry_from_metadata(name: String, md: &std::fs::Metadata) -> FsEntry { + FsEntry { + name, + is_dir: md.is_dir(), + size: md.len(), + mode: format!("{:04o}", md.permissions().mode() & 0o7777), + mtime: md + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) + .unwrap_or(0), + is_symlink: md.file_type().is_symlink(), + } +} + +async fn pump_file_to_channel( + path: &std::path::Path, + writer: &iii_sdk::channels::ChannelWriter, +) -> Result<(), String> { + use tokio::io::AsyncReadExt; + let mut file = tokio::fs::File::open(path) + .await + .map_err(|e| format!("open failed: {e}"))?; + let mut buf = vec![0u8; 64 * 1024]; + // Open the WS connection eagerly: for a zero-byte file the loop exits + // without write(), leaving the sender never dropped and blocking the + // reader side forever. This empty write forces the connect. + writer + .write(&[]) + .await + .map_err(|e| format!("channel connect failed: {e}"))?; + loop { + let n = file + .read(&mut buf) + .await + .map_err(|e| format!("file read failed: {e}"))?; + if n == 0 { + return Ok(()); + } + writer + .write(&buf[..n]) + .await + .map_err(|e| format!("channel write failed: {e}"))?; + } +} + +#[async_trait] +impl FsBackend for HostFsBackend { + async fn ls(&self, req: LsArgs) -> FsCallResult { + let p = self.validate_path(&req.path)?; + let md = std::fs::symlink_metadata(&p).map_err(|e| FsError::from_io(&req.path, e))?; + if !md.is_dir() { + return Err(FsError::new( + "S212", + format!("not a directory: {}", req.path), + )); + } + let rd = std::fs::read_dir(&p).map_err(|e| FsError::from_io(&req.path, e))?; + let mut entries = Vec::new(); + for ent in rd { + let ent = match ent { + Ok(e) => e, + Err(_) => continue, + }; + let md = match ent.path().symlink_metadata() { + Ok(m) => m, + Err(_) => continue, + }; + let name = ent.file_name().to_string_lossy().into_owned(); + entries.push(fs_entry_from_metadata(name, &md)); + } + Ok(LsResponse { entries }) + } + + async fn stat(&self, req: StatArgs) -> FsCallResult { + let p = self.validate_path(&req.path)?; + let md = std::fs::symlink_metadata(&p).map_err(|e| FsError::from_io(&req.path, e))?; + let name = p + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| req.path.clone()); + Ok(StatResponse(fs_entry_from_metadata(name, &md))) + } + + async fn mkdir(&self, req: crate::fs::MkdirArgs) -> FsCallResult { + let p = self.validate_path(&req.path)?; + let bits = crate::fs::error::parse_mode(&req.mode)?; + if p.exists() { + if req.parents { + return Ok(crate::fs::MkdirResponse { created: false }); + } + return Err(FsError::new( + "S213", + format!("path already exists: {}", req.path), + )); + } + let res = if req.parents { + std::fs::create_dir_all(&p) + } else { + std::fs::create_dir(&p) + }; + res.map_err(|e| FsError::from_io(&req.path, e))?; + let perms = std::fs::Permissions::from_mode(bits); + std::fs::set_permissions(&p, perms).map_err(|e| FsError::from_io(&req.path, e))?; + Ok(crate::fs::MkdirResponse { created: true }) + } + + async fn rm(&self, req: crate::fs::RmArgs) -> FsCallResult { + // Lexical form required: rm of a symlink must remove the link, not + // the target. validate_path canonicalizes for jail confinement; we + // operate on the lexical path to preserve unlink semantics. + self.validate_path(&req.path)?; + let p = normalize_lexical(Path::new(&req.path)); + + let md = std::fs::symlink_metadata(&p).map_err(|e| FsError::from_io(&req.path, e))?; + + if md.is_dir() && !md.file_type().is_symlink() { + if req.recursive { + std::fs::remove_dir_all(&p).map_err(|e| FsError::from_io(&req.path, e))?; + } else { + let mut rd = std::fs::read_dir(&p).map_err(|e| FsError::from_io(&req.path, e))?; + if rd.next().is_some() { + return Err(FsError::new( + "S214", + format!("directory not empty: {}", req.path), + )); + } + std::fs::remove_dir(&p).map_err(|e| FsError::from_io(&req.path, e))?; + } + } else { + std::fs::remove_file(&p).map_err(|e| FsError::from_io(&req.path, e))?; + } + Ok(crate::fs::RmResponse { removed: true }) + } + async fn chmod(&self, req: crate::fs::ChmodArgs) -> FsCallResult { + self.validate_path(&req.path)?; + let p = normalize_lexical(Path::new(&req.path)); + let bits = crate::fs::error::parse_mode(&req.mode)?; + if !p.exists() { + return Err(FsError::new( + "S211", + format!("path not found: {}", req.path), + )); + } + let uid = req.uid; + let gid = req.gid; + let apply = |target: &Path| -> Result<(), FsError> { + let perms = std::fs::Permissions::from_mode(bits); + std::fs::set_permissions(target, perms) + .map_err(|e| FsError::from_io(&target.to_string_lossy(), e))?; + if uid.is_some() || gid.is_some() { + std::os::unix::fs::chown(target, uid, gid) + .map_err(|e| FsError::from_io(&target.to_string_lossy(), e))?; + } + Ok(()) + }; + let mut updated: u64 = 0; + if req.recursive { + // Reject if the walk root itself is a symlink: descending into + // a symlink target would change perms outside the recursive + // root, and skipping the root entry silently (which is what + // the per-entry skip below would do) is a quiet no-op that + // looks like success to the caller. S212 = wrong file type. + let root_md = + std::fs::symlink_metadata(&p).map_err(|e| FsError::from_io(&req.path, e))?; + if root_md.file_type().is_symlink() { + return Err(FsError::new( + "S212", + format!( + "recursive chmod refuses to follow symlink at root: {}", + req.path + ), + )); + } + for entry in walkdir::WalkDir::new(&p) + .follow_links(false) + .into_iter() + .filter_map(|e| e.ok()) + { + // Skip symlink entries inside the walk: chmod(2)/chown(2) + // follow symlinks and would rewrite the target's + // mode/owner — possibly outside the recursive root or the + // jail. lchmod isn't portable. + if entry.file_type().is_symlink() { + continue; + } + apply(entry.path())?; + updated += 1; + } + } else { + apply(&p)?; + updated = 1; + } + Ok(crate::fs::ChmodResponse { updated }) + } + + async fn mv(&self, req: crate::fs::MvArgs) -> FsCallResult { + self.validate_path(&req.src)?; + self.validate_path(&req.dst)?; + let src_p = normalize_lexical(Path::new(&req.src)); + let dst_p = normalize_lexical(Path::new(&req.dst)); + if !src_p.exists() { + return Err(FsError::new("S211", format!("src not found: {}", req.src))); + } + if dst_p.exists() && !req.overwrite { + return Err(FsError::new( + "S213", + format!("dst already exists: {}", req.dst), + )); + } + match std::fs::rename(&src_p, &dst_p) { + Ok(()) => Ok(crate::fs::MvResponse { moved: true }), + // EXDEV: cross-fs move — fall back to copy+rename+unlink. + // File-only; directories are unsupported (matches engine daemon). + Err(e) if e.raw_os_error() == Some(libc::EXDEV) => { + let tmp = temp_sibling(&dst_p); + std::fs::copy(&src_p, &tmp).map_err(|e| FsError::from_io(&req.dst, e))?; + if let Err(e) = std::fs::rename(&tmp, &dst_p) { + let _ = std::fs::remove_file(&tmp); + return Err(FsError::from_io(&req.dst, e)); + } + std::fs::remove_file(&src_p).map_err(|e| FsError::from_io(&req.src, e))?; + Ok(crate::fs::MvResponse { moved: true }) + } + Err(e) => Err(FsError::from_io(&req.dst, e)), + } + } + async fn grep(&self, req: crate::fs::GrepArgs) -> FsCallResult { + let root = self.validate_path(&req.path)?; + let md = std::fs::symlink_metadata(&root).map_err(|e| FsError::from_io(&req.path, e))?; + let re = regex::RegexBuilder::new(&req.pattern) + .case_insensitive(req.ignore_case) + .build() + .map_err(|e| FsError::new("S217", format!("bad regex: {e}")))?; + let max_matches_usize = req.max_matches as usize; + let max_line_usize = req.max_line_bytes as usize; + let include_glob = req.include_glob; + let exclude_glob = req.exclude_glob; + + let should_scan = |rel: &str| -> bool { + if !include_glob.is_empty() && !include_glob.iter().any(|g| glob_matches_path(g, rel)) { + return false; + } + if exclude_glob.iter().any(|g| glob_matches_path(g, rel)) { + return false; + } + true + }; + + let mut out: Vec = Vec::new(); + let mut truncated = false; + + let mut scan = |file_path: &Path| -> Result { + if looks_binary(file_path) { + return Ok(false); + } + let f = std::fs::File::open(file_path) + .map_err(|e| FsError::from_io(&file_path.to_string_lossy(), e))?; + let reader = std::io::BufReader::new(f); + use std::io::BufRead; + for (idx, line_res) in reader.lines().enumerate() { + let Ok(mut line) = line_res else { + continue; + }; + if re.is_match(&line) { + if max_line_usize > 0 && line.len() > max_line_usize { + // Floor to nearest char boundary so a multi-byte + // codepoint straddling the cut doesn't panic. + let cut = (0..=max_line_usize) + .rev() + .find(|&i| line.is_char_boundary(i)) + .unwrap_or(0); + line.truncate(cut); + line.push('…'); + } + out.push(crate::fs::wire::FsMatch { + path: file_path.to_string_lossy().into_owned(), + line: (idx + 1) as u64, + content: line, + }); + if max_matches_usize > 0 && out.len() >= max_matches_usize { + return Ok(true); + } + } + } + Ok(false) + }; + + if md.is_dir() { + if !req.recursive { + return Err(FsError::new( + "S210", + "recursive=false on a directory is unsupported; \ + pass a file path or set recursive=true", + )); + } + for entry in walkdir::WalkDir::new(&root) + .follow_links(false) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + { + let rel = entry + .path() + .strip_prefix(&root) + .unwrap_or(entry.path()) + .to_string_lossy() + .into_owned(); + if !should_scan(&rel) { + continue; + } + if scan(entry.path())? { + truncated = true; + break; + } + } + } else { + let rel = root + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + if should_scan(&rel) && scan(&root)? { + truncated = true; + } + } + + Ok(crate::fs::GrepResponse { + matches: out, + truncated, + }) + } + async fn sed(&self, req: crate::fs::SedArgs) -> FsCallResult { + let files: Vec = match (req.files.is_empty(), req.path.as_ref()) { + (false, None) => req.files.clone(), + (true, Some(root)) => { + self.validate_path(root)?; + let root_path = Path::new(root); + let _ = root_path + .symlink_metadata() + .map_err(|e| FsError::from_io(root, e))?; + let target_is_dir = match std::fs::metadata(root_path) { + Ok(m) => m.is_dir(), + Err(e) => return Err(FsError::from_io(root, e)), + }; + if target_is_dir && !req.recursive { + return Err(FsError::new( + "S210", + "recursive=false on a directory is unsupported; \ + pass a file path or set recursive=true", + )); + } + let collected = collect_files_to_sed( + root_path, + req.recursive, + &req.include_glob, + &req.exclude_glob, + ); + collected + .into_iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect() + } + (false, Some(_)) => { + return Err(FsError::new( + "S210", + "sed: provide exactly one of `files` or `path`, not both", + )); + } + (true, None) => { + return Err(FsError::new( + "S210", + "sed: must provide exactly one of `files` or `path`", + )); + } + }; + + for f in &files { + self.validate_path(f)?; + } + + let matcher: Option = if req.regex { + Some( + regex::RegexBuilder::new(&req.pattern) + .case_insensitive(req.ignore_case) + .build() + .map_err(|e| FsError::new("S217", format!("bad regex: {e}")))?, + ) + } else if req.pattern.is_empty() { + return Err(FsError::new("S210", "pattern is empty")); + } else { + None + }; + let case_fold = req.ignore_case && !req.regex; + + let mut results: Vec = Vec::with_capacity(files.len()); + let mut total: u64 = 0; + use std::os::unix::fs::PermissionsExt; + + for file in files { + let p = Path::new(&file); + let original = match std::fs::read_to_string(p) { + Ok(s) => s, + Err(e) => { + results.push(crate::fs::wire::FsSedFileResult { + path: file.clone(), + replacements: 0, + success: false, + error: Some(format!("{e}")), + }); + continue; + } + }; + let mut replacements: u64 = 0; + let mut output = String::with_capacity(original.len()); + for line in original.split_inclusive('\n') { + let (new_line, n) = match &matcher { + Some(re) => { + let mut count_here = 0u64; + let produced = if req.first_only { + re.replacen(line, 1, |caps: ®ex::Captures| { + count_here += 1; + expand_regex_replacement(caps, &req.replacement) + }) + .into_owned() + } else { + re.replace_all(line, |caps: ®ex::Captures| { + count_here += 1; + expand_regex_replacement(caps, &req.replacement) + }) + .into_owned() + }; + (produced, count_here) + } + None => literal_replace_line( + line, + &req.pattern, + case_fold, + &req.replacement, + req.first_only, + ), + }; + replacements += n; + output.push_str(&new_line); + } + let tmp = temp_sibling(p); + let write_result: Result<(), std::io::Error> = (|| { + let original_md = std::fs::metadata(p)?; + std::fs::write(&tmp, output.as_bytes())?; + std::fs::set_permissions( + &tmp, + std::fs::Permissions::from_mode(original_md.permissions().mode()), + )?; + std::fs::rename(&tmp, p)?; + Ok(()) + })(); + match write_result { + Ok(()) => { + total += replacements; + results.push(crate::fs::wire::FsSedFileResult { + path: file, + replacements, + success: true, + error: None, + }); + } + Err(e) => { + let _ = std::fs::remove_file(&tmp); + results.push(crate::fs::wire::FsSedFileResult { + path: file, + replacements: 0, + success: false, + error: Some(format!("{e}")), + }); + } + } + } + Ok(crate::fs::SedResponse { + results, + total_replacements: total, + }) + } + async fn write(&self, req: crate::fs::WriteArgs) -> FsCallResult { + let p = self.validate_path(&req.path)?; + let bits = crate::fs::error::parse_mode(&req.mode)?; + + // Defense-in-depth: re-check parent against the precomputed + // canonical root before creating intermediate directories. + // validate_path already enforces this on `p`, but parents:true + // is S-C1's second site so we keep the belt. + if req.parents { + if let Some(parent) = p.parent() { + if let Some(root_canon) = &self.host_root_canon { + if !parent.starts_with(root_canon) { + return Err(FsError::new( + "S215", + format!("parent escapes host_root: {}", req.path), + )); + } + } + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| FsError::from_io(&req.path, e))?; + } + } + + let suffix = format!(".tmp.{}", uuid::Uuid::new_v4().simple()); + let mut temp_str = p.as_os_str().to_os_string(); + temp_str.push(&suffix); + let temp = std::path::PathBuf::from(temp_str); + + use tokio::io::AsyncWriteExt; + let mut file = tokio::fs::OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .open(&temp) + .await + .map_err(|e| FsError::from_io(&req.path, e))?; + let guard = TempGuard::new(temp.clone()); + + let reader = + iii_sdk::channels::ChannelReader::new(&self.chan.engine_address(), &req.content); + let cap = self.cfg.max_write_bytes; + let mut total: u64 = 0; + // Per-chunk idle timeout: if the caller opens a write but never sends + // data and never closes the channel, the worker would hold the temp + // file open indefinitely. N parked writers = resource exhaustion. + let idle = std::time::Duration::from_secs(30); + loop { + let next = match tokio::time::timeout(idle, reader.next_binary()).await { + Ok(r) => r, + Err(_) => { + return Err(FsError::new( + "S216", + format!("channel idle for {}s — aborting write", idle.as_secs()), + )); + } + }; + match next { + Ok(Some(chunk)) => { + let new_total = total + chunk.len() as u64; + if cap > 0 && new_total > cap as u64 { + return Err(FsError::new( + "S218", + format!( + "write payload exceeds max_write_bytes {} (after {} bytes)", + cap, total + ), + )); + } + if let Err(e) = file.write_all(&chunk).await { + return Err(FsError::from_io(&req.path, e)); + } + total = new_total; + } + Ok(None) => break, + Err(e) => { + return Err(FsError::new("S216", format!("channel read failed: {e}"))); + } + } + } + + if let Err(e) = file.flush().await { + return Err(FsError::from_io(&req.path, e)); + } + if let Err(e) = file.sync_all().await { + return Err(FsError::from_io(&req.path, e)); + } + drop(file); + + let perms = std::fs::Permissions::from_mode(bits); + if let Err(e) = tokio::fs::set_permissions(guard.path(), perms).await { + return Err(FsError::from_io(&req.path, e)); + } + if let Err(e) = tokio::fs::rename(guard.path(), &p).await { + return Err(FsError::from_io(&req.path, e)); + } + guard.commit(); + + Ok(crate::fs::WriteResponse { + bytes_written: total, + path: req.path, + }) + } + async fn read(&self, req: crate::fs::ReadArgs) -> FsCallResult { + use std::os::unix::fs::PermissionsExt; + + let p = self.validate_path(&req.path)?; + let md = tokio::fs::symlink_metadata(&p) + .await + .map_err(|e| FsError::from_io(&req.path, e))?; + if md.is_dir() { + return Err(FsError::new( + "S212", + format!("path is a directory, not a file: {}", req.path), + )); + } + let cap = self.cfg.max_read_bytes; + if cap > 0 && (md.len() as usize) > cap { + return Err(FsError::new( + "S218", + format!("file size {} exceeds max_read_bytes {}", md.len(), cap), + )); + } + let size = md.len(); + let mode = format!("{:04o}", md.permissions().mode() & 0o7777); + let mtime = md + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + + let channel = self + .chan + .create_channel(64) + .await + .map_err(|e| FsError::new("S216", format!("create_channel: {e}")))?; + let reader_ref = channel.reader_ref.clone(); + let writer = channel.writer; + let path_for_pump = p; + + tokio::spawn(async move { + let result = pump_file_to_channel(&path_for_pump, &writer).await; + if let Err(msg) = result { + let _ = writer + .send_message(&serde_json::json!({"error": msg}).to_string()) + .await; + } + let _ = writer.close().await; + }); + + Ok(crate::fs::ReadResponse { + content: reader_ref, + size, + mode, + mtime, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn tmp() -> PathBuf { + let d = std::env::temp_dir().join(format!("iii-shell-fs-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&d).unwrap(); + d + } + + #[derive(Debug)] + struct StubChan; + #[async_trait::async_trait] + impl super::ChannelMaker for StubChan { + async fn create_channel(&self, _: usize) -> Result { + Err(iii_sdk::IIIError::Handler("stub".into())) + } + fn engine_address(&self) -> String { + "ws://stub:0".into() + } + } + fn stub_backend(cfg: HostFsConfig) -> HostFsBackend { + HostFsBackend::new(Arc::new(cfg), Arc::new(StubChan)) + } + fn stub_ref() -> iii_sdk::channels::StreamChannelRef { + iii_sdk::channels::StreamChannelRef { + channel_id: "c".into(), + access_key: "k".into(), + direction: iii_sdk::channels::ChannelDirection::Read, + } + } + + #[test] + fn rejects_relative_path() { + let h = stub_backend(HostFsConfig::default()); + let err = h.validate_path("relative/path").unwrap_err(); + assert_eq!(err.code, "S210"); + } + + #[test] + fn allows_absolute_when_no_root() { + let h = stub_backend(HostFsConfig::default()); + let _ = h.validate_path("/tmp").unwrap(); + } + + #[test] + fn rejects_path_outside_host_root() { + let root = tmp(); + let cfg = HostFsConfig { + host_root: Some(root.clone()), + ..Default::default() + }; + let h = stub_backend(cfg); + let err = h.validate_path("/etc").unwrap_err(); + assert_eq!(err.code, "S215"); + } + + #[test] + fn allows_descendant_of_host_root() { + let root = tmp(); + fs::create_dir(root.join("sub")).unwrap(); + let cfg = HostFsConfig { + host_root: Some(root.clone()), + ..Default::default() + }; + let h = stub_backend(cfg); + let _ = h.validate_path(root.join("sub").to_str().unwrap()).unwrap(); + } + + #[test] + fn denylist_blocks_even_inside_root() { + let root = tmp(); + fs::create_dir(root.join("etc")).unwrap(); + fs::write(root.join("etc/shadow"), b"x").unwrap(); + let cfg = HostFsConfig { + host_root: Some(root.clone()), + denylist_paths: vec![root.join("etc/shadow")], + ..Default::default() + }; + let h = stub_backend(cfg); + let err = h + .validate_path(root.join("etc/shadow").to_str().unwrap()) + .unwrap_err(); + assert_eq!(err.code, "S215"); + } + + #[test] + fn nonexistent_path_with_dotdot_cannot_escape_root() { + let root = tmp(); + let cfg = HostFsConfig { + host_root: Some(root.clone()), + ..Default::default() + }; + let h = stub_backend(cfg); + let escape = root.join("nonexistent/../../../etc/passwd"); + let err = h.validate_path(escape.to_str().unwrap()).unwrap_err(); + assert_eq!(err.code, "S215"); + } + + #[tokio::test] + async fn ls_lists_dir_entries() { + let root = tmp(); + std::fs::write(root.join("a.txt"), b"hi").unwrap(); + let h = stub_backend(HostFsConfig::default()); + let resp = h + .ls(LsArgs { + path: root.to_str().unwrap().into(), + }) + .await + .unwrap(); + assert_eq!(resp.entries.len(), 1); + assert_eq!(resp.entries[0].name, "a.txt"); + assert_eq!(resp.entries[0].size, 2); + assert!(!resp.entries[0].is_dir); + } + + #[tokio::test] + async fn ls_missing_returns_s211() { + let h = stub_backend(HostFsConfig::default()); + let err = h + .ls(LsArgs { + path: "/nope/never/exists/iii".into(), + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S211"); + } + + #[tokio::test] + async fn stat_returns_file_entry() { + let root = tmp(); + let f = root.join("x.txt"); + std::fs::write(&f, b"data").unwrap(); + let h = stub_backend(HostFsConfig::default()); + let resp = h + .stat(StatArgs { + path: f.to_str().unwrap().into(), + }) + .await + .unwrap(); + assert_eq!(resp.0.name, "x.txt"); + assert_eq!(resp.0.size, 4); + } + + #[tokio::test] + async fn ls_on_file_returns_s212() { + let root = tmp(); + let f = root.join("a.txt"); + std::fs::write(&f, b"x").unwrap(); + let h = stub_backend(HostFsConfig::default()); + let err = h + .ls(LsArgs { + path: f.to_str().unwrap().into(), + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S212"); + } + + #[tokio::test] + async fn ls_includes_symlink_with_is_symlink_true() { + use std::os::unix::fs::symlink; + let root = tmp(); + let target = root.join("real.txt"); + std::fs::write(&target, b"x").unwrap(); + symlink(&target, root.join("alias")).unwrap(); + let h = stub_backend(HostFsConfig::default()); + let resp = h + .ls(LsArgs { + path: root.to_str().unwrap().into(), + }) + .await + .unwrap(); + let alias = resp.entries.iter().find(|e| e.name == "alias").unwrap(); + assert!( + alias.is_symlink, + "ls should report is_symlink=true for symlink entries" + ); + } + + #[tokio::test] + async fn ls_with_broken_symlink_does_not_abort() { + use std::os::unix::fs::symlink; + let root = tmp(); + std::fs::write(root.join("real.txt"), b"x").unwrap(); + symlink(root.join("nonexistent"), root.join("dangling")).unwrap(); + let h = stub_backend(HostFsConfig::default()); + let resp = h + .ls(LsArgs { + path: root.to_str().unwrap().into(), + }) + .await + .unwrap(); + assert_eq!(resp.entries.len(), 2); + } + + #[tokio::test] + async fn ls_returns_mode_padded_to_four_digits() { + let root = tmp(); + let f = root.join("p.txt"); + std::fs::write(&f, b"x").unwrap(); + let perms = std::fs::Permissions::from_mode(0o600); + std::fs::set_permissions(&f, perms).unwrap(); + let h = stub_backend(HostFsConfig::default()); + let resp = h + .ls(LsArgs { + path: root.to_str().unwrap().into(), + }) + .await + .unwrap(); + let entry = resp.entries.iter().find(|e| e.name == "p.txt").unwrap(); + assert_eq!(entry.mode, "0600"); + } + + #[tokio::test] + async fn mkdir_creates_directory() { + let root = tmp(); + let p = root.join("new"); + let h = stub_backend(HostFsConfig::default()); + let resp = h + .mkdir(crate::fs::MkdirArgs { + path: p.to_str().unwrap().into(), + mode: "0755".into(), + parents: false, + }) + .await + .unwrap(); + assert!(resp.created); + assert!(p.is_dir()); + } + + #[tokio::test] + async fn mkdir_returns_s213_on_exists_without_parents() { + let root = tmp(); + let h = stub_backend(HostFsConfig::default()); + let err = h + .mkdir(crate::fs::MkdirArgs { + path: root.to_str().unwrap().into(), + mode: "0755".into(), + parents: false, + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S213"); + } + + #[tokio::test] + async fn mkdir_with_parents_tolerates_existing_path() { + let root = tmp(); + let h = stub_backend(HostFsConfig::default()); + let resp = h + .mkdir(crate::fs::MkdirArgs { + path: root.to_str().unwrap().into(), + mode: "0755".into(), + parents: true, + }) + .await + .unwrap(); + assert!(!resp.created); + } + + #[tokio::test] + async fn mkdir_bad_mode_returns_s210() { + let root = tmp(); + let h = stub_backend(HostFsConfig::default()); + let err = h + .mkdir(crate::fs::MkdirArgs { + path: root.join("x").to_str().unwrap().into(), + mode: "garbage".into(), + parents: false, + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S210"); + } + + #[tokio::test] + async fn rm_file_succeeds() { + let root = tmp(); + let f = root.join("d.txt"); + std::fs::write(&f, b"x").unwrap(); + let h = stub_backend(HostFsConfig::default()); + let resp = h + .rm(crate::fs::RmArgs { + path: f.to_str().unwrap().into(), + recursive: false, + }) + .await + .unwrap(); + assert!(resp.removed); + assert!(!f.exists()); + } + + #[tokio::test] + async fn rm_missing_path_returns_s211() { + let h = stub_backend(HostFsConfig::default()); + let err = h + .rm(crate::fs::RmArgs { + path: "/nope/never/iii-rm-test".into(), + recursive: false, + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S211"); + } + + #[tokio::test] + async fn rm_nonempty_dir_returns_s214() { + let root = tmp(); + let sub = root.join("d"); + std::fs::create_dir(&sub).unwrap(); + std::fs::write(sub.join("f"), b"x").unwrap(); + let h = stub_backend(HostFsConfig::default()); + let err = h + .rm(crate::fs::RmArgs { + path: sub.to_str().unwrap().into(), + recursive: false, + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S214"); + } + + #[tokio::test] + async fn rm_symlink_to_dir_uses_remove_file() { + use std::os::unix::fs::symlink; + let root = tmp(); + let target = root.join("real-dir"); + std::fs::create_dir(&target).unwrap(); + let link = root.join("link-to-dir"); + symlink(&target, &link).unwrap(); + let h = stub_backend(HostFsConfig::default()); + let resp = h + .rm(crate::fs::RmArgs { + path: link.to_str().unwrap().into(), + recursive: false, + }) + .await + .unwrap(); + assert!(resp.removed); + assert!(!link.exists()); + assert!(target.is_dir()); + } + + #[tokio::test] + async fn mkdir_with_parents_creates_nested() { + let root = tmp(); + let deep = root.join("a/b/c"); + let h = stub_backend(HostFsConfig::default()); + let resp = h + .mkdir(crate::fs::MkdirArgs { + path: deep.to_str().unwrap().into(), + mode: "0755".into(), + parents: true, + }) + .await + .unwrap(); + assert!(resp.created); + assert!(deep.is_dir()); + assert!(root.join("a").is_dir()); + assert!(root.join("a/b").is_dir()); + } + + #[tokio::test] + async fn rm_recursive_removes_tree() { + let root = tmp(); + let tree = root.join("tree"); + std::fs::create_dir(&tree).unwrap(); + std::fs::create_dir(tree.join("sub")).unwrap(); + std::fs::write(tree.join("sub/leaf.txt"), b"x").unwrap(); + let h = stub_backend(HostFsConfig::default()); + let resp = h + .rm(crate::fs::RmArgs { + path: tree.to_str().unwrap().into(), + recursive: true, + }) + .await + .unwrap(); + assert!(resp.removed); + assert!(!tree.exists()); + } + + #[tokio::test] + async fn chmod_changes_mode() { + let root = tmp(); + let f = root.join("c.txt"); + std::fs::write(&f, b"x").unwrap(); + let h = stub_backend(HostFsConfig::default()); + let resp = h + .chmod(crate::fs::ChmodArgs { + path: f.to_str().unwrap().into(), + mode: "0600".into(), + uid: None, + gid: None, + recursive: false, + }) + .await + .unwrap(); + assert_eq!(resp.updated, 1); + let perms = std::fs::metadata(&f).unwrap().permissions().mode() & 0o7777; + assert_eq!(perms, 0o600); + } + + #[tokio::test] + async fn chmod_missing_returns_s211() { + let h = stub_backend(HostFsConfig::default()); + let err = h + .chmod(crate::fs::ChmodArgs { + path: "/nope/never/iii-chmod".into(), + mode: "0600".into(), + uid: None, + gid: None, + recursive: false, + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S211"); + } + + #[tokio::test] + async fn chmod_bad_mode_returns_s210() { + let root = tmp(); + let f = root.join("c.txt"); + std::fs::write(&f, b"x").unwrap(); + let h = stub_backend(HostFsConfig::default()); + let err = h + .chmod(crate::fs::ChmodArgs { + path: f.to_str().unwrap().into(), + mode: "garbage".into(), + uid: None, + gid: None, + recursive: false, + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S210"); + } + + #[tokio::test] + async fn chmod_recursive_walks_tree_and_counts() { + let root = tmp(); + let tree = root.join("tree"); + std::fs::create_dir(&tree).unwrap(); + std::fs::create_dir(tree.join("sub")).unwrap(); + std::fs::write(tree.join("sub/leaf.txt"), b"x").unwrap(); + let h = stub_backend(HostFsConfig::default()); + let resp = h + .chmod(crate::fs::ChmodArgs { + path: tree.to_str().unwrap().into(), + mode: "0700".into(), + uid: None, + gid: None, + recursive: true, + }) + .await + .unwrap(); + assert_eq!(resp.updated, 3); + let leaf_perms = std::fs::metadata(tree.join("sub/leaf.txt")) + .unwrap() + .permissions() + .mode() + & 0o7777; + assert_eq!(leaf_perms, 0o700); + } + + #[tokio::test] + async fn mv_renames_file() { + let root = tmp(); + let a = root.join("a"); + let b = root.join("b"); + std::fs::write(&a, b"x").unwrap(); + let h = stub_backend(HostFsConfig::default()); + let resp = h + .mv(crate::fs::MvArgs { + src: a.to_str().unwrap().into(), + dst: b.to_str().unwrap().into(), + overwrite: false, + }) + .await + .unwrap(); + assert!(resp.moved); + assert!(!a.exists()); + assert!(b.exists()); + } + + #[tokio::test] + async fn mv_missing_src_returns_s211() { + let root = tmp(); + let h = stub_backend(HostFsConfig::default()); + let err = h + .mv(crate::fs::MvArgs { + src: root.join("nope").to_str().unwrap().into(), + dst: root.join("dst").to_str().unwrap().into(), + overwrite: false, + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S211"); + assert!(err.message.contains("src not found")); + } + + #[tokio::test] + async fn mv_existing_dst_no_overwrite_returns_s213() { + let root = tmp(); + let a = root.join("a"); + let b = root.join("b"); + std::fs::write(&a, b"x").unwrap(); + std::fs::write(&b, b"y").unwrap(); + let h = stub_backend(HostFsConfig::default()); + let err = h + .mv(crate::fs::MvArgs { + src: a.to_str().unwrap().into(), + dst: b.to_str().unwrap().into(), + overwrite: false, + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S213"); + assert!(err.message.contains("dst already exists")); + } + + #[tokio::test] + async fn mv_with_overwrite_replaces_dst() { + let root = tmp(); + let a = root.join("a"); + let b = root.join("b"); + std::fs::write(&a, b"new").unwrap(); + std::fs::write(&b, b"old").unwrap(); + let h = stub_backend(HostFsConfig::default()); + let resp = h + .mv(crate::fs::MvArgs { + src: a.to_str().unwrap().into(), + dst: b.to_str().unwrap().into(), + overwrite: true, + }) + .await + .unwrap(); + assert!(resp.moved); + assert_eq!(std::fs::read(&b).unwrap(), b"new"); + assert!(!a.exists()); + } + + #[tokio::test] + async fn chmod_with_uid_unprivileged_returns_s215() { + let root = tmp(); + let f = root.join("c.txt"); + std::fs::write(&f, b"x").unwrap(); + let h = stub_backend(HostFsConfig::default()); + let resp = h + .chmod(crate::fs::ChmodArgs { + path: f.to_str().unwrap().into(), + mode: "0644".into(), + uid: Some(0), + gid: None, + recursive: false, + }) + .await; + match resp { + Ok(_) => {} + + Err(e) => assert_eq!(e.code, "S215"), + } + } + + #[tokio::test] + async fn write_rejects_non_absolute_path_with_s210() { + let b = stub_backend(HostFsConfig::default()); + let err = b + .write(crate::fs::WriteArgs { + path: "rel/path".into(), + mode: "0644".into(), + parents: false, + content: stub_ref(), + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S210"); + } + + #[tokio::test] + async fn write_rejects_invalid_mode_with_s210() { + let b = stub_backend(HostFsConfig::default()); + let err = b + .write(crate::fs::WriteArgs { + path: "/tmp/iii-shell-write-bad-mode".into(), + mode: "not-octal".into(), + parents: false, + content: stub_ref(), + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S210"); + } + + #[tokio::test] + async fn write_rejects_path_outside_host_root_with_s215() { + let root = tmp(); + let cfg = HostFsConfig { + host_root: Some(root.clone()), + ..Default::default() + }; + let b = stub_backend(cfg); + let err = b + .write(crate::fs::WriteArgs { + path: "/etc/iii-shell-escape".into(), + mode: "0644".into(), + parents: false, + content: stub_ref(), + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S215"); + } + + #[tokio::test] + async fn write_with_missing_parent_and_parents_false_returns_s211() { + let root = tmp(); + let target = root + .join("nonexistent") + .join("file.txt") + .to_string_lossy() + .to_string(); + let b = stub_backend(HostFsConfig::default()); + let err = b + .write(crate::fs::WriteArgs { + path: target, + mode: "0644".into(), + parents: false, + content: stub_ref(), + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S211"); + } + + #[tokio::test] + async fn read_rejects_missing_file_with_s211() { + let root = tmp(); + let canon_root = std::fs::canonicalize(&root).unwrap(); + let missing = canon_root.join("nope.txt").to_string_lossy().to_string(); + let cfg = HostFsConfig { + host_root: Some(root.clone()), + ..Default::default() + }; + let b = stub_backend(cfg); + let err = b + .read(crate::fs::ReadArgs { path: missing }) + .await + .unwrap_err(); + assert_eq!(err.code, "S211"); + } + + #[tokio::test] + async fn read_rejects_directory_with_s212() { + let root = tmp(); + let dir = root.to_string_lossy().to_string(); + let cfg = HostFsConfig { + host_root: Some(root.clone()), + ..Default::default() + }; + let b = stub_backend(cfg); + let err = b.read(crate::fs::ReadArgs { path: dir }).await.unwrap_err(); + assert_eq!(err.code, "S212"); + } + + #[tokio::test] + async fn read_rejects_oversize_file_with_s218_preflight() { + let root = tmp(); + let f = root.join("big.bin"); + std::fs::write(&f, vec![0u8; 1024]).unwrap(); + let cfg = HostFsConfig { + host_root: Some(root.clone()), + max_read_bytes: 10, + ..Default::default() + }; + let b = stub_backend(cfg); + let err = b + .read(crate::fs::ReadArgs { + path: f.to_string_lossy().to_string(), + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S218"); + } + + #[tokio::test] + async fn read_rejects_path_outside_host_root_with_s215() { + let root = tmp(); + let cfg = HostFsConfig { + host_root: Some(root.clone()), + ..Default::default() + }; + let b = stub_backend(cfg); + let err = b + .read(crate::fs::ReadArgs { + path: "/etc/iii-shell-escape".into(), + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S215"); + } + + #[tokio::test] + async fn read_create_channel_failure_maps_to_s216() { + let root = tmp(); + let f = root.join("ok.txt"); + std::fs::write(&f, b"hello").unwrap(); + let cfg = HostFsConfig { + host_root: Some(root.clone()), + ..Default::default() + }; + let b = stub_backend(cfg); + let err = b + .read(crate::fs::ReadArgs { + path: f.to_string_lossy().to_string(), + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S216"); + } + + #[tokio::test] + async fn grep_finds_matches_in_dir() { + let root = tmp(); + std::fs::write(root.join("a.txt"), "alpha\nbeta\nalpha-beta\n").unwrap(); + let h = stub_backend(HostFsConfig::default()); + let resp = h + .grep(crate::fs::GrepArgs { + path: root.to_str().unwrap().into(), + pattern: "alpha".into(), + recursive: true, + ignore_case: false, + include_glob: vec![], + exclude_glob: vec![], + max_matches: 100, + max_line_bytes: 8192, + }) + .await + .unwrap(); + assert_eq!(resp.matches.len(), 2); + assert!(!resp.truncated); + assert_eq!(resp.matches[0].line, 1); + } + + #[tokio::test] + async fn grep_recursive_false_on_dir_returns_s210() { + let root = tmp(); + let h = stub_backend(HostFsConfig::default()); + let err = h + .grep(crate::fs::GrepArgs { + path: root.to_str().unwrap().into(), + pattern: "x".into(), + recursive: false, + ignore_case: false, + include_glob: vec![], + exclude_glob: vec![], + max_matches: 100, + max_line_bytes: 8192, + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S210"); + } + + #[tokio::test] + async fn grep_bad_regex_returns_s217() { + let root = tmp(); + std::fs::write(root.join("a.txt"), "x").unwrap(); + let h = stub_backend(HostFsConfig::default()); + let err = h + .grep(crate::fs::GrepArgs { + path: root.to_str().unwrap().into(), + pattern: "[unclosed".into(), + recursive: true, + ignore_case: false, + include_glob: vec![], + exclude_glob: vec![], + max_matches: 1, + max_line_bytes: 8192, + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S217"); + } + + #[tokio::test] + async fn grep_truncates_at_max_matches() { + let root = tmp(); + std::fs::write(root.join("a.txt"), "x\nx\nx\nx\nx\n").unwrap(); + let h = stub_backend(HostFsConfig::default()); + let resp = h + .grep(crate::fs::GrepArgs { + path: root.to_str().unwrap().into(), + pattern: "x".into(), + recursive: true, + ignore_case: false, + include_glob: vec![], + exclude_glob: vec![], + max_matches: 2, + max_line_bytes: 8192, + }) + .await + .unwrap(); + assert_eq!(resp.matches.len(), 2); + assert!(resp.truncated); + } + + #[tokio::test] + async fn grep_skips_binary_files() { + let root = tmp(); + std::fs::write(root.join("bin.dat"), b"x\0x\0x\nx\nx\n").unwrap(); + std::fs::write(root.join("a.txt"), "x\n").unwrap(); + let h = stub_backend(HostFsConfig::default()); + let resp = h + .grep(crate::fs::GrepArgs { + path: root.to_str().unwrap().into(), + pattern: "x".into(), + recursive: true, + ignore_case: false, + include_glob: vec![], + exclude_glob: vec![], + max_matches: 100, + max_line_bytes: 8192, + }) + .await + .unwrap(); + assert!(resp.matches.iter().all(|m| m.path.ends_with("a.txt"))); + } + + #[tokio::test] + async fn grep_include_glob_filters_by_extension() { + let root = tmp(); + std::fs::write(root.join("a.txt"), "match\n").unwrap(); + std::fs::write(root.join("b.md"), "match\n").unwrap(); + let h = stub_backend(HostFsConfig::default()); + let resp = h + .grep(crate::fs::GrepArgs { + path: root.to_str().unwrap().into(), + pattern: "match".into(), + recursive: true, + ignore_case: false, + include_glob: vec!["*.txt".into()], + exclude_glob: vec![], + max_matches: 100, + max_line_bytes: 8192, + }) + .await + .unwrap(); + assert_eq!(resp.matches.len(), 1); + assert!(resp.matches[0].path.ends_with("a.txt")); + } + + #[tokio::test] + async fn sed_literal_replaces_files_form() { + let root = tmp(); + let f = root.join("s.txt"); + std::fs::write(&f, "hello world hello").unwrap(); + let h = stub_backend(HostFsConfig::default()); + let resp = h + .sed(crate::fs::SedArgs { + files: vec![f.to_str().unwrap().into()], + path: None, + recursive: false, + include_glob: vec![], + exclude_glob: vec![], + pattern: "hello".into(), + replacement: "HI".into(), + regex: false, + first_only: false, + ignore_case: false, + }) + .await + .unwrap(); + assert_eq!(resp.total_replacements, 2); + assert_eq!(resp.results.len(), 1); + assert!(resp.results[0].success); + assert_eq!(std::fs::read_to_string(&f).unwrap(), "HI world HI"); + } + + #[tokio::test] + async fn sed_first_only_stops_after_one() { + let root = tmp(); + let f = root.join("s.txt"); + std::fs::write(&f, "x x x").unwrap(); + let h = stub_backend(HostFsConfig::default()); + let resp = h + .sed(crate::fs::SedArgs { + files: vec![f.to_str().unwrap().into()], + path: None, + recursive: false, + include_glob: vec![], + exclude_glob: vec![], + pattern: "x".into(), + replacement: "Y".into(), + regex: false, + first_only: true, + ignore_case: false, + }) + .await + .unwrap(); + assert_eq!(resp.total_replacements, 1); + assert_eq!(std::fs::read_to_string(&f).unwrap(), "Y x x"); + } + + #[tokio::test] + async fn sed_neither_files_nor_path_returns_s210() { + let h = stub_backend(HostFsConfig::default()); + let err = h + .sed(crate::fs::SedArgs { + files: vec![], + path: None, + recursive: false, + include_glob: vec![], + exclude_glob: vec![], + pattern: "x".into(), + replacement: "Y".into(), + regex: false, + first_only: false, + ignore_case: false, + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S210"); + } + + #[tokio::test] + async fn sed_both_files_and_path_returns_s210() { + let root = tmp(); + let h = stub_backend(HostFsConfig::default()); + let err = h + .sed(crate::fs::SedArgs { + files: vec!["/x".into()], + path: Some(root.to_str().unwrap().into()), + recursive: true, + include_glob: vec![], + exclude_glob: vec![], + pattern: "x".into(), + replacement: "Y".into(), + regex: false, + first_only: false, + ignore_case: false, + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S210"); + } + + #[tokio::test] + async fn sed_path_form_recursive_false_on_dir_returns_s210() { + let root = tmp(); + let h = stub_backend(HostFsConfig::default()); + let err = h + .sed(crate::fs::SedArgs { + files: vec![], + path: Some(root.to_str().unwrap().into()), + recursive: false, + include_glob: vec![], + exclude_glob: vec![], + pattern: "x".into(), + replacement: "Y".into(), + regex: false, + first_only: false, + ignore_case: false, + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S210"); + } + + #[tokio::test] + async fn sed_regex_with_capture_refs_works() { + let root = tmp(); + let f = root.join("r.txt"); + std::fs::write(&f, "Alice loves Bob\n").unwrap(); + let h = stub_backend(HostFsConfig::default()); + let resp = h + .sed(crate::fs::SedArgs { + files: vec![f.to_str().unwrap().into()], + path: None, + recursive: false, + include_glob: vec![], + exclude_glob: vec![], + pattern: "(\\w+) loves (\\w+)".into(), + replacement: "$2 then $1".into(), + regex: true, + first_only: false, + ignore_case: false, + }) + .await + .unwrap(); + assert_eq!(resp.total_replacements, 1); + assert_eq!(std::fs::read_to_string(&f).unwrap(), "Bob then Alice\n"); + } + + /// Regression: with `regex=false, ignore_case=true`, replacement is + /// supposed to be literal text. The case-insensitive path used to + /// `replace('$', "$$")` before returning from a closure passed to + /// `Regex::replace_all`, but closure returns are inserted verbatim + /// (no $N substitution), so the escape doubled `$` and corrupted + /// user-supplied literals like "$1" into "$$1". + #[tokio::test] + async fn sed_literal_ignore_case_preserves_dollar_in_replacement() { + let root = tmp(); + let f = root.join("d.txt"); + std::fs::write(&f, "HELLO world\n").unwrap(); + let h = stub_backend(HostFsConfig::default()); + let resp = h + .sed(crate::fs::SedArgs { + files: vec![f.to_str().unwrap().into()], + path: None, + recursive: false, + include_glob: vec![], + exclude_glob: vec![], + pattern: "hello".into(), + replacement: "$1".into(), + regex: false, + first_only: false, + ignore_case: true, + }) + .await + .unwrap(); + assert_eq!(resp.total_replacements, 1); + assert_eq!(std::fs::read_to_string(&f).unwrap(), "$1 world\n"); + } +} diff --git a/shell/src/fs/mod.rs b/shell/src/fs/mod.rs new file mode 100644 index 0000000000..3ca7982bfd --- /dev/null +++ b/shell/src/fs/mod.rs @@ -0,0 +1,607 @@ +pub mod error; +pub mod host; +pub mod sandbox; +pub mod wire; + +use async_trait::async_trait; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::fs::error::FsError; +use crate::fs::wire::{FsEntry, FsMatch, FsSedFileResult}; + +/// Per-call target selector carried at the top level of each +/// `shell::fs::*` request body. Defaults to host when omitted. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Target { + #[default] + Host, + Sandbox { + sandbox_id: Uuid, + }, +} + +/// Wire-identical mirror of `iii_sdk::channels::StreamChannelRef`. The SDK +/// type lacks `JsonSchema` in 0.11.3, which would block typed registration +/// of `shell::fs::write`/`read`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)] +pub struct ContentRef { + pub channel_id: String, + pub access_key: String, + #[serde(default)] + pub direction: ContentDirection, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)] +#[serde(rename_all = "lowercase")] +pub enum ContentDirection { + #[default] + Read, + Write, +} + +impl From for iii_sdk::channels::StreamChannelRef { + fn from(c: ContentRef) -> Self { + iii_sdk::channels::StreamChannelRef { + channel_id: c.channel_id, + access_key: c.access_key, + direction: match c.direction { + ContentDirection::Read => iii_sdk::channels::ChannelDirection::Read, + ContentDirection::Write => iii_sdk::channels::ChannelDirection::Write, + }, + } + } +} + +impl From for ContentRef { + fn from(c: iii_sdk::channels::StreamChannelRef) -> Self { + ContentRef { + channel_id: c.channel_id, + access_key: c.access_key, + direction: match c.direction { + iii_sdk::channels::ChannelDirection::Read => ContentDirection::Read, + iii_sdk::channels::ChannelDirection::Write => ContentDirection::Write, + }, + } + } +} + +// Backend args — `FsBackend` trait inputs, no `target`, no `JsonSchema`. + +#[derive(Debug, Deserialize)] +pub struct LsArgs { + pub path: String, +} + +#[derive(Debug, Deserialize)] +pub struct StatArgs { + pub path: String, +} + +#[derive(Debug, Deserialize)] +pub struct MkdirArgs { + pub path: String, + #[serde(default = "default_mkdir_mode")] + pub mode: String, + #[serde(default)] + pub parents: bool, +} + +#[derive(Debug, Deserialize)] +pub struct RmArgs { + pub path: String, + #[serde(default)] + pub recursive: bool, +} + +#[derive(Debug, Deserialize)] +pub struct ChmodArgs { + pub path: String, + pub mode: String, + #[serde(default)] + pub uid: Option, + #[serde(default)] + pub gid: Option, + #[serde(default)] + pub recursive: bool, +} + +#[derive(Debug, Deserialize)] +pub struct MvArgs { + pub src: String, + pub dst: String, + #[serde(default)] + pub overwrite: bool, +} + +#[derive(Debug, Deserialize)] +pub struct GrepArgs { + pub path: String, + pub pattern: String, + #[serde(default = "default_true")] + pub recursive: bool, + #[serde(default)] + pub ignore_case: bool, + #[serde(default)] + pub include_glob: Vec, + #[serde(default)] + pub exclude_glob: Vec, + #[serde(default = "default_max_matches")] + pub max_matches: u64, + #[serde(default = "default_max_line_bytes")] + pub max_line_bytes: u64, +} +fn default_max_matches() -> u64 { + 10_000 +} +fn default_max_line_bytes() -> u64 { + 4096 +} +fn default_mkdir_mode() -> String { + "0755".to_string() +} +fn default_write_mode() -> String { + "0644".to_string() +} + +#[derive(Debug, Deserialize)] +pub struct SedArgs { + #[serde(default)] + pub files: Vec, + #[serde(default)] + pub path: Option, + #[serde(default = "default_true")] + pub recursive: bool, + #[serde(default)] + pub include_glob: Vec, + #[serde(default)] + pub exclude_glob: Vec, + pub pattern: String, + pub replacement: String, + #[serde(default = "default_true")] + pub regex: bool, + #[serde(default)] + pub first_only: bool, + #[serde(default)] + pub ignore_case: bool, +} +fn default_true() -> bool { + true +} + +#[derive(Debug, Deserialize)] +pub struct WriteArgs { + pub path: String, + #[serde(default = "default_write_mode")] + pub mode: String, + #[serde(default)] + pub parents: bool, + pub content: iii_sdk::channels::StreamChannelRef, +} + +#[derive(Debug, Deserialize)] +pub struct ReadArgs { + pub path: String, +} + +// Registration-boundary requests — each carries `target` plus the matching +// `*Args` fields and derives `JsonSchema` for the SDK. + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct LsRequest { + #[serde(default)] + pub target: Target, + pub path: String, +} +impl LsRequest { + pub fn split(self) -> (Target, LsArgs) { + (self.target, LsArgs { path: self.path }) + } +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct StatRequest { + #[serde(default)] + pub target: Target, + pub path: String, +} +impl StatRequest { + pub fn split(self) -> (Target, StatArgs) { + (self.target, StatArgs { path: self.path }) + } +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct MkdirRequest { + #[serde(default)] + pub target: Target, + pub path: String, + #[serde(default = "default_mkdir_mode")] + pub mode: String, + #[serde(default)] + pub parents: bool, +} +impl MkdirRequest { + pub fn split(self) -> (Target, MkdirArgs) { + ( + self.target, + MkdirArgs { + path: self.path, + mode: self.mode, + parents: self.parents, + }, + ) + } +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct RmRequest { + #[serde(default)] + pub target: Target, + pub path: String, + #[serde(default)] + pub recursive: bool, +} +impl RmRequest { + pub fn split(self) -> (Target, RmArgs) { + ( + self.target, + RmArgs { + path: self.path, + recursive: self.recursive, + }, + ) + } +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ChmodRequest { + #[serde(default)] + pub target: Target, + pub path: String, + pub mode: String, + #[serde(default)] + pub uid: Option, + #[serde(default)] + pub gid: Option, + #[serde(default)] + pub recursive: bool, +} +impl ChmodRequest { + pub fn split(self) -> (Target, ChmodArgs) { + ( + self.target, + ChmodArgs { + path: self.path, + mode: self.mode, + uid: self.uid, + gid: self.gid, + recursive: self.recursive, + }, + ) + } +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct MvRequest { + #[serde(default)] + pub target: Target, + pub src: String, + pub dst: String, + #[serde(default)] + pub overwrite: bool, +} +impl MvRequest { + pub fn split(self) -> (Target, MvArgs) { + ( + self.target, + MvArgs { + src: self.src, + dst: self.dst, + overwrite: self.overwrite, + }, + ) + } +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GrepRequest { + #[serde(default)] + pub target: Target, + pub path: String, + pub pattern: String, + #[serde(default = "default_true")] + pub recursive: bool, + #[serde(default)] + pub ignore_case: bool, + #[serde(default)] + pub include_glob: Vec, + #[serde(default)] + pub exclude_glob: Vec, + #[serde(default = "default_max_matches")] + pub max_matches: u64, + #[serde(default = "default_max_line_bytes")] + pub max_line_bytes: u64, +} +impl GrepRequest { + pub fn split(self) -> (Target, GrepArgs) { + ( + self.target, + GrepArgs { + path: self.path, + pattern: self.pattern, + recursive: self.recursive, + ignore_case: self.ignore_case, + include_glob: self.include_glob, + exclude_glob: self.exclude_glob, + max_matches: self.max_matches, + max_line_bytes: self.max_line_bytes, + }, + ) + } +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SedRequest { + #[serde(default)] + pub target: Target, + #[serde(default)] + pub files: Vec, + #[serde(default)] + pub path: Option, + #[serde(default = "default_true")] + pub recursive: bool, + #[serde(default)] + pub include_glob: Vec, + #[serde(default)] + pub exclude_glob: Vec, + pub pattern: String, + pub replacement: String, + #[serde(default = "default_true")] + pub regex: bool, + #[serde(default)] + pub first_only: bool, + #[serde(default)] + pub ignore_case: bool, +} +impl SedRequest { + pub fn split(self) -> (Target, SedArgs) { + ( + self.target, + SedArgs { + files: self.files, + path: self.path, + recursive: self.recursive, + include_glob: self.include_glob, + exclude_glob: self.exclude_glob, + pattern: self.pattern, + replacement: self.replacement, + regex: self.regex, + first_only: self.first_only, + ignore_case: self.ignore_case, + }, + ) + } +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct WriteRequest { + #[serde(default)] + pub target: Target, + pub path: String, + #[serde(default = "default_write_mode")] + pub mode: String, + #[serde(default)] + pub parents: bool, + pub content: ContentRef, +} +impl WriteRequest { + pub fn split(self) -> (Target, WriteArgs) { + ( + self.target, + WriteArgs { + path: self.path, + mode: self.mode, + parents: self.parents, + content: self.content.into(), + }, + ) + } +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ReadRequest { + #[serde(default)] + pub target: Target, + pub path: String, +} +impl ReadRequest { + pub fn split(self) -> (Target, ReadArgs) { + (self.target, ReadArgs { path: self.path }) + } +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct LsResponse { + pub entries: Vec, +} +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(transparent)] +pub struct StatResponse(pub FsEntry); +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct MkdirResponse { + pub created: bool, +} +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct RmResponse { + pub removed: bool, +} +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct ChmodResponse { + pub updated: u64, +} +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct MvResponse { + pub moved: bool, +} +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct GrepResponse { + pub matches: Vec, + pub truncated: bool, +} +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct SedResponse { + pub results: Vec, + pub total_replacements: u64, +} +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct WriteResponse { + pub bytes_written: u64, + pub path: String, +} + +/// Backend-internal read result, holding the SDK's `StreamChannelRef` +/// directly so the host backend can return what `create_channel` produced +/// without conversion. Wire-facing reads serialize via `ReadResponseWire`. +#[derive(Debug, Serialize, Deserialize)] +pub struct ReadResponse { + pub content: iii_sdk::channels::StreamChannelRef, + pub size: u64, + pub mode: String, + pub mtime: i64, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct ReadResponseWire { + pub content: ContentRef, + pub size: u64, + pub mode: String, + pub mtime: i64, +} +impl From for ReadResponseWire { + fn from(r: ReadResponse) -> Self { + ReadResponseWire { + content: r.content.into(), + size: r.size, + mode: r.mode, + mtime: r.mtime, + } + } +} + +pub type FsCallResult = Result; + +#[async_trait] +pub trait FsBackend: Send + Sync { + async fn ls(&self, req: LsArgs) -> FsCallResult; + async fn stat(&self, req: StatArgs) -> FsCallResult; + async fn mkdir(&self, req: MkdirArgs) -> FsCallResult; + async fn rm(&self, req: RmArgs) -> FsCallResult; + async fn chmod(&self, req: ChmodArgs) -> FsCallResult; + async fn mv(&self, req: MvArgs) -> FsCallResult; + async fn grep(&self, req: GrepArgs) -> FsCallResult; + async fn sed(&self, req: SedArgs) -> FsCallResult; + async fn write(&self, req: WriteArgs) -> FsCallResult; + async fn read(&self, req: ReadArgs) -> FsCallResult; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn target_defaults_to_host_when_field_missing() { + let v: Target = serde_json::from_value(serde_json::json!({"kind": "host"})).unwrap(); + assert_eq!(v, Target::Host); + } + + #[test] + fn target_parses_sandbox() { + let id = Uuid::new_v4(); + let v: Target = serde_json::from_value( + serde_json::json!({"kind": "sandbox", "sandbox_id": id.to_string()}), + ) + .unwrap(); + assert_eq!(v, Target::Sandbox { sandbox_id: id }); + } + + #[test] + fn target_rejects_unknown_kind() { + let r: Result = serde_json::from_value(serde_json::json!({"kind": "wat"})); + assert!(r.is_err()); + } + + #[test] + fn grep_args_recursive_defaults_true() { + let r: GrepArgs = + serde_json::from_value(serde_json::json!({"path":"/x","pattern":"foo"})).unwrap(); + assert!(r.recursive); + assert_eq!(r.max_matches, 10_000); + assert_eq!(r.max_line_bytes, 4096); + } + + #[test] + fn sed_args_regex_defaults_true() { + let r: SedArgs = serde_json::from_value( + serde_json::json!({"path":"/x","pattern":"a","replacement":"b"}), + ) + .unwrap(); + assert!(r.regex); + assert!(r.recursive); + } + + #[test] + fn mkdir_args_mode_defaults_0755() { + let r: MkdirArgs = serde_json::from_value(serde_json::json!({"path":"/x"})).unwrap(); + assert_eq!(r.mode, "0755"); + assert!(!r.parents); + } + + #[test] + fn write_args_mode_defaults_0644() { + let r: WriteArgs = serde_json::from_value(serde_json::json!({ + "path":"/x", + "content": { + "channel_id": "c-1", + "access_key": "k-1", + "direction": "read" + } + })) + .unwrap(); + assert_eq!(r.mode, "0644"); + assert_eq!(r.content.channel_id, "c-1"); + } + + #[test] + fn ls_request_target_defaults_to_host() { + let r: LsRequest = serde_json::from_value(serde_json::json!({"path": "/x"})).unwrap(); + assert_eq!(r.target, Target::Host); + } + + #[test] + fn ls_request_with_sandbox_target_round_trips() { + let id = Uuid::new_v4(); + let r: LsRequest = serde_json::from_value(serde_json::json!({ + "target": {"kind": "sandbox", "sandbox_id": id.to_string()}, + "path": "/x", + })) + .unwrap(); + assert_eq!(r.target, Target::Sandbox { sandbox_id: id }); + let (t, args) = r.split(); + assert_eq!(t, Target::Sandbox { sandbox_id: id }); + assert_eq!(args.path, "/x"); + } + + #[test] + fn write_request_splits_contentref_to_streamchannelref() { + let req: WriteRequest = serde_json::from_value(serde_json::json!({ + "path": "/x", + "content": {"channel_id": "c", "access_key": "k", "direction": "read"}, + })) + .unwrap(); + let (_t, args) = req.split(); + assert_eq!(args.content.channel_id, "c"); + } +} diff --git a/shell/src/fs/sandbox.rs b/shell/src/fs/sandbox.rs new file mode 100644 index 0000000000..9ff555f89b --- /dev/null +++ b/shell/src/fs/sandbox.rs @@ -0,0 +1,481 @@ +//! Sandbox-target backend. Forwards every fs op to the engine daemon's +//! `sandbox::fs::` trigger via `iii.trigger`. Stateless — engine owns +//! all sandbox state, including channel allocation for write/read +//! passthroughs. + +use async_trait::async_trait; +use iii_sdk::{IIIError, TriggerRequest}; +use serde_json::{json, Value}; +use std::sync::Arc; +use uuid::Uuid; + +use crate::fs::error::FsError; +use crate::fs::{ + ChmodArgs, ChmodResponse, FsBackend, FsCallResult, GrepArgs, GrepResponse, LsArgs, LsResponse, + MkdirArgs, MkdirResponse, MvArgs, MvResponse, ReadArgs, ReadResponse, RmArgs, RmResponse, + SedArgs, SedResponse, StatArgs, StatResponse, WriteArgs, WriteResponse, +}; + +/// Test seam: lets tests swap `iii.trigger` for a mock. +#[async_trait] +pub trait TriggerFwd: Send + Sync { + async fn trigger(&self, function_id: &str, payload: Value) -> Result; +} + +pub struct IiiTriggerFwd { + iii: iii_sdk::III, +} + +impl IiiTriggerFwd { + pub fn new(iii: iii_sdk::III) -> Self { + Self { iii } + } +} + +#[async_trait] +impl TriggerFwd for IiiTriggerFwd { + async fn trigger(&self, function_id: &str, payload: Value) -> Result { + self.iii + .trigger(TriggerRequest { + function_id: function_id.to_string(), + payload, + action: None, + timeout_ms: None, + }) + .await + } +} + +pub struct SandboxFsBackend { + fwd: Arc, + enabled: bool, + sandbox_id: Uuid, +} + +impl SandboxFsBackend { + pub fn new(fwd: Arc, enabled: bool, sandbox_id: Uuid) -> Self { + Self { + fwd, + enabled, + sandbox_id, + } + } + + fn check_enabled(&self) -> FsCallResult<()> { + if !self.enabled { + return Err(FsError::new("S210", "sandbox target disabled in config")); + } + Ok(()) + } + + async fn dispatch( + &self, + function_id: &str, + mut payload: Value, + ) -> FsCallResult { + self.check_enabled()?; + if let Some(obj) = payload.as_object_mut() { + obj.insert("sandbox_id".into(), json!(self.sandbox_id.to_string())); + } + let resp = self + .fwd + .trigger(function_id, payload) + .await + .map_err(map_iii_err)?; + serde_json::from_value(resp) + .map_err(|e| FsError::new("S216", format!("bad engine response: {e}"))) + } +} + +/// Recover an S2xx code from an `IIIError`. Engine `Remote` errors carry +/// the code structurally; the engine's `invocation_failed` wrapper hides +/// it inside the message string; mock paths emit a JSON payload as +/// `Handler(string)`. Unknown shapes fall through to S216. +fn map_iii_err(err: IIIError) -> FsError { + match &err { + IIIError::Remote { code, message, .. } if code.starts_with('S') => { + return FsError::new( + map_static_code(code), + format!("forwarded from engine: {message}"), + ); + } + IIIError::Remote { message, .. } => { + if let Some(c) = scan_s_code(message) { + return FsError::new( + map_static_code(c), + format!("forwarded from engine (wrapped): {message}"), + ); + } + } + IIIError::Handler(s) => { + if let Ok(parsed) = serde_json::from_str::(s) { + if let Some(c) = parsed.get("code").and_then(|v| v.as_str()) { + let msg = parsed.get("message").and_then(|v| v.as_str()).unwrap_or(""); + return FsError::new( + map_static_code(c), + format!("forwarded from engine: {msg}"), + ); + } + } + if let Some(c) = scan_s_code(s) { + return FsError::new( + map_static_code(c), + format!("forwarded from engine (raw): {s}"), + ); + } + } + _ => {} + } + FsError::new("S216", format!("engine error: {err:?}")) +} + +fn scan_s_code(s: &str) -> Option<&str> { + let bytes = s.as_bytes(); + for i in 0..bytes.len().saturating_sub(3) { + if bytes[i] == b'S' + && bytes[i + 1].is_ascii_digit() + && bytes[i + 2].is_ascii_digit() + && bytes[i + 3].is_ascii_digit() + { + // Reject matches preceded by alphanumerics so we don't grab + // the tail of a longer identifier (e.g. "FOO_S211"). + let preceded_by_word = + i > 0 && (bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_'); + if !preceded_by_word { + return Some(&s[i..i + 4]); + } + } + } + None +} + +fn map_static_code(code: &str) -> &'static str { + match code { + "S001" => "S001", + "S002" => "S002", + "S003" => "S003", + "S004" => "S004", + "S200" => "S200", + "S210" => "S210", + "S211" => "S211", + "S212" => "S212", + "S213" => "S213", + "S214" => "S214", + "S215" => "S215", + "S216" => "S216", + "S217" => "S217", + "S218" => "S218", + "S219" => "S219", + _ => "S216", + } +} + +#[async_trait] +impl FsBackend for SandboxFsBackend { + async fn ls(&self, req: LsArgs) -> FsCallResult { + self.dispatch("sandbox::fs::ls", json!({ "path": req.path })) + .await + } + async fn stat(&self, req: StatArgs) -> FsCallResult { + // StatResponse is #[serde(transparent)] so it deserializes from + // the engine's flat FsEntry shape directly. + self.dispatch("sandbox::fs::stat", json!({ "path": req.path })) + .await + } + async fn mkdir(&self, req: MkdirArgs) -> FsCallResult { + self.dispatch( + "sandbox::fs::mkdir", + json!({ "path": req.path, "mode": req.mode, "parents": req.parents }), + ) + .await + } + async fn rm(&self, req: RmArgs) -> FsCallResult { + self.dispatch( + "sandbox::fs::rm", + json!({ "path": req.path, "recursive": req.recursive }), + ) + .await + } + async fn chmod(&self, req: ChmodArgs) -> FsCallResult { + self.dispatch( + "sandbox::fs::chmod", + json!({ + "path": req.path, "mode": req.mode, + "uid": req.uid, "gid": req.gid, "recursive": req.recursive + }), + ) + .await + } + async fn mv(&self, req: MvArgs) -> FsCallResult { + self.dispatch( + "sandbox::fs::mv", + json!({ "src": req.src, "dst": req.dst, "overwrite": req.overwrite }), + ) + .await + } + async fn grep(&self, req: GrepArgs) -> FsCallResult { + self.dispatch( + "sandbox::fs::grep", + json!({ + "path": req.path, "pattern": req.pattern, + "recursive": req.recursive, "ignore_case": req.ignore_case, + "include_glob": req.include_glob, "exclude_glob": req.exclude_glob, + "max_matches": req.max_matches, "max_line_bytes": req.max_line_bytes + }), + ) + .await + } + async fn sed(&self, req: SedArgs) -> FsCallResult { + self.dispatch( + "sandbox::fs::sed", + json!({ + "files": req.files, "path": req.path, + "recursive": req.recursive, + "include_glob": req.include_glob, "exclude_glob": req.exclude_glob, + "pattern": req.pattern, "replacement": req.replacement, + "regex": req.regex, "first_only": req.first_only, + "ignore_case": req.ignore_case + }), + ) + .await + } + async fn write(&self, req: WriteArgs) -> FsCallResult { + self.dispatch( + "sandbox::fs::write", + json!({ + "path": req.path, + "mode": req.mode, + "parents": req.parents, + "content": req.content, + }), + ) + .await + } + + async fn read(&self, req: ReadArgs) -> FsCallResult { + self.dispatch("sandbox::fs::read", json!({ "path": req.path })) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + struct StubFwd { + captured: Mutex>, + respond_with: Result, + } + + #[async_trait] + impl TriggerFwd for StubFwd { + async fn trigger(&self, fid: &str, payload: Value) -> Result { + *self.captured.lock().unwrap() = Some((fid.into(), payload)); + match &self.respond_with { + Ok(v) => Ok(v.clone()), + Err(json) => Err(IIIError::Handler(json.to_string())), + } + } + } + + #[tokio::test] + async fn ls_forwards_payload_with_sandbox_id() { + let stub = Arc::new(StubFwd { + captured: Mutex::new(None), + respond_with: Ok(json!({ "entries": [] })), + }); + let id = Uuid::new_v4(); + let b = SandboxFsBackend::new(stub.clone(), true, id); + let resp = b.ls(LsArgs { path: "/x".into() }).await.unwrap(); + assert_eq!(resp.entries.len(), 0); + let (fid, payload) = stub.captured.lock().unwrap().clone().unwrap(); + assert_eq!(fid, "sandbox::fs::ls"); + assert_eq!(payload.get("path").unwrap().as_str(), Some("/x")); + assert_eq!( + payload.get("sandbox_id").unwrap().as_str(), + Some(id.to_string().as_str()), + ); + } + + #[tokio::test] + async fn disabled_returns_s210() { + let stub = Arc::new(StubFwd { + captured: Mutex::new(None), + respond_with: Ok(json!({ "entries": [] })), + }); + let b = SandboxFsBackend::new(stub, false, Uuid::new_v4()); + let err = b.ls(LsArgs { path: "/x".into() }).await.unwrap_err(); + assert_eq!(err.code, "S210"); + } + + #[tokio::test] + async fn engine_s211_forwards_as_s211() { + let stub = Arc::new(StubFwd { + captured: Mutex::new(None), + respond_with: Err(r#"{"code":"S211","message":"not found"}"#), + }); + let b = SandboxFsBackend::new(stub, true, Uuid::new_v4()); + let err = b.ls(LsArgs { path: "/x".into() }).await.unwrap_err(); + assert_eq!(err.code, "S211"); + } + + #[tokio::test] + async fn engine_s002_sandbox_not_found_forwards() { + let stub = Arc::new(StubFwd { + captured: Mutex::new(None), + respond_with: Err(r#"{"code":"S002","message":"sandbox not found"}"#), + }); + let b = SandboxFsBackend::new(stub, true, Uuid::new_v4()); + let err = b.ls(LsArgs { path: "/x".into() }).await.unwrap_err(); + assert_eq!(err.code, "S002"); + } + + #[tokio::test] + async fn write_forwards_payload_with_content_and_sandbox_id() { + let stub = Arc::new(StubFwd { + captured: Mutex::new(None), + respond_with: Ok(json!({ "bytes_written": 5, "path": "/sb/x" })), + }); + let id = Uuid::new_v4(); + let b = SandboxFsBackend::new(stub.clone(), true, id); + let content = iii_sdk::channels::StreamChannelRef { + channel_id: "c-1".into(), + access_key: "k-1".into(), + direction: iii_sdk::channels::ChannelDirection::Read, + }; + let resp = b + .write(WriteArgs { + path: "/sb/x".into(), + mode: "0644".into(), + parents: false, + content: content.clone(), + }) + .await + .unwrap(); + assert_eq!(resp.bytes_written, 5); + let (fid, payload) = stub.captured.lock().unwrap().clone().unwrap(); + assert_eq!(fid, "sandbox::fs::write"); + let obj = payload.as_object().unwrap(); + assert_eq!(obj["sandbox_id"].as_str(), Some(id.to_string().as_str())); + assert_eq!(obj["path"].as_str(), Some("/sb/x")); + assert_eq!(obj["mode"].as_str(), Some("0644")); + assert_eq!(obj["parents"].as_bool(), Some(false)); + assert_eq!(obj["content"]["channel_id"].as_str(), Some("c-1")); + assert_eq!(obj["content"]["access_key"].as_str(), Some("k-1")); + assert_eq!(obj["content"]["direction"].as_str(), Some("read")); + } + + #[tokio::test] + async fn read_forwards_path_only_and_returns_engine_response() { + let stub = Arc::new(StubFwd { + captured: Mutex::new(None), + respond_with: Ok(json!({ + "content": { "channel_id": "ec", "access_key": "ek", "direction": "read" }, + "size": 42, + "mode": "0644", + "mtime": 1700000000 + })), + }); + let id = Uuid::new_v4(); + let b = SandboxFsBackend::new(stub.clone(), true, id); + let resp = b + .read(ReadArgs { + path: "/sb/y".into(), + }) + .await + .unwrap(); + assert_eq!(resp.size, 42); + assert_eq!(resp.content.channel_id, "ec"); + assert_eq!(resp.content.access_key, "ek"); + let (fid, payload) = stub.captured.lock().unwrap().clone().unwrap(); + assert_eq!(fid, "sandbox::fs::read"); + let obj = payload.as_object().unwrap(); + assert_eq!(obj["sandbox_id"].as_str(), Some(id.to_string().as_str())); + assert_eq!(obj["path"].as_str(), Some("/sb/y")); + assert!( + obj.get("content").is_none(), + "read request must not carry a content field" + ); + } + + #[tokio::test] + async fn engine_remote_with_s_code_forwards_directly() { + struct RemoteFwd; + #[async_trait] + impl TriggerFwd for RemoteFwd { + async fn trigger(&self, _fid: &str, _payload: Value) -> Result { + Err(IIIError::Remote { + code: "S214".into(), + message: "directory not empty".into(), + stacktrace: None, + }) + } + } + let b = SandboxFsBackend::new(Arc::new(RemoteFwd), true, Uuid::new_v4()); + let err = b + .rm(RmArgs { + path: "/x".into(), + recursive: false, + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S214"); + } + + #[tokio::test] + async fn engine_remote_with_invocation_failed_recovers_s_code_from_message() { + // Engine wraps handler errors as Remote { code: "invocation_failed", + // message: "handler error: ...S211..." } — recover the S-code from + // the message body. + struct WrappedFwd; + #[async_trait] + impl TriggerFwd for WrappedFwd { + async fn trigger(&self, _fid: &str, _payload: Value) -> Result { + Err(IIIError::Remote { + code: "invocation_failed".into(), + message: "handler error: S211: not found".into(), + stacktrace: None, + }) + } + } + let b = SandboxFsBackend::new(Arc::new(WrappedFwd), true, Uuid::new_v4()); + let err = b.ls(LsArgs { path: "/x".into() }).await.unwrap_err(); + assert_eq!(err.code, "S211"); + } + + #[tokio::test] + async fn grep_forwards_full_payload() { + let stub = Arc::new(StubFwd { + captured: Mutex::new(None), + respond_with: Ok(json!({ "matches": [], "truncated": false })), + }); + let id = Uuid::new_v4(); + let b = SandboxFsBackend::new(stub.clone(), true, id); + let _ = b + .grep(GrepArgs { + path: "/x".into(), + pattern: "p".into(), + recursive: true, + ignore_case: true, + include_glob: vec!["*.rs".into()], + exclude_glob: vec!["target/*".into()], + max_matches: 10, + max_line_bytes: 4096, + }) + .await + .unwrap(); + let (fid, payload) = stub.captured.lock().unwrap().clone().unwrap(); + assert_eq!(fid, "sandbox::fs::grep"); + let obj = payload.as_object().unwrap(); + assert_eq!(obj["sandbox_id"].as_str(), Some(id.to_string().as_str())); + assert_eq!(obj["path"].as_str(), Some("/x")); + assert_eq!(obj["pattern"].as_str(), Some("p")); + assert_eq!(obj["recursive"].as_bool(), Some(true)); + assert_eq!(obj["ignore_case"].as_bool(), Some(true)); + assert_eq!(obj["max_matches"].as_u64(), Some(10)); + assert_eq!(obj["max_line_bytes"].as_u64(), Some(4096)); + assert_eq!(obj["include_glob"][0].as_str(), Some("*.rs")); + assert_eq!(obj["exclude_glob"][0].as_str(), Some("target/*")); + } +} diff --git a/shell/src/fs/wire.rs b/shell/src/fs/wire.rs new file mode 100644 index 0000000000..eb56563c19 --- /dev/null +++ b/shell/src/fs/wire.rs @@ -0,0 +1,114 @@ +//! Serde mirrors of the engine daemon's `sandbox::fs::*` JSON shapes. +//! Authoritative reference: `crates/iii-shell-proto/src/lib.rs:69-100` +//! (FsEntry, FsMatch, FsSedFileResult) in the motia repo. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub struct FsEntry { + pub name: String, + pub is_dir: bool, + pub size: u64, + pub mode: String, + pub mtime: i64, + pub is_symlink: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub struct FsMatch { + #[serde(alias = "file")] + pub path: String, + pub line: u64, + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub struct FsSedFileResult { + #[serde(alias = "file")] + pub path: String, + pub replacements: u64, + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub error: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fs_entry_round_trips() { + let e = FsEntry { + name: "hello.txt".into(), + is_dir: false, + size: 5, + mode: "0644".into(), + mtime: 1700000000, + is_symlink: false, + }; + let json = serde_json::to_string(&e).unwrap(); + let back: FsEntry = serde_json::from_str(&json).unwrap(); + assert_eq!(e, back); + // Field name parity with engine daemon — case matters. + assert!(json.contains("\"is_dir\"")); + assert!(json.contains("\"is_symlink\"")); + } + + #[test] + fn fs_match_round_trips() { + let m = FsMatch { + path: "/a".into(), + line: 3, + content: "x".into(), + }; + let json = serde_json::to_string(&m).unwrap(); + let back: FsMatch = serde_json::from_str(&json).unwrap(); + assert_eq!(m, back); + } + + #[test] + fn fs_entry_deserializes_upstream_format() { + let upstream = r#"{"name":"a.txt","is_dir":false,"size":5,"mode":"0644","mtime":1700000000,"is_symlink":false}"#; + let e: FsEntry = serde_json::from_str(upstream).unwrap(); + assert_eq!(e.name, "a.txt"); + assert!(!e.is_dir); + } + + #[test] + fn fs_match_deserializes_upstream_format() { + let upstream = r#"{"path":"/x","line":7,"content":"hi"}"#; + let m: FsMatch = serde_json::from_str(upstream).unwrap(); + assert_eq!(m.line, 7); + assert_eq!(m.content, "hi"); + } + + #[test] + fn fs_match_accepts_legacy_file_alias() { + // Older peers send `file` instead of `path`. + let legacy = r#"{"file":"/x","line":1,"content":"hi"}"#; + let m: FsMatch = serde_json::from_str(legacy).unwrap(); + assert_eq!(m.path, "/x"); + } + + #[test] + fn fs_sed_result_deserializes_upstream_format() { + let upstream = r#"{"path":"/x","replacements":2,"success":true}"#; + let r: FsSedFileResult = serde_json::from_str(upstream).unwrap(); + assert!(r.success); + assert!(r.error.is_none()); + } + + #[test] + fn fs_sed_result_with_error_round_trips() { + let r = FsSedFileResult { + path: "/x".into(), + replacements: 0, + success: false, + error: Some("nope".into()), + }; + let j = serde_json::to_string(&r).unwrap(); + let back: FsSedFileResult = serde_json::from_str(&j).unwrap(); + assert_eq!(r, back); + } +} diff --git a/shell/src/functions/exec.rs b/shell/src/functions/exec.rs new file mode 100644 index 0000000000..28bacd0bb6 --- /dev/null +++ b/shell/src/functions/exec.rs @@ -0,0 +1,62 @@ +use std::sync::Arc; + +use serde_json::Value; + +use crate::config::ShellConfig; +use crate::exec::{parse_argv, run_to_completion}; +use crate::functions::types::ExecResponse; + +pub async fn handle(cfg: Arc, payload: Value) -> Result { + let command = payload + .get("command") + .and_then(|v| v.as_str()) + .ok_or_else(|| "missing 'command'".to_string())?; + // Reject non-string elements rather than silently filtering: passing + // `{"args": ["--count", 5]}` would otherwise drop the `5` and run with + // partial arguments, a dangerous-to-debug deviation from caller intent. + let args: Option> = match payload.get("args") { + None | Some(Value::Null) => None, + Some(Value::Array(arr)) => { + let mut out = Vec::with_capacity(arr.len()); + for (i, v) in arr.iter().enumerate() { + match v.as_str() { + Some(s) => out.push(s.to_string()), + None => { + return Err(format!("'args[{}]' must be a string (got {})", i, v)); + } + } + } + Some(out) + } + Some(other) => { + return Err(format!( + "'args' must be an array of strings (got {})", + other + )); + } + }; + // `as_u64()` returns None for negative or float values, so an + // unparseable `timeout_ms` silently falls back to the default. This + // loose semantic is part of the wire contract (covered by e2e). + let timeout_ms = payload.get("timeout_ms").and_then(|v| v.as_u64()); + + let argv = parse_argv(command, args.as_ref()).map_err(|e| format!("argv: {}", e))?; + + cfg.is_command_allowed(&argv)?; + + let timeout = cfg.resolve_timeout(timeout_ms); + + let out = run_to_completion(&argv, &cfg, timeout) + .await + .map_err(|e| format!("exec: {}", e))?; + + Ok(ExecResponse { + exit_code: out.exit_code, + stdout: out.stdout, + stderr: out.stderr, + duration_ms: out.duration_ms, + timed_out: out.timed_out, + stdout_truncated: out.stdout_truncated, + stderr_truncated: out.stderr_truncated, + }) +} diff --git a/shell/src/functions/exec_bg.rs b/shell/src/functions/exec_bg.rs new file mode 100644 index 0000000000..ac87f54222 --- /dev/null +++ b/shell/src/functions/exec_bg.rs @@ -0,0 +1,185 @@ +use std::sync::Arc; + +use serde_json::Value; +use uuid::Uuid; + +use crate::config::ShellConfig; +use crate::exec::{build_command, parse_argv}; +use crate::functions::types::ExecBgResponse; +use crate::jobs::{self, JobHandle, JobRecord, JobStatus}; +use tokio::io::AsyncReadExt; + +pub async fn handle(cfg: Arc, payload: Value) -> Result { + let command = payload + .get("command") + .and_then(|v| v.as_str()) + .ok_or_else(|| "missing 'command'".to_string())?; + // See exec.rs — silently dropping non-string args is especially + // dangerous for a backgrounded long-lived job. + let args: Option> = match payload.get("args") { + None | Some(Value::Null) => None, + Some(Value::Array(arr)) => { + let mut out = Vec::with_capacity(arr.len()); + for (i, v) in arr.iter().enumerate() { + match v.as_str() { + Some(s) => out.push(s.to_string()), + None => { + return Err(format!("'args[{}]' must be a string (got {})", i, v)); + } + } + } + Some(out) + } + Some(other) => { + return Err(format!( + "'args' must be an array of strings (got {})", + other + )); + } + }; + + let argv = parse_argv(command, args.as_ref()).map_err(|e| format!("argv: {}", e))?; + + cfg.is_command_allowed(&argv)?; + + let mut cmd = build_command(&argv, &cfg)?; + let mut child = cmd.spawn().map_err(|e| format!("spawn: {}", e))?; + + let stdout_pipe = child.stdout.take(); + let stderr_pipe = child.stderr.take(); + + let id = format!("job-{}", Uuid::new_v4()); + let record = JobRecord { + id: id.clone(), + argv: argv.clone(), + started_at_ms: jobs::now_ms(), + finished_at_ms: None, + status: JobStatus::Running, + exit_code: None, + stdout: String::new(), + stderr: String::new(), + stdout_truncated: false, + stderr_truncated: false, + }; + // Atomic check-and-insert: prevents a TOCTOU where two concurrent + // exec_bg calls both pass a separate running_count() check before + // either insert lands. On rejection, kill the orphaned child. + match jobs::try_reserve_and_insert( + JobHandle { + record, + child: Some(child), + }, + cfg.max_concurrent_jobs, + ) + .await + { + Ok(_) => {} + Err((running, mut handle)) => { + if let Some(mut ch) = handle.child.take() { + let _ = ch.start_kill(); + } + return Err(format!( + "max concurrent jobs ({}) reached, currently running: {}", + cfg.max_concurrent_jobs, running + )); + } + } + + let id_clone = id.clone(); + let limit = cfg.max_output_bytes; + tokio::spawn(async move { + let handle = match jobs::get(&id_clone).await { + Some(h) => h, + None => return, + }; + + // Drain stdout/stderr concurrently — sequential reads deadlock + // once the child fills one pipe's ~64 KiB buffer before closing + // the other. + let stdout_task = stdout_pipe.map(|mut out| { + tokio::spawn(async move { + let mut buf = Vec::new(); + let mut trunc = false; + read_bounded(&mut out, limit, &mut buf, &mut trunc).await; + (buf, trunc) + }) + }); + let stderr_task = stderr_pipe.map(|mut err| { + tokio::spawn(async move { + let mut buf = Vec::new(); + let mut trunc = false; + read_bounded(&mut err, limit, &mut buf, &mut trunc).await; + (buf, trunc) + }) + }); + + let (stdout_buf, stdout_trunc) = match stdout_task { + Some(t) => t.await.unwrap_or_else(|_| (Vec::new(), false)), + None => (Vec::new(), false), + }; + let (stderr_buf, stderr_trunc) = match stderr_task { + Some(t) => t.await.unwrap_or_else(|_| (Vec::new(), false)), + None => (Vec::new(), false), + }; + + { + let mut h = handle.lock().await; + if let Some(mut ch) = h.child.take() { + drop(h); + let wait_res = ch.wait().await; + let mut h2 = handle.lock().await; + match wait_res { + Ok(s) => { + h2.record.exit_code = s.code(); + if h2.record.status == JobStatus::Running { + h2.record.status = if s.success() { + JobStatus::Finished + } else { + JobStatus::Failed + }; + } + } + Err(_) => { + h2.record.status = JobStatus::Failed; + } + } + } + } + + let mut h = handle.lock().await; + h.record.stdout = String::from_utf8_lossy(&stdout_buf).into_owned(); + h.record.stderr = String::from_utf8_lossy(&stderr_buf).into_owned(); + h.record.stdout_truncated = stdout_trunc; + h.record.stderr_truncated = stderr_trunc; + h.record.finished_at_ms = Some(jobs::now_ms()); + }); + + Ok(ExecBgResponse { job_id: id, argv }) +} + +async fn read_bounded( + reader: &mut R, + limit: usize, + buf: &mut Vec, + truncated: &mut bool, +) { + let mut chunk = [0u8; 8192]; + loop { + match reader.read(&mut chunk).await { + Ok(0) => break, + Ok(n) => { + if !*truncated { + if buf.len() + n > limit { + let take = limit.saturating_sub(buf.len()); + buf.extend_from_slice(&chunk[..take]); + *truncated = true; + // Keep draining to avoid SIGPIPE on the child. + } else { + buf.extend_from_slice(&chunk[..n]); + } + } + } + Err(_) => break, + } + } +} diff --git a/shell/src/functions/fs_chmod.rs b/shell/src/functions/fs_chmod.rs new file mode 100644 index 0000000000..e358da455b --- /dev/null +++ b/shell/src/functions/fs_chmod.rs @@ -0,0 +1,20 @@ +use std::sync::Arc; + +use serde_json::Value; + +use crate::fs::error::FsError; +use crate::fs::{ChmodRequest, ChmodResponse, FsBackend}; +use crate::functions::fs_dispatch::{err_to_string, pick_backend}; + +pub async fn handle( + host: Arc, + iii: iii_sdk::III, + sandbox_enabled: bool, + payload: Value, +) -> Result { + let req: ChmodRequest = serde_json::from_value(payload) + .map_err(|e| FsError::new("S210", format!("bad chmod payload: {e}")).to_json())?; + let (target, args) = req.split(); + let backend = pick_backend(target, host, iii, sandbox_enabled); + backend.chmod(args).await.map_err(err_to_string) +} diff --git a/shell/src/functions/fs_dispatch.rs b/shell/src/functions/fs_dispatch.rs new file mode 100644 index 0000000000..9fb47d1d3e --- /dev/null +++ b/shell/src/functions/fs_dispatch.rs @@ -0,0 +1,33 @@ +//! Shared backend selection used by every `shell::fs::*` handler. + +use std::sync::Arc; + +use uuid::Uuid; + +use crate::fs::error::FsError; +use crate::fs::sandbox::{IiiTriggerFwd, SandboxFsBackend}; +use crate::fs::{FsBackend, Target}; + +pub fn pick_backend( + target: Target, + host: Arc, + iii: iii_sdk::III, + sandbox_enabled: bool, +) -> Arc { + match target { + Target::Host => host, + Target::Sandbox { sandbox_id } => sandbox_for(sandbox_id, iii, sandbox_enabled), + } +} + +fn sandbox_for(id: Uuid, iii: iii_sdk::III, enabled: bool) -> Arc { + Arc::new(SandboxFsBackend::new( + Arc::new(IiiTriggerFwd::new(iii)), + enabled, + id, + )) +} + +pub fn err_to_string(e: FsError) -> String { + e.to_json() +} diff --git a/shell/src/functions/fs_grep.rs b/shell/src/functions/fs_grep.rs new file mode 100644 index 0000000000..48213e09ab --- /dev/null +++ b/shell/src/functions/fs_grep.rs @@ -0,0 +1,20 @@ +use std::sync::Arc; + +use serde_json::Value; + +use crate::fs::error::FsError; +use crate::fs::{FsBackend, GrepRequest, GrepResponse}; +use crate::functions::fs_dispatch::{err_to_string, pick_backend}; + +pub async fn handle( + host: Arc, + iii: iii_sdk::III, + sandbox_enabled: bool, + payload: Value, +) -> Result { + let req: GrepRequest = serde_json::from_value(payload) + .map_err(|e| FsError::new("S210", format!("bad grep payload: {e}")).to_json())?; + let (target, args) = req.split(); + let backend = pick_backend(target, host, iii, sandbox_enabled); + backend.grep(args).await.map_err(err_to_string) +} diff --git a/shell/src/functions/fs_ls.rs b/shell/src/functions/fs_ls.rs new file mode 100644 index 0000000000..2966c64b8f --- /dev/null +++ b/shell/src/functions/fs_ls.rs @@ -0,0 +1,20 @@ +use std::sync::Arc; + +use serde_json::Value; + +use crate::fs::error::FsError; +use crate::fs::{FsBackend, LsRequest, LsResponse}; +use crate::functions::fs_dispatch::{err_to_string, pick_backend}; + +pub async fn handle( + host: Arc, + iii: iii_sdk::III, + sandbox_enabled: bool, + payload: Value, +) -> Result { + let req: LsRequest = serde_json::from_value(payload) + .map_err(|e| FsError::new("S210", format!("bad ls payload: {e}")).to_json())?; + let (target, args) = req.split(); + let backend = pick_backend(target, host, iii, sandbox_enabled); + backend.ls(args).await.map_err(err_to_string) +} diff --git a/shell/src/functions/fs_mkdir.rs b/shell/src/functions/fs_mkdir.rs new file mode 100644 index 0000000000..e06448e37d --- /dev/null +++ b/shell/src/functions/fs_mkdir.rs @@ -0,0 +1,20 @@ +use std::sync::Arc; + +use serde_json::Value; + +use crate::fs::error::FsError; +use crate::fs::{FsBackend, MkdirRequest, MkdirResponse}; +use crate::functions::fs_dispatch::{err_to_string, pick_backend}; + +pub async fn handle( + host: Arc, + iii: iii_sdk::III, + sandbox_enabled: bool, + payload: Value, +) -> Result { + let req: MkdirRequest = serde_json::from_value(payload) + .map_err(|e| FsError::new("S210", format!("bad mkdir payload: {e}")).to_json())?; + let (target, args) = req.split(); + let backend = pick_backend(target, host, iii, sandbox_enabled); + backend.mkdir(args).await.map_err(err_to_string) +} diff --git a/shell/src/functions/fs_mv.rs b/shell/src/functions/fs_mv.rs new file mode 100644 index 0000000000..784c965faa --- /dev/null +++ b/shell/src/functions/fs_mv.rs @@ -0,0 +1,20 @@ +use std::sync::Arc; + +use serde_json::Value; + +use crate::fs::error::FsError; +use crate::fs::{FsBackend, MvRequest, MvResponse}; +use crate::functions::fs_dispatch::{err_to_string, pick_backend}; + +pub async fn handle( + host: Arc, + iii: iii_sdk::III, + sandbox_enabled: bool, + payload: Value, +) -> Result { + let req: MvRequest = serde_json::from_value(payload) + .map_err(|e| FsError::new("S210", format!("bad mv payload: {e}")).to_json())?; + let (target, args) = req.split(); + let backend = pick_backend(target, host, iii, sandbox_enabled); + backend.mv(args).await.map_err(err_to_string) +} diff --git a/shell/src/functions/fs_read.rs b/shell/src/functions/fs_read.rs new file mode 100644 index 0000000000..6ab882c1f3 --- /dev/null +++ b/shell/src/functions/fs_read.rs @@ -0,0 +1,21 @@ +use std::sync::Arc; + +use serde_json::Value; + +use crate::fs::error::FsError; +use crate::fs::{FsBackend, ReadRequest, ReadResponseWire}; +use crate::functions::fs_dispatch::{err_to_string, pick_backend}; + +pub async fn handle( + host: Arc, + iii: iii_sdk::III, + sandbox_enabled: bool, + payload: Value, +) -> Result { + let req: ReadRequest = serde_json::from_value(payload) + .map_err(|e| FsError::new("S210", format!("bad read payload: {e}")).to_json())?; + let (target, args) = req.split(); + let backend = pick_backend(target, host, iii, sandbox_enabled); + let resp = backend.read(args).await.map_err(err_to_string)?; + Ok(resp.into()) +} diff --git a/shell/src/functions/fs_rm.rs b/shell/src/functions/fs_rm.rs new file mode 100644 index 0000000000..165c424ba8 --- /dev/null +++ b/shell/src/functions/fs_rm.rs @@ -0,0 +1,20 @@ +use std::sync::Arc; + +use serde_json::Value; + +use crate::fs::error::FsError; +use crate::fs::{FsBackend, RmRequest, RmResponse}; +use crate::functions::fs_dispatch::{err_to_string, pick_backend}; + +pub async fn handle( + host: Arc, + iii: iii_sdk::III, + sandbox_enabled: bool, + payload: Value, +) -> Result { + let req: RmRequest = serde_json::from_value(payload) + .map_err(|e| FsError::new("S210", format!("bad rm payload: {e}")).to_json())?; + let (target, args) = req.split(); + let backend = pick_backend(target, host, iii, sandbox_enabled); + backend.rm(args).await.map_err(err_to_string) +} diff --git a/shell/src/functions/fs_sed.rs b/shell/src/functions/fs_sed.rs new file mode 100644 index 0000000000..0bd6129db9 --- /dev/null +++ b/shell/src/functions/fs_sed.rs @@ -0,0 +1,20 @@ +use std::sync::Arc; + +use serde_json::Value; + +use crate::fs::error::FsError; +use crate::fs::{FsBackend, SedRequest, SedResponse}; +use crate::functions::fs_dispatch::{err_to_string, pick_backend}; + +pub async fn handle( + host: Arc, + iii: iii_sdk::III, + sandbox_enabled: bool, + payload: Value, +) -> Result { + let req: SedRequest = serde_json::from_value(payload) + .map_err(|e| FsError::new("S210", format!("bad sed payload: {e}")).to_json())?; + let (target, args) = req.split(); + let backend = pick_backend(target, host, iii, sandbox_enabled); + backend.sed(args).await.map_err(err_to_string) +} diff --git a/shell/src/functions/fs_stat.rs b/shell/src/functions/fs_stat.rs new file mode 100644 index 0000000000..5db339cddf --- /dev/null +++ b/shell/src/functions/fs_stat.rs @@ -0,0 +1,20 @@ +use std::sync::Arc; + +use serde_json::Value; + +use crate::fs::error::FsError; +use crate::fs::{FsBackend, StatRequest, StatResponse}; +use crate::functions::fs_dispatch::{err_to_string, pick_backend}; + +pub async fn handle( + host: Arc, + iii: iii_sdk::III, + sandbox_enabled: bool, + payload: Value, +) -> Result { + let req: StatRequest = serde_json::from_value(payload) + .map_err(|e| FsError::new("S210", format!("bad stat payload: {e}")).to_json())?; + let (target, args) = req.split(); + let backend = pick_backend(target, host, iii, sandbox_enabled); + backend.stat(args).await.map_err(err_to_string) +} diff --git a/shell/src/functions/fs_write.rs b/shell/src/functions/fs_write.rs new file mode 100644 index 0000000000..ee0edf5f97 --- /dev/null +++ b/shell/src/functions/fs_write.rs @@ -0,0 +1,20 @@ +use std::sync::Arc; + +use serde_json::Value; + +use crate::fs::error::FsError; +use crate::fs::{FsBackend, WriteRequest, WriteResponse}; +use crate::functions::fs_dispatch::{err_to_string, pick_backend}; + +pub async fn handle( + host: Arc, + iii: iii_sdk::III, + sandbox_enabled: bool, + payload: Value, +) -> Result { + let req: WriteRequest = serde_json::from_value(payload) + .map_err(|e| FsError::new("S210", format!("bad write payload: {e}")).to_json())?; + let (target, args) = req.split(); + let backend = pick_backend(target, host, iii, sandbox_enabled); + backend.write(args).await.map_err(err_to_string) +} diff --git a/shell/src/functions/kill.rs b/shell/src/functions/kill.rs new file mode 100644 index 0000000000..907f6ba7b8 --- /dev/null +++ b/shell/src/functions/kill.rs @@ -0,0 +1,37 @@ +use crate::functions::types::{KillRequest, KillResponse}; +use crate::jobs::{self, JobStatus}; + +pub async fn handle(req: KillRequest) -> Result { + let handle = jobs::get(&req.job_id) + .await + .ok_or_else(|| format!("no such job: {}", req.job_id))?; + + let mut h = handle.lock().await; + if h.record.status != JobStatus::Running { + return Ok(KillResponse { + job_id: req.job_id, + killed: false, + status: h.record.status.clone(), + reason: Some("not running".into()), + }); + } + let Some(child) = h.child.as_mut() else { + return Ok(KillResponse { + job_id: req.job_id, + killed: false, + status: h.record.status.clone(), + reason: Some("missing child handle".into()), + }); + }; + child + .start_kill() + .map_err(|e| format!("failed to kill job {}: {}", req.job_id, e))?; + h.record.status = JobStatus::Killed; + h.record.finished_at_ms = Some(jobs::now_ms()); + Ok(KillResponse { + job_id: req.job_id, + killed: true, + status: h.record.status.clone(), + reason: None, + }) +} diff --git a/shell/src/functions/list.rs b/shell/src/functions/list.rs new file mode 100644 index 0000000000..25f9288acb --- /dev/null +++ b/shell/src/functions/list.rs @@ -0,0 +1,16 @@ +use std::sync::Arc; + +use crate::config::ShellConfig; +use crate::functions::types::{JobSummary, ListResponse}; +use crate::jobs; + +pub async fn handle(cfg: Arc) -> Result { + jobs::remove_old(cfg.job_retention_secs).await; + let all = jobs::list_all().await; + let summaries: Vec = all.iter().map(JobSummary::from).collect(); + let count = summaries.len(); + Ok(ListResponse { + jobs: summaries, + count, + }) +} diff --git a/shell/src/functions/mod.rs b/shell/src/functions/mod.rs new file mode 100644 index 0000000000..9c86b7c209 --- /dev/null +++ b/shell/src/functions/mod.rs @@ -0,0 +1,18 @@ +pub mod exec; +pub mod exec_bg; +pub mod kill; +pub mod list; +pub mod status; +pub mod types; + +pub mod fs_chmod; +pub mod fs_dispatch; +pub mod fs_grep; +pub mod fs_ls; +pub mod fs_mkdir; +pub mod fs_mv; +pub mod fs_read; +pub mod fs_rm; +pub mod fs_sed; +pub mod fs_stat; +pub mod fs_write; diff --git a/shell/src/functions/status.rs b/shell/src/functions/status.rs new file mode 100644 index 0000000000..0fb21bc0fa --- /dev/null +++ b/shell/src/functions/status.rs @@ -0,0 +1,12 @@ +use crate::functions::types::{StatusRequest, StatusResponse}; +use crate::jobs; + +pub async fn handle(req: StatusRequest) -> Result { + let handle = jobs::get(&req.job_id) + .await + .ok_or_else(|| format!("no such job: {}", req.job_id))?; + let h = handle.lock().await; + Ok(StatusResponse { + job: h.record.clone(), + }) +} diff --git a/shell/src/functions/types.rs b/shell/src/functions/types.rs new file mode 100644 index 0000000000..af7bf23b4b --- /dev/null +++ b/shell/src/functions/types.rs @@ -0,0 +1,91 @@ +//! Typed request/response shapes for the 5 `shell::*` (non-fs) functions. +//! These mirror the inline `json!()` schemas that lived in `main.rs` before +//! the migration to `RegisterFunction::new_async`. The wire format is +//! unchanged from prior versions. + +//! Typed responses for the 5 `shell::*` (non-fs) functions. Request shapes +//! for `shell::exec` and `shell::exec_bg` are not typed here because their +//! handlers accept raw `Value` to preserve legacy wire contracts. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::jobs::{JobRecord, JobStatus}; + +#[derive(Debug, Serialize, JsonSchema)] +pub struct ExecResponse { + pub exit_code: Option, + pub stdout: String, + pub stderr: String, + pub duration_ms: u64, + pub timed_out: bool, + pub stdout_truncated: bool, + pub stderr_truncated: bool, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct ExecBgResponse { + pub job_id: String, + pub argv: Vec, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct KillRequest { + pub job_id: String, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct KillResponse { + pub job_id: String, + pub killed: bool, + pub status: JobStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct StatusRequest { + pub job_id: String, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct StatusResponse { + pub job: JobRecord, +} + +/// `shell::list` returns one `JobSummary` per record. argv, stdout, and +/// stderr are deliberately omitted: the global JOBS map is process-wide and +/// has no per-caller scope, so any caller could otherwise read every other +/// caller's command line and captured output (which may embed credentials). +/// Full records remain reachable via `shell::status ` — the random +/// UUID acts as an unguessable capability for that record. +#[derive(Debug, Serialize, JsonSchema)] +pub struct JobSummary { + pub id: String, + pub status: JobStatus, + pub started_at_ms: u64, + pub finished_at_ms: Option, + pub exit_code: Option, + pub stdout_truncated: bool, + pub stderr_truncated: bool, +} + +impl From<&JobRecord> for JobSummary { + fn from(r: &JobRecord) -> Self { + Self { + id: r.id.clone(), + status: r.status.clone(), + started_at_ms: r.started_at_ms, + finished_at_ms: r.finished_at_ms, + exit_code: r.exit_code, + stdout_truncated: r.stdout_truncated, + stderr_truncated: r.stderr_truncated, + } + } +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct ListResponse { + pub jobs: Vec, + pub count: usize, +} diff --git a/shell/src/jobs.rs b/shell/src/jobs.rs new file mode 100644 index 0000000000..67d6174c2e --- /dev/null +++ b/shell/src/jobs.rs @@ -0,0 +1,206 @@ +use once_cell::sync::Lazy; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; +use tokio::process::Child; +use tokio::sync::Mutex; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum JobStatus { + Running, + Finished, + Killed, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct JobRecord { + pub id: String, + pub argv: Vec, + pub started_at_ms: u64, + pub finished_at_ms: Option, + pub status: JobStatus, + pub exit_code: Option, + pub stdout: String, + pub stderr: String, + pub stdout_truncated: bool, + pub stderr_truncated: bool, +} + +pub struct JobHandle { + pub record: JobRecord, + pub child: Option, +} + +pub struct Jobs { + pub map: Mutex>>>, +} + +impl Jobs { + fn new() -> Self { + Self { + map: Mutex::new(HashMap::new()), + } + } +} + +pub static JOBS: Lazy = Lazy::new(Jobs::new); + +pub fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// Atomically count running jobs and insert a new one if under `max`. Holds +/// the map mutex across both operations so two concurrent callers cannot +/// each pass the count check and then both insert. A handle whose lock is +/// currently held by another task is conservatively counted as running — +/// it's mid-finalization and either still Running or about to be — so the +/// soft cap may briefly under-allow at the boundary, but never over-allows. +/// +/// On rejection, returns the running count and the original handle so the +/// caller can reclaim the spawned child process and kill it. +pub async fn try_reserve_and_insert( + handle: JobHandle, + max: usize, +) -> Result { + let mut guard = JOBS.map.lock().await; + let mut running = 0usize; + for h in guard.values() { + match h.try_lock() { + Ok(g) => { + if g.record.status == JobStatus::Running { + running += 1; + } + } + Err(_) => running += 1, + } + } + if running >= max { + return Err((running, handle)); + } + let id = handle.record.id.clone(); + let boxed = Arc::new(Mutex::new(handle)); + guard.insert(id.clone(), boxed); + Ok(id) +} + +pub async fn get(id: &str) -> Option>> { + JOBS.map.lock().await.get(id).cloned() +} + +// Snapshot the map before awaiting per-job locks. Holding the map guard +// across `handle.lock().await` head-of-line-blocks every other job +// operation (insert, get) for the duration of the iteration. +async fn snapshot() -> Vec<(String, Arc>)> { + let guard = JOBS.map.lock().await; + guard.iter().map(|(k, v)| (k.clone(), v.clone())).collect() +} + +pub async fn remove_old(retention_secs: u64) { + let now = now_ms(); + let threshold_ms = retention_secs.saturating_mul(1000); + let handles = snapshot().await; + let mut to_remove: Vec = Vec::new(); + for (id, handle) in handles { + let h = handle.lock().await; + if let Some(fin) = h.record.finished_at_ms { + if now.saturating_sub(fin) > threshold_ms { + to_remove.push(id); + } + } + } + if !to_remove.is_empty() { + let mut guard = JOBS.map.lock().await; + for id in to_remove { + guard.remove(&id); + } + } +} + +pub async fn list_all() -> Vec { + let handles = snapshot().await; + let mut out = Vec::with_capacity(handles.len()); + for (_, handle) in handles { + out.push(handle.lock().await.record.clone()); + } + out +} + +/// Used by integration tests to assert lifecycle invariants. The +/// production exec_bg path uses `try_reserve_and_insert` which does the +/// counting and insertion atomically. +#[allow(dead_code)] +pub async fn running_count() -> usize { + let handles = snapshot().await; + let mut n = 0; + for (_, handle) in handles { + if handle.lock().await.record.status == JobStatus::Running { + n += 1; + } + } + n +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_insert_and_get() { + let id = format!("test-insert-{}", uuid::Uuid::new_v4()); + match try_reserve_and_insert(make_handle(&id, JobStatus::Running), usize::MAX).await { + Ok(_) => {} + Err(_) => panic!("usize::MAX cap must always succeed"), + } + let got = get(&id).await.expect("job exists"); + assert_eq!(got.lock().await.record.id, id); + JOBS.map.lock().await.remove(&id); + } + + fn make_handle(id: &str, status: JobStatus) -> JobHandle { + JobHandle { + record: JobRecord { + id: id.into(), + argv: vec!["x".into()], + started_at_ms: now_ms(), + finished_at_ms: if status == JobStatus::Running { + None + } else { + Some(now_ms()) + }, + status, + exit_code: None, + stdout: String::new(), + stderr: String::new(), + stdout_truncated: false, + stderr_truncated: false, + }, + child: None, + } + } + + // The `JOBS` singleton is shared across all tests in the binary and + // tokio runs them concurrently, so these tests use deterministic + // assertions that don't depend on absolute counts. + + #[tokio::test] + async fn try_reserve_with_zero_cap_rejects_and_returns_handle() { + let id = format!("reserve-zero-{}", uuid::Uuid::new_v4()); + match try_reserve_and_insert(make_handle(&id, JobStatus::Running), 0).await { + Ok(_) => panic!("cap=0 must reject"), + Err((_, returned)) => { + assert_eq!(returned.record.id, id); + } + } + assert!( + get(&id).await.is_none(), + "rejected reservation must not insert into the map" + ); + } +} diff --git a/shell/src/lib.rs b/shell/src/lib.rs new file mode 100644 index 0000000000..4a8446c8d1 --- /dev/null +++ b/shell/src/lib.rs @@ -0,0 +1,10 @@ +//! Library facade exposing the binary's modules so integration tests +//! under `tests/` can drive them at the public-API level. Both targets +//! share source files via Cargo's two-target compile. + +pub mod config; +pub mod exec; +pub mod fs; +pub mod functions; +pub mod jobs; +pub mod manifest; diff --git a/shell/src/main.rs b/shell/src/main.rs new file mode 100644 index 0000000000..9c1f88a1ed --- /dev/null +++ b/shell/src/main.rs @@ -0,0 +1,291 @@ +use anyhow::Result; +use clap::Parser; +use iii_sdk::{register_worker, InitOptions, OtelConfig, RegisterFunction}; +use serde_json::Value; +use std::sync::Arc; + +mod config; +mod exec; +mod fs; +mod functions; +mod jobs; +mod manifest; + +use functions::types::{KillRequest, StatusRequest}; + +#[derive(Parser, Debug)] +#[command( + name = "iii-shell", + about = "Unix shell execution worker for iii agents" +)] +struct Cli { + #[arg(long, default_value = "./config.yaml")] + config: String, + + #[arg(long, default_value = "ws://127.0.0.1:49134")] + url: String, + + #[arg(long)] + manifest: bool, +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let cli = Cli::parse(); + + if cli.manifest { + let m = manifest::build_manifest(); + println!("{}", serde_json::to_string_pretty(&m).unwrap()); + return Ok(()); + } + + let shell_config = match config::load_config(&cli.config) { + Ok(c) => { + tracing::info!( + allowlist_size = c.allowlist.len(), + denylist_size = c.denylist_patterns.len(), + max_timeout_ms = c.max_timeout_ms, + max_concurrent = c.max_concurrent_jobs, + "loaded config from {}", + cli.config + ); + c + } + Err(e) => { + tracing::warn!(error = %e, path = %cli.config, "failed to load config, using defaults"); + let mut c = config::ShellConfig::default(); + c.compile_denylist()?; + // Defaults have host_root=None and allow_unjailed=false, so this + // path refuses to start. Otherwise a missing config file would + // silently bypass the S-H2 jail requirement. + c.validate_fs_jail()?; + c + } + }; + let shared = Arc::new(shell_config); + + tracing::info!(url = %cli.url, "connecting to III engine"); + let iii = register_worker( + &cli.url, + InitOptions { + otel: Some(OtelConfig::default()), + ..Default::default() + }, + ); + + // shell::exec, shell::exec_bg, and the 10 shell::fs::* handlers take + // Value at the registration boundary so they can preserve legacy wire + // contracts (S210 for fs malformed payloads, "missing 'command'" / + // "must be a string" for exec, silent timeout_ms fallback) that the + // SDK's typed deserialization can't reproduce. + + { + let cfg = shared.clone(); + iii.register_function( + RegisterFunction::new_async("shell::exec", move |req: Value| { + let cfg = cfg.clone(); + async move { functions::exec::handle(cfg, req).await } + }) + .description("Execute a command and return full output"), + ); + } + + { + let cfg = shared.clone(); + iii.register_function( + RegisterFunction::new_async("shell::exec_bg", move |req: Value| { + let cfg = cfg.clone(); + async move { functions::exec_bg::handle(cfg, req).await } + }) + .description("Spawn a command in background, return job_id"), + ); + } + + iii.register_function( + RegisterFunction::new_async("shell::kill", |req: KillRequest| async move { + functions::kill::handle(req).await + }) + .description("Kill a running background job"), + ); + + iii.register_function( + RegisterFunction::new_async("shell::status", |req: StatusRequest| async move { + functions::status::handle(req).await + }) + .description("Get status of a background job"), + ); + + { + let cfg = shared.clone(); + iii.register_function( + RegisterFunction::new_async("shell::list", move |_req: Value| { + let cfg = cfg.clone(); + async move { functions::list::handle(cfg).await } + }) + .description("List all background jobs"), + ); + } + + let host_fs_cfg = std::sync::Arc::new(crate::fs::host::HostFsConfig { + host_root: shared.fs.host_root.clone(), + max_read_bytes: shared.fs.max_read_bytes, + max_write_bytes: shared.fs.max_write_bytes, + denylist_paths: shared.fs.denylist_paths.clone(), + }); + let chan_maker: std::sync::Arc = + std::sync::Arc::new(crate::fs::host::IiiChannelMaker::new(iii.clone())); + let host_backend: std::sync::Arc = + std::sync::Arc::new(crate::fs::host::HostFsBackend::new(host_fs_cfg, chan_maker)); + let sb_enabled = shared.sandbox.enabled; + + if shared.fs.host_root.is_none() { + tracing::warn!( + "fs.host_root is unset — host backend is unjailed; \ + every absolute path is reachable by shell::fs::* (denylist still applies)", + ); + } + + { + let h = host_backend.clone(); + let i = iii.clone(); + iii.register_function( + RegisterFunction::new_async("shell::fs::ls", move |req: Value| { + let h = h.clone(); + let i = i.clone(); + async move { functions::fs_ls::handle(h, i, sb_enabled, req).await } + }) + .description("List directory contents on host or sandbox"), + ); + } + + { + let h = host_backend.clone(); + let i = iii.clone(); + iii.register_function( + RegisterFunction::new_async("shell::fs::stat", move |req: Value| { + let h = h.clone(); + let i = i.clone(); + async move { functions::fs_stat::handle(h, i, sb_enabled, req).await } + }) + .description("Stat a path on host or sandbox"), + ); + } + + { + let h = host_backend.clone(); + let i = iii.clone(); + iii.register_function( + RegisterFunction::new_async("shell::fs::mkdir", move |req: Value| { + let h = h.clone(); + let i = i.clone(); + async move { functions::fs_mkdir::handle(h, i, sb_enabled, req).await } + }) + .description("Create a directory on host or sandbox"), + ); + } + + { + let h = host_backend.clone(); + let i = iii.clone(); + iii.register_function( + RegisterFunction::new_async("shell::fs::rm", move |req: Value| { + let h = h.clone(); + let i = i.clone(); + async move { functions::fs_rm::handle(h, i, sb_enabled, req).await } + }) + .description("Remove a path on host or sandbox"), + ); + } + + { + let h = host_backend.clone(); + let i = iii.clone(); + iii.register_function( + RegisterFunction::new_async("shell::fs::chmod", move |req: Value| { + let h = h.clone(); + let i = i.clone(); + async move { functions::fs_chmod::handle(h, i, sb_enabled, req).await } + }) + .description("Change permissions on host or sandbox"), + ); + } + + { + let h = host_backend.clone(); + let i = iii.clone(); + iii.register_function( + RegisterFunction::new_async("shell::fs::mv", move |req: Value| { + let h = h.clone(); + let i = i.clone(); + async move { functions::fs_mv::handle(h, i, sb_enabled, req).await } + }) + .description("Move/rename a path on host or sandbox"), + ); + } + + { + let h = host_backend.clone(); + let i = iii.clone(); + iii.register_function( + RegisterFunction::new_async("shell::fs::grep", move |req: Value| { + let h = h.clone(); + let i = i.clone(); + async move { functions::fs_grep::handle(h, i, sb_enabled, req).await } + }) + .description("Recursive regex search on host or sandbox"), + ); + } + + { + let h = host_backend.clone(); + let i = iii.clone(); + iii.register_function( + RegisterFunction::new_async("shell::fs::sed", move |req: Value| { + let h = h.clone(); + let i = i.clone(); + async move { functions::fs_sed::handle(h, i, sb_enabled, req).await } + }) + .description("Find-and-replace on host or sandbox"), + ); + } + + { + let h = host_backend.clone(); + let i = iii.clone(); + iii.register_function( + RegisterFunction::new_async("shell::fs::write", move |req: Value| { + let h = h.clone(); + let i = i.clone(); + async move { functions::fs_write::handle(h, i, sb_enabled, req).await } + }) + .description("Stream a file to a host path or sandbox via StreamChannelRef"), + ); + } + + { + let h = host_backend.clone(); + let i = iii.clone(); + iii.register_function( + RegisterFunction::new_async("shell::fs::read", move |req: Value| { + let h = h.clone(); + let i = i.clone(); + async move { functions::fs_read::handle(h, i, sb_enabled, req).await } + }) + .description("Stream a file from a host path or sandbox via StreamChannelRef"), + ); + } + + tracing::info!("iii-shell registered 15 functions, ready"); + + tokio::signal::ctrl_c().await?; + tracing::info!("iii-shell shutting down"); + iii.shutdown_async().await; + Ok(()) +} diff --git a/shell/src/manifest.rs b/shell/src/manifest.rs new file mode 100644 index 0000000000..2e1309d1c0 --- /dev/null +++ b/shell/src/manifest.rs @@ -0,0 +1,98 @@ +use serde_json::{json, Value}; + +pub fn build_manifest() -> Value { + json!({ + "name": "iii-shell", + "version": env!("CARGO_PKG_VERSION"), + "description": "Unix shell execution worker for iii agents", + "functions": [ + { + "id": "shell::exec", + "description": "Execute a command synchronously and return stdout/stderr (capped at max_output_bytes; truncation flagged per stream)", + }, + { + "id": "shell::exec_bg", + "description": "Spawn a command in the background and return job_id", + }, + { + "id": "shell::kill", + "description": "Kill a running background job", + }, + { + "id": "shell::status", + "description": "Get status of a background job", + }, + { + "id": "shell::list", + "description": "List all background jobs (running + recently completed)", + }, + { + "id": "shell::fs::ls", + "description": "List directory contents on host or sandbox", + }, + { + "id": "shell::fs::stat", + "description": "Stat a path on host or sandbox", + }, + { + "id": "shell::fs::mkdir", + "description": "Create a directory on host or sandbox", + }, + { + "id": "shell::fs::rm", + "description": "Remove a path on host or sandbox", + }, + { + "id": "shell::fs::chmod", + "description": "Change permissions on host or sandbox", + }, + { + "id": "shell::fs::mv", + "description": "Move/rename a path on host or sandbox", + }, + { + "id": "shell::fs::grep", + "description": "Recursive regex search on host or sandbox", + }, + { + "id": "shell::fs::sed", + "description": "Find-and-replace on host or sandbox", + }, + { + "id": "shell::fs::write", + "description": "Stream a file to a host path or sandbox via StreamChannelRef", + }, + { + "id": "shell::fs::read", + "description": "Stream a file from a host path or sandbox via StreamChannelRef", + }, + ], + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_manifest_has_required_fields() { + let m = build_manifest(); + assert!(m.get("name").is_some()); + assert!(m.get("version").is_some()); + assert!(m.get("functions").is_some()); + let fns = m.get("functions").unwrap().as_array().unwrap(); + assert_eq!(fns.len(), 15); + } + + #[test] + fn test_manifest_json_output() { + let m = build_manifest(); + let s = serde_json::to_string(&m).unwrap(); + assert!(s.contains("shell::exec")); + assert!(s.contains("shell::exec_bg")); + assert!(s.contains("shell::kill")); + assert!(s.contains("shell::fs::read")); + assert!(s.contains("shell::fs::write")); + assert!(s.contains("shell::fs::grep")); + } +} diff --git a/shell/tests/e2e/.gitignore b/shell/tests/e2e/.gitignore new file mode 100644 index 0000000000..468b1078d7 --- /dev/null +++ b/shell/tests/e2e/.gitignore @@ -0,0 +1,8 @@ +data/* +!data/.gitkeep +reports/* +!reports/.gitkeep +node_modules/ +dist/ +*.log +.DS_Store diff --git a/shell/tests/e2e/README.md b/shell/tests/e2e/README.md new file mode 100644 index 0000000000..91b3cf7abf --- /dev/null +++ b/shell/tests/e2e/README.md @@ -0,0 +1,110 @@ +# iii-shell worker — end-to-end harness + +Self-asserting smoke harness for the `shell` worker. Validates all 5 iii +functions (`shell::exec`, `shell::exec_bg`, `shell::kill`, `shell::status`, +`shell::list`), every safety guardrail (allowlist, denylist, timeout, output +truncation, env scrubbing), and the background-job lifecycle in one command. + +Modeled on `iii-database/tests/e2e/`. + +## Prerequisites + +- Rust toolchain (`cargo` on `$PATH`) +- Node.js 20+ (`npm` on `$PATH`) +- The iii engine on `$PATH`. Install with: + ```sh + curl -fsSL https://install.iii.dev/iii/main/install.sh | sh + ``` + The script drops the binary at `$HOME/.local/bin/iii` (override with + `BIN_DIR=...` or `PREFIX=...`). + +## Run + +```sh +./run-tests.sh +``` + +Builds the worker (`cargo build --release --bin iii-shell` from the `shell/` crate root), starts the +engine with `config.yaml`, runs ~27 assertions across function happy paths, +safety guardrails, background jobs, and edge cases. Exits 0 on PASS, 1 on +any FAIL. + +## Flags + +| Flag | Effect | +|---|---| +| `--keep` | Leave the engine running after the run for debugging | +| `--no-build` | Skip the cargo build step | + +## Env overrides + +| Var | Default | Purpose | +|---|---|---| +| `WORKER_SRC` | `../..` (the `shell/` crate) | Where to `cargo build` | +| `III_BIN` | `$(command -v iii)` then `$HOME/.local/bin/iii` | Engine binary | +| `WORKER_BIN_TARGET` | `$WORKER_SRC/target/release/iii-shell` | Built worker | +| `WORKER_BIN_LINK` | `$HOME/.iii/workers/shell` | Symlink the engine reads (registered worker name from `iii.worker.yaml`). Set to `$HOME/.iii/workers/iii-shell` if your engine resolves by binary name instead. | +| `HARNESS_TIMEOUT` | `90` | Seconds to wait for the test sentinel | + +## Layout + +| File | Role | +|---|---| +| `run-tests.sh` | Orchestrator | +| `config.yaml` | Engine config (queue, observability, shell with tightened test config) | +| `workers/harness/` | TypeScript smoke-test worker (runs as a host process) | +| `reports/report.json` | Per-case results (latest run) | + +## Filesystem cases + +The harness includes ~52 `shell::fs::*` cases across four files: + +- **`cases-fs-host.ts` (13)** — mkdir, ls, stat, write (channel-based + streaming), read (channel-based streaming), rm, chmod, mv, grep, sed + against host filesystem, plus a default-target check, an 8 MiB + streaming round-trip, an empty-stream zero-byte write, and a cap-off + baseline. +- **`cases-fs-host-jail.ts` (4)** — relative path → `S210`, denylisted + paths (`/etc/passwd`, `/etc/shadow`) → `S215`, unknown `target.kind` + → `S210`. +- **`cases-fs-sandbox.ts` (13)** — sandbox-target forwarding tests. + The harness registers fake `sandbox::fs::*` handlers on its own SDK + instance. The 8 simple ops use script-and-respond mocks; **`write` + drains the caller's channel** and **`read` allocates its own channel + and pumps known bytes**, exercising the full streaming round-trip + end-to-end without a real sandbox VM. +- **`cases-fs-protocol-break.ts` (22)** — adversarial inputs designed + to expose protocol gaps. Coverage includes target-envelope abuse, + path field abuse, mode parsing, grep/sed rejection paths, and + streaming-specific cases (missing/wrong-type `content`, malformed + channel ref). + +## Vulnerability reproductions + +`cases-vuln-repro.ts` and `cases-vuln-repro-jailed.ts` reproduce the +security findings from the `feat/shell-e2e-harness` review. Each case +**passes today** (the vuln is present) and is meant to be inverted or +deleted once the underlying fix lands. Names start with `vuln_repro_` +so they're easy to grep. + +| Finding | Where | Suite | +|---|---|---| +| S-H1 (`chmod -R` follows symlinks) | `cases-vuln-repro.ts` | default | +| S-H2 (`host_root: null` is unjailed) | `cases-vuln-repro.ts` | default | +| S-H3 (denylist regex bypass via shell vars) | `cases-vuln-repro.ts` | default | +| S-H4 (`shell::list` cross-call argv/stdout leak) | `cases-vuln-repro.ts` | default | +| S-C1 (symlink-parent jail escape on writes) | `cases-vuln-repro-jailed.ts` | jailed | + +The default suite (`./run-tests.sh`) runs the unjailed four alongside +the rest. The jailed suite (`./run-tests-jailed.sh`) boots the engine +with `config-jailed.yaml` (`host_root: /private/tmp/iii-shell-jailed-root`) +and runs only the C1 repro — the rest of the suite assumes +`host_root: null` and would mis-fail with a jail set. + +## Troubleshooting + +- **`worker binary missing`**: run without `--no-build` once. +- **`iii engine binary missing`**: install with the script above. +- **Sentinel timeout**: tail `reports/harness-*.log` for the harness output. +- **`WORKER_BIN_LINK` unresolved by engine**: try the binary-name fallback + `WORKER_BIN_LINK=$HOME/.iii/workers/iii-shell ./run-tests.sh`. diff --git a/shell/tests/e2e/config-jailed.yaml b/shell/tests/e2e/config-jailed.yaml new file mode 100644 index 0000000000..0f492aeb43 --- /dev/null +++ b/shell/tests/e2e/config-jailed.yaml @@ -0,0 +1,47 @@ +# Variant of config.yaml used by run-tests-jailed.sh to reproduce +# S-C1 (symlink-parent jail escape) — see +# `cases-vuln-repro-jailed.ts`. Only difference from the default +# config is `fs.host_root` being set to a fixed absolute path; the +# jailed suite creates the directory and a symlink inside it that +# points at /private/tmp/. The vuln is that +# `validate_path` falls back to lexical normalization on ENOENT +# (target doesn't yet exist), which never resolves the symlink, so +# the jail check passes against the lexical path inside the jail +# while the kernel follows the symlink and writes to /private/tmp/. + +workers: + - name: iii-queue + config: + adapter: + name: builtin + + - name: iii-observability + config: + enabled: true + service_name: iii-shell-tests + exporter: memory + logs_console_output: true + sampling_ratio: 1.0 + + - name: shell + config: + max_timeout_ms: 5000 + default_timeout_ms: 1500 + max_output_bytes: 4096 + working_dir: ./data + inherit_env: false + allowed_env: [PATH, HOME, LANG, HARNESS_TEST_VAR] + allowlist: [echo, ls, cat, sleep, pwd, printf, sh, false, "true", env] + denylist_patterns: + - "rm\\s+-rf\\s+/" + max_concurrent_jobs: 2 + job_retention_secs: 2 + fs: + host_root: /private/tmp/iii-shell-jailed-root + max_read_bytes: 1048576 + max_write_bytes: 1048576 + denylist_paths: + - /etc/passwd + - /etc/shadow + sandbox: + enabled: false diff --git a/shell/tests/e2e/config.yaml b/shell/tests/e2e/config.yaml new file mode 100644 index 0000000000..8879da407f --- /dev/null +++ b/shell/tests/e2e/config.yaml @@ -0,0 +1,53 @@ +# iii engine configuration — passed via `iii -c config.yaml`. +# +# The harness TS worker is NOT registered here. It runs as a plain host +# node process launched from run-tests.sh and connects to the engine via +# WebSocket like any external client — sidesteps the libkrun-VM-based +# managed-worker setup, which is overkill for a test harness. + +workers: + - name: iii-queue + config: + adapter: + name: builtin + + - name: iii-observability + config: + enabled: true + service_name: iii-shell-tests + exporter: memory + logs_console_output: true + sampling_ratio: 1.0 + + - name: shell + config: + max_timeout_ms: 5000 + default_timeout_ms: 1500 + max_output_bytes: 4096 + working_dir: ./data + inherit_env: false + allowed_env: [PATH, HOME, LANG, HARNESS_TEST_VAR] + allowlist: [echo, ls, cat, sleep, pwd, printf, sh, false, "true", env] + denylist_patterns: + - "rm\\s+-rf\\s+/" + - ":\\(\\)\\s*\\{\\s*:\\|" + - "harness_denylist_marker" + max_concurrent_jobs: 2 + job_retention_secs: 2 + fs: + host_root: null + # Test harness needs to write to OS tmpdirs scattered across the + # filesystem, so the jail is intentionally off here. Production + # operators should leave this false (default) and pin host_root. + allow_unjailed: true + # Caps deliberately set to 1 MiB so size-cap-boundary tests + # in cases-fs-protocol-break.ts can trip S218 cheaply. Streaming + # itself is still exercised at 512 KiB in cases-fs-host.ts (~8 + # frames at the 64 KiB chunk size). + max_read_bytes: 1048576 + max_write_bytes: 1048576 + denylist_paths: + - /etc/passwd + - /etc/shadow + sandbox: + enabled: true diff --git a/shell/tests/e2e/data/.gitkeep b/shell/tests/e2e/data/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/shell/tests/e2e/reports/.gitkeep b/shell/tests/e2e/reports/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/shell/tests/e2e/run-tests-jailed.sh b/shell/tests/e2e/run-tests-jailed.sh new file mode 100755 index 0000000000..08e5a654b4 --- /dev/null +++ b/shell/tests/e2e/run-tests-jailed.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# Variant of run-tests.sh that boots the engine against +# `config-jailed.yaml` (with `fs.host_root` set) and runs the +# jailed-only vuln-repro suite (S-C1). Reuses the same engine startup +# logic; the differences are: (1) a different config, (2) HARNESS_SUITE=jailed +# so runner.ts picks the matching case set, (3) a separate report path +# so a default run doesn't clobber it. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +WORKER_SRC="${WORKER_SRC:-$(cd "$ROOT_DIR/../.." && pwd)}" +III_BIN="${III_BIN:-$(command -v iii 2>/dev/null || echo "$HOME/.local/bin/iii")}" +WORKER_BIN_TARGET="${WORKER_BIN_TARGET:-$WORKER_SRC/target/release/iii-shell}" +WORKER_BIN_LINK="${WORKER_BIN_LINK:-$HOME/.iii/workers/shell}" +JAIL_ROOT="${JAIL_ROOT:-/private/tmp/iii-shell-jailed-root}" + +REPORT_PATH="$ROOT_DIR/reports/report-jailed.json" +TS=$(date +%Y%m%d-%H%M%S) +ENGINE_LOG="$ROOT_DIR/reports/engine-jailed-$TS.log" +HARNESS_LOG="$ROOT_DIR/reports/harness-jailed-$TS.log" +SENTINEL_TIMEOUT="${HARNESS_TIMEOUT:-90}" + +KEEP=0 +NO_BUILD=0 + +for arg in "$@"; do + case "$arg" in + --keep) KEEP=1 ;; + --no-build) NO_BUILD=1 ;; + -h|--help) + cat <&2; exit 2 ;; + esac +done + +ENGINE_PID="" +HARNESS_PID="" +cleanup() { + local code=$? + if [[ -n "$HARNESS_PID" ]] && kill -0 "$HARNESS_PID" 2>/dev/null; then + kill "$HARNESS_PID" 2>/dev/null || true + wait "$HARNESS_PID" 2>/dev/null || true + fi + if [[ "$KEEP" -eq 0 ]] && [[ -n "$ENGINE_PID" ]] && kill -0 "$ENGINE_PID" 2>/dev/null; then + kill "$ENGINE_PID" 2>/dev/null || true + wait "$ENGINE_PID" 2>/dev/null || true + fi + exit "$code" +} +trap cleanup EXIT INT TERM + +mkdir -p "$ROOT_DIR/reports" "$ROOT_DIR/data" "$(dirname "$WORKER_BIN_LINK")" "$JAIL_ROOT" + +if [[ "$NO_BUILD" -eq 0 ]]; then + echo "[run-tests-jailed] cargo build --release (iii-shell worker)" + (cd "$WORKER_SRC" && cargo build --release --bin iii-shell) +fi +if [[ ! -x "$WORKER_BIN_TARGET" ]]; then + echo "[run-tests-jailed] FATAL: worker binary missing at $WORKER_BIN_TARGET" >&2 + exit 1 +fi + +if [[ ! -L "$WORKER_BIN_LINK" || "$(readlink "$WORKER_BIN_LINK")" != "$WORKER_BIN_TARGET" ]]; then + ln -sfn "$WORKER_BIN_TARGET" "$WORKER_BIN_LINK" +fi + +if [[ ! -x "$III_BIN" ]]; then + echo "[run-tests-jailed] FATAL: iii engine binary missing at $III_BIN" >&2 + exit 1 +fi + +if [[ ! -d "$ROOT_DIR/workers/harness/node_modules" ]]; then + echo "[run-tests-jailed] npm install (harness)" + (cd "$ROOT_DIR/workers/harness" && npm install --silent) +fi + +export HARNESS_TEST_VAR="harness-allowed-value" + +echo "[run-tests-jailed] starting iii engine (jailed config: host_root=$JAIL_ROOT)" +: > "$ENGINE_LOG" +: > "$HARNESS_LOG" + +( cd "$ROOT_DIR" && "$III_BIN" --no-update-check -c ./config-jailed.yaml ) > "$ENGINE_LOG" 2>&1 & +ENGINE_PID=$! +echo "[run-tests-jailed] engine pid=$ENGINE_PID" + +deadline=$(( $(date +%s) + 30 )) +while :; do + if (echo > /dev/tcp/127.0.0.1/49134) 2>/dev/null; then + break + fi + if ! kill -0 "$ENGINE_PID" 2>/dev/null; then + echo "[run-tests-jailed] FATAL: engine exited before binding port; tail of engine log:" >&2 + tail -40 "$ENGINE_LOG" >&2 + exit 1 + fi + if (( $(date +%s) > deadline )); then + echo "[run-tests-jailed] FATAL: engine did not bind port 49134 within 30s; tail of engine log:" >&2 + tail -40 "$ENGINE_LOG" >&2 + exit 1 + fi + sleep 0.5 +done +echo "[run-tests-jailed] engine listening" + +echo "[run-tests-jailed] starting harness (HARNESS_SUITE=jailed)" +( cd "$ROOT_DIR/workers/harness" && \ + env III_URL=ws://127.0.0.1:49134 \ + HARNESS_REPORT_PATH="$REPORT_PATH" \ + HARNESS_SUITE=jailed \ + HARNESS_TEST_VAR="$HARNESS_TEST_VAR" \ + npm run --silent dev ) > "$HARNESS_LOG" 2>&1 & +HARNESS_PID=$! + +sentinel="" +deadline=$(( $(date +%s) + SENTINEL_TIMEOUT )) +while (( $(date +%s) < deadline )); do + if ! kill -0 "$HARNESS_PID" 2>/dev/null; then + if grep -m1 -E '^HARNESS_DONE: (PASS|FAIL) [0-9]+/[0-9]+$' "$HARNESS_LOG" >/dev/null 2>&1; then + sentinel=$(grep -m1 -E '^HARNESS_DONE: (PASS|FAIL) [0-9]+/[0-9]+$' "$HARNESS_LOG") + break + fi + echo "[run-tests-jailed] harness exited without sentinel; tail:" >&2 + tail -40 "$HARNESS_LOG" >&2 + exit 1 + fi + if grep -m1 -E '^HARNESS_DONE: (PASS|FAIL) [0-9]+/[0-9]+$' "$HARNESS_LOG" >/dev/null 2>&1; then + sentinel=$(grep -m1 -E '^HARNESS_DONE: (PASS|FAIL) [0-9]+/[0-9]+$' "$HARNESS_LOG") + break + fi + sleep 1 +done + +if [[ -z "$sentinel" ]]; then + echo "[run-tests-jailed] FATAL: harness did not emit sentinel within ${SENTINEL_TIMEOUT}s" >&2 + tail -40 "$HARNESS_LOG" >&2 + exit 1 +fi + +echo +echo "=======================================================================" +echo "$sentinel" +if [[ -f "$REPORT_PATH" ]]; then + python3 - "$REPORT_PATH" <<'PY' 2>/dev/null || cat "$REPORT_PATH" +import json, sys +data = json.load(open(sys.argv[1])) +for r in data["results"]: + tag = "PASS" if r["status"] == "PASS" else "FAIL" + err = (" — " + r.get("error","")) if r["status"] == "FAIL" else "" + print(f" [{tag}] {r['case']}{err}") +PY +fi +echo "=======================================================================" + +case "$sentinel" in + *PASS*) exit 0 ;; + *) exit 1 ;; +esac diff --git a/shell/tests/e2e/run-tests.sh b/shell/tests/e2e/run-tests.sh new file mode 100755 index 0000000000..70da709447 --- /dev/null +++ b/shell/tests/e2e/run-tests.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Path overrides (defaults assume the harness lives at shell/tests/e2e/ inside +# the workers repo and the iii engine is on $PATH or at $HOME/.local/bin/iii — +# which is where the install script +# `curl -fsSL https://install.iii.dev/iii/main/install.sh | sh` puts it). +WORKER_SRC="${WORKER_SRC:-$(cd "$ROOT_DIR/../.." && pwd)}" +III_BIN="${III_BIN:-$(command -v iii 2>/dev/null || echo "$HOME/.local/bin/iii")}" +WORKER_BIN_TARGET="${WORKER_BIN_TARGET:-$WORKER_SRC/target/release/iii-shell}" +# The engine resolves binaries by registered worker name (`shell` per +# iii.worker.yaml), not by cargo bin name. If the engine actually looks up +# by binary name, override with WORKER_BIN_LINK=$HOME/.iii/workers/iii-shell. +WORKER_BIN_LINK="${WORKER_BIN_LINK:-$HOME/.iii/workers/shell}" + +REPORT_PATH="$ROOT_DIR/reports/report.json" +TS=$(date +%Y%m%d-%H%M%S) +ENGINE_LOG="$ROOT_DIR/reports/engine-$TS.log" +HARNESS_LOG="$ROOT_DIR/reports/harness-$TS.log" +SENTINEL_TIMEOUT="${HARNESS_TIMEOUT:-90}" + +KEEP=0 +NO_BUILD=0 + +for arg in "$@"; do + case "$arg" in + --keep) KEEP=1 ;; + --no-build) NO_BUILD=1 ;; + -h|--help) + cat <&2; exit 2 ;; + esac +done + +ENGINE_PID="" +HARNESS_PID="" +cleanup() { + local code=$? + if [[ -n "$HARNESS_PID" ]] && kill -0 "$HARNESS_PID" 2>/dev/null; then + kill "$HARNESS_PID" 2>/dev/null || true + wait "$HARNESS_PID" 2>/dev/null || true + fi + if [[ "$KEEP" -eq 0 ]] && [[ -n "$ENGINE_PID" ]] && kill -0 "$ENGINE_PID" 2>/dev/null; then + kill "$ENGINE_PID" 2>/dev/null || true + wait "$ENGINE_PID" 2>/dev/null || true + fi + exit "$code" +} +trap cleanup EXIT INT TERM + +mkdir -p "$ROOT_DIR/reports" "$ROOT_DIR/data" "$(dirname "$WORKER_BIN_LINK")" + +# 1. Build the worker (unless --no-build) +if [[ "$NO_BUILD" -eq 0 ]]; then + echo "[run-tests] cargo build --release (iii-shell worker)" + (cd "$WORKER_SRC" && cargo build --release --bin iii-shell) +fi +if [[ ! -x "$WORKER_BIN_TARGET" ]]; then + echo "[run-tests] FATAL: worker binary missing at $WORKER_BIN_TARGET — run without --no-build" >&2 + exit 1 +fi + +# 2. Symlink at $WORKER_BIN_LINK +if [[ ! -L "$WORKER_BIN_LINK" || "$(readlink "$WORKER_BIN_LINK")" != "$WORKER_BIN_TARGET" ]]; then + ln -sfn "$WORKER_BIN_TARGET" "$WORKER_BIN_LINK" + echo "[run-tests] symlink: $WORKER_BIN_LINK -> $WORKER_BIN_TARGET" +fi + +# 3. Verify engine binary +if [[ ! -x "$III_BIN" ]]; then + echo "[run-tests] FATAL: iii engine binary missing at $III_BIN" >&2 + echo "[run-tests] install with: curl -fsSL https://install.iii.dev/iii/main/install.sh | sh" >&2 + exit 1 +fi + +# 4. Install harness deps if needed +if [[ ! -d "$ROOT_DIR/workers/harness/node_modules" ]]; then + echo "[run-tests] npm install (harness)" + (cd "$ROOT_DIR/workers/harness" && npm install --silent) +fi + +# 5. Set env vars used by the env-scrubbing/passthrough cases. HARNESS_TEST_VAR +# is in allowed_env — should round-trip. HARNESS_NOT_ALLOWED is not — should be +# scrubbed before reaching the spawned `env` command. +export HARNESS_TEST_VAR="harness-allowed-value" +export HARNESS_NOT_ALLOWED="harness-blocked-value" + +# 6. Start the engine +echo "[run-tests] starting iii engine" +: > "$ENGINE_LOG" +: > "$HARNESS_LOG" + +( cd "$ROOT_DIR" && "$III_BIN" --no-update-check -c ./config.yaml ) > "$ENGINE_LOG" 2>&1 & +ENGINE_PID=$! +echo "[run-tests] engine pid=$ENGINE_PID" + +# 7. Wait for the engine to accept TCP on its WebSocket port (49134). +deadline=$(( $(date +%s) + 30 )) +while :; do + if (echo > /dev/tcp/127.0.0.1/49134) 2>/dev/null; then + break + fi + if ! kill -0 "$ENGINE_PID" 2>/dev/null; then + echo "[run-tests] FATAL: engine exited before binding port; tail of engine log:" >&2 + tail -40 "$ENGINE_LOG" >&2 + exit 1 + fi + if (( $(date +%s) > deadline )); then + echo "[run-tests] FATAL: engine did not bind port 49134 within 30s; tail of engine log:" >&2 + tail -40 "$ENGINE_LOG" >&2 + exit 1 + fi + sleep 0.5 +done +echo "[run-tests] engine listening" + +# 8. Launch the harness as a host node process +echo "[run-tests] starting harness" +( cd "$ROOT_DIR/workers/harness" && \ + env III_URL=ws://127.0.0.1:49134 \ + HARNESS_REPORT_PATH="$REPORT_PATH" \ + HARNESS_TEST_VAR="$HARNESS_TEST_VAR" \ + HARNESS_NOT_ALLOWED="$HARNESS_NOT_ALLOWED" \ + npm run --silent dev ) > "$HARNESS_LOG" 2>&1 & +HARNESS_PID=$! +echo "[run-tests] harness pid=$HARNESS_PID" + +# 9. Wait for sentinel line +sentinel="" +deadline=$(( $(date +%s) + SENTINEL_TIMEOUT )) +while (( $(date +%s) < deadline )); do + if ! kill -0 "$HARNESS_PID" 2>/dev/null; then + if grep -m1 -E '^HARNESS_DONE: (PASS|FAIL) [0-9]+/[0-9]+$' "$HARNESS_LOG" >/dev/null 2>&1; then + sentinel=$(grep -m1 -E '^HARNESS_DONE: (PASS|FAIL) [0-9]+/[0-9]+$' "$HARNESS_LOG") + break + fi + echo "[run-tests] harness exited without sentinel; tail of harness log:" >&2 + tail -40 "$HARNESS_LOG" >&2 + exit 1 + fi + if grep -m1 -E '^HARNESS_DONE: (PASS|FAIL) [0-9]+/[0-9]+$' "$HARNESS_LOG" >/dev/null 2>&1; then + sentinel=$(grep -m1 -E '^HARNESS_DONE: (PASS|FAIL) [0-9]+/[0-9]+$' "$HARNESS_LOG") + break + fi + sleep 1 +done + +if [[ -z "$sentinel" ]]; then + echo "[run-tests] FATAL: harness did not emit sentinel within ${SENTINEL_TIMEOUT}s" >&2 + echo "[run-tests] tail of harness log:" >&2 + tail -40 "$HARNESS_LOG" >&2 + exit 1 +fi + +echo +echo "=======================================================================" +echo "$sentinel" +if [[ -f "$REPORT_PATH" ]]; then + python3 - "$REPORT_PATH" <<'PY' 2>/dev/null || cat "$REPORT_PATH" +import json, sys +data = json.load(open(sys.argv[1])) +for r in data["results"]: + tag = "PASS" if r["status"] == "PASS" else "FAIL" + err = (" — " + r.get("error","")) if r["status"] == "FAIL" else "" + print(f" [{tag}] {r['case']}{err}") +PY +fi +echo "=======================================================================" + +case "$sentinel" in + *PASS*) exit 0 ;; + *) exit 1 ;; +esac diff --git a/shell/tests/e2e/workers/harness/iii.worker.yaml b/shell/tests/e2e/workers/harness/iii.worker.yaml new file mode 100644 index 0000000000..0bd526905c --- /dev/null +++ b/shell/tests/e2e/workers/harness/iii.worker.yaml @@ -0,0 +1,8 @@ +name: harness +runtime: + language: typescript + package_manager: npm + entry: src/worker.ts +scripts: + install: 'npm install' + start: 'npm run dev' diff --git a/shell/tests/e2e/workers/harness/package-lock.json b/shell/tests/e2e/workers/harness/package-lock.json new file mode 100644 index 0000000000..6a99faa545 --- /dev/null +++ b/shell/tests/e2e/workers/harness/package-lock.json @@ -0,0 +1,1160 @@ +{ + "name": "iii-shell-tests-harness", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "iii-shell-tests-harness", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "iii-sdk": "0.11.2" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tsx": "^4.0.0", + "typescript": "^5.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", + "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.30.1.tgz", + "integrity": "sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", + "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.57.2", + "@types/shimmer": "^1.2.0", + "import-in-the-middle": "^1.8.1", + "require-in-the-middle": "^7.1.1", + "semver": "^7.5.2", + "shimmer": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.57.2.tgz", + "integrity": "sha512-48IIRj49gbQVK52jYsw70+Jv+JbahT8BqT2Th7C4H7RCM9d0gZ5sgNPoMpWldmfjvIsSgiGJtjfk9MeZvjhoig==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.57.2", + "@opentelemetry/core": "1.30.1", + "@opentelemetry/resources": "1.30.1", + "@opentelemetry/sdk-logs": "0.57.2", + "@opentelemetry/sdk-metrics": "1.30.1", + "@opentelemetry/sdk-trace-base": "1.30.1", + "protobufjs": "^7.3.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/propagator-b3": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-b3/-/propagator-b3-1.30.1.tgz", + "integrity": "sha512-oATwWWDIJzybAZ4pO76ATN5N6FFbOA1otibAVlS8v90B4S1wClnhRUk7K+2CHAwN1JKYuj4jh/lpCEG5BAqFuQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.30.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/propagator-jaeger": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-jaeger/-/propagator-jaeger-1.30.1.tgz", + "integrity": "sha512-Pj/BfnYEKIOImirH76M4hDaBSx6HyZ2CXUqk+Kj02m6BB80c/yo4BdWkn/1gDFfU+YPY+bPR2U0DKBfdxCKwmg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.30.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.30.1.tgz", + "integrity": "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.30.1", + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.57.2.tgz", + "integrity": "sha512-TXFHJ5c+BKggWbdEQ/inpgIzEmS2BGQowLE9UhsMd7YYlUfBQJ4uax0VF/B5NYigdM/75OoJGhAV3upEhK+3gg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.57.2", + "@opentelemetry/core": "1.30.1", + "@opentelemetry/resources": "1.30.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.30.1.tgz", + "integrity": "sha512-q9zcZ0Okl8jRgmy7eNW3Ku1XSgg3sDLa5evHZpCwjspw7E8Is4K/haRPDJrBcX3YSn/Y7gUvFnByNYEKQNbNog==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.30.1", + "@opentelemetry/resources": "1.30.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.30.1.tgz", + "integrity": "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.30.1", + "@opentelemetry/resources": "1.30.1", + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sdk-trace-node": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-1.30.1.tgz", + "integrity": "sha512-cBjYOINt1JxXdpw1e5MlHmFRc5fgj4GW/86vsKFxJCJ8AL4PdVtYH41gWwl4qd4uQjqEL1oJVrXkSy5cnduAnQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-async-hooks": "1.30.1", + "@opentelemetry/core": "1.30.1", + "@opentelemetry/propagator-b3": "1.30.1", + "@opentelemetry/propagator-jaeger": "1.30.1", + "@opentelemetry/sdk-trace-base": "1.30.1", + "semver": "^7.5.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", + "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", + "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/shimmer": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@types/shimmer/-/shimmer-1.2.0.tgz", + "integrity": "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/iii-sdk": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/iii-sdk/-/iii-sdk-0.11.2.tgz", + "integrity": "sha512-S8/o53j1z+IOU6Mp1f3GbivJ59hEgWhtT6hNutVpfwhJK5Q9zS2rV2LUX1Ko6+xF/Zr3Y6xodNRmBRng0qiZZA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api-logs": "^0.57.0", + "@opentelemetry/core": "^1.30.0", + "@opentelemetry/instrumentation": "^0.57.0", + "@opentelemetry/otlp-transformer": "^0.57.0", + "@opentelemetry/resources": "^1.30.0", + "@opentelemetry/sdk-logs": "^0.57.0", + "@opentelemetry/sdk-metrics": "^1.30.0", + "@opentelemetry/sdk-trace-base": "^1.30.0", + "@opentelemetry/sdk-trace-node": "^1.30.0", + "@opentelemetry/semantic-conventions": "^1.28.0", + "ws": "^8.18.3" + } + }, + "node_modules/import-in-the-middle": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.15.0.tgz", + "integrity": "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==", + "license": "Apache-2.0", + "dependencies": { + "acorn": "^8.14.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^1.2.2", + "module-details-from-path": "^1.0.3" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/protobufjs": { + "version": "7.5.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz", + "integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.1", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/require-in-the-middle": { + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz", + "integrity": "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3", + "resolve": "^1.22.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shimmer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", + "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==", + "license": "BSD-2-Clause" + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/shell/tests/e2e/workers/harness/package.json b/shell/tests/e2e/workers/harness/package.json new file mode 100644 index 0000000000..363c5afefc --- /dev/null +++ b/shell/tests/e2e/workers/harness/package.json @@ -0,0 +1,20 @@ +{ + "name": "iii-shell-tests-harness", + "version": "0.1.0", + "type": "module", + "private": true, + "description": "Self-asserting smoke harness for the iii-shell worker.", + "scripts": { + "dev": "tsx src/worker.ts", + "build": "tsc" + }, + "dependencies": { + "iii-sdk": "0.11.2" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tsx": "^4.0.0", + "typescript": "^5.4.0" + }, + "license": "Apache-2.0" +} diff --git a/shell/tests/e2e/workers/harness/src/cases-concurrency.ts b/shell/tests/e2e/workers/harness/src/cases-concurrency.ts new file mode 100644 index 0000000000..c189558167 --- /dev/null +++ b/shell/tests/e2e/workers/harness/src/cases-concurrency.ts @@ -0,0 +1,75 @@ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fsWriteStream, fsReadStream } from './cases-fs-host.ts'; +import { expect, expectEqual, type CaseContext, type TestCase } from './cases.ts'; + +function newWorkdir(prefix: string): string { + return mkdtempSync(join(tmpdir(), `iii-shell-conc-${prefix}-`)); +} + +export const CONCURRENCY_CASES: TestCase[] = [ + { + name: 'concurrency_two_writes_same_path_one_wins', + async run(ctx: CaseContext) { + const root = newWorkdir('two-write'); + const f = join(root, 'race.bin'); + const a = Buffer.from('A'.repeat(2048)); + const b = Buffer.from('B'.repeat(2048)); + const [ra, rb] = await Promise.allSettled([ + fsWriteStream(ctx, { path: f, bytes: a }), + fsWriteStream(ctx, { path: f, bytes: b }), + ]); + expect( + ra.status === 'fulfilled' && rb.status === 'fulfilled', + `both writes must complete (a=${ra.status}, b=${rb.status})`, + ); + const final = await fsReadStream(ctx, { path: f }); + expect( + final.data.equals(a) || final.data.equals(b), + 'final content must equal exactly one of the two payloads', + ); + }, + }, + { + name: 'concurrency_list_during_job_churn', + async run({ call, sleep }) { + const a = await call('shell::exec_bg', { command: 'sleep', args: ['1'] }); + const b = await call('shell::exec_bg', { command: 'sleep', args: ['1'] }); + const lists = await Promise.allSettled( + Array.from({ length: 10 }, () => call('shell::list', {})), + ); + const fulfilled = lists.filter((s) => s.status === 'fulfilled') as PromiseFulfilledResult<{ + jobs?: Array<{ id: string }>; + records?: Array<{ id: string }>; + }>[]; + expectEqual(fulfilled.length, 10, 'all 10 list calls succeed'); + for (const f of fulfilled) { + const jobs = f.value.jobs ?? f.value.records ?? []; + expect( + Array.isArray(jobs) && jobs.length <= 2, + `each list returns 0..2 jobs (got ${jobs.length})`, + ); + } + await call('shell::kill', { job_id: a.job_id }).catch(() => {}); + await call('shell::kill', { job_id: b.job_id }).catch(() => {}); + await sleep(300); + }, + }, + { + name: 'concurrency_concurrent_reads_same_file', + async run(ctx: CaseContext) { + const root = newWorkdir('many-read'); + const f = join(root, 'shared.bin'); + const payload = Buffer.alloc(64 * 1024); + for (let i = 0; i < payload.length; i++) payload[i] = (i * 7) & 0xff; + await fsWriteStream(ctx, { path: f, bytes: payload }); + const reads = await Promise.all( + Array.from({ length: 5 }, () => fsReadStream(ctx, { path: f })), + ); + for (const r of reads) { + expect(r.data.equals(payload), 'every concurrent read returns identical bytes'); + } + }, + }, +]; diff --git a/shell/tests/e2e/workers/harness/src/cases-edge.ts b/shell/tests/e2e/workers/harness/src/cases-edge.ts new file mode 100644 index 0000000000..d3a306216b --- /dev/null +++ b/shell/tests/e2e/workers/harness/src/cases-edge.ts @@ -0,0 +1,49 @@ +import type { TestCase } from './cases.ts'; +import { expectEqual } from './cases.ts'; + +export const EDGE_CASES: TestCase[] = [ + { + name: 'missing command field rejects', + async run({ call, expectError }) { + await expectError( + () => call('shell::exec', { args: ['hi'] }), + "missing 'command'", + ); + }, + }, + { + name: 'non-string args entry rejects', + async run({ call, expectError }) { + await expectError( + () => call('shell::exec', { command: 'echo', args: ['--n', 5] as unknown as string[] }), + 'must be a string', + ); + }, + }, + { + name: 'bad shell-words quoting rejects', + async run({ call, expectError }) { + await expectError( + () => call('shell::exec', { command: 'echo "unterminated' }), + 'parse command', + ); + }, + }, + { + name: 'nonexistent unlisted command is rejected by allowlist', + async run({ call, expectError }) { + await expectError( + () => call('shell::exec', { command: '/_no_such_bin_at_all_' }), + 'not in allowlist', + ); + }, + }, + { + name: 'empty args array runs the bare program', + async run({ call }) { + const r = await call('shell::exec', { command: 'echo', args: [] }); + expectEqual(r.exit_code, 0, 'exit_code'); + expectEqual(r.stdout, '\n', 'bare echo prints newline'); + }, + }, +]; diff --git a/shell/tests/e2e/workers/harness/src/cases-exec-break.ts b/shell/tests/e2e/workers/harness/src/cases-exec-break.ts new file mode 100644 index 0000000000..b83356bacd --- /dev/null +++ b/shell/tests/e2e/workers/harness/src/cases-exec-break.ts @@ -0,0 +1,189 @@ +import { expect, expectEqual, type CaseContext, type TestCase } from './cases.ts'; + +export const EXEC_BREAK_CASES: TestCase[] = [ + { + name: 'exec_empty_command_string_rejected', + async run({ call, expectError }) { + await expectError( + () => call('shell::exec', { command: '' }), + 'empty command', + ); + }, + }, + { + name: 'exec_command_wrong_type_reports_as_missing', + async run({ call, expectError }) { + await expectError( + () => call('shell::exec', { command: 42 }), + "missing 'command'", + ); + }, + }, + { + name: 'exec_command_boolean_reports_as_missing', + async run({ call, expectError }) { + await expectError( + () => call('shell::exec', { command: true }), + "missing 'command'", + ); + }, + }, + { + name: 'exec_args_field_not_array_rejected', + async run({ call, expectError }) { + await expectError( + () => call('shell::exec', { command: 'echo', args: 'not-an-array' as unknown as string[] }), + 'must be an array of strings', + ); + }, + }, + { + name: 'exec_args_null_entry_rejected', + async run({ call, expectError }) { + await expectError( + () => call('shell::exec', { command: 'echo', args: [null] as unknown as string[] }), + 'must be a string', + ); + }, + }, + { + name: 'exec_args_object_entry_rejected', + async run({ call, expectError }) { + await expectError( + () => call('shell::exec', { command: 'echo', args: [{}] as unknown as string[] }), + 'must be a string', + ); + }, + }, + { + name: 'exec_args_bool_entry_rejected', + async run({ call, expectError }) { + await expectError( + () => call('shell::exec', { command: 'echo', args: [true] as unknown as string[] }), + 'must be a string', + ); + }, + }, + { + name: 'exec_command_with_embedded_nul_rejected', + async run({ call }) { + let threw = false; + try { + await call('shell::exec', { command: 'echo\0evil' }); + } catch { + threw = true; + } + expect(threw, 'embedded NUL in command must reject'); + }, + }, + { + name: 'exec_arg_with_embedded_nul_no_crash', + async run({ call }) { + let threw = false; + let exitOk = false; + try { + const r = await call('shell::exec', { + command: 'echo', + args: ['safe', 'has\0nul'], + }); + exitOk = typeof r.exit_code === 'number'; + } catch { + threw = true; + } + expect(threw || exitOk, 'either rejects cleanly or returns a result'); + }, + }, + { + name: 'exec_timeout_ms_zero_triggers_immediate_timeout', + async run({ call }) { + const r = await call('shell::exec', { + command: 'sleep', + args: ['1'], + timeout_ms: 0, + }); + expectEqual(r.timed_out, true, 'timeout_ms=0 should produce timed_out=true'); + expectEqual(r.exit_code, null, 'exit_code is null on timeout'); + }, + }, + { + name: 'exec_timeout_ms_negative_silently_uses_default', + async run({ call }) { + const r = await call('shell::exec', { + command: 'echo', + args: ['ok'], + timeout_ms: -1 as unknown as number, + }); + expectEqual(r.exit_code, 0, 'echo still runs (timeout_ms=-1 ignored)'); + expectEqual(r.timed_out, false, 'fallback to default_timeout_ms'); + }, + }, + { + name: 'exec_timeout_ms_float_silently_uses_default', + async run({ call }) { + const r = await call('shell::exec', { + command: 'echo', + args: ['ok'], + timeout_ms: 1.5 as unknown as number, + }); + expectEqual(r.exit_code, 0, 'echo still runs (timeout_ms=1.5 ignored)'); + expectEqual(r.timed_out, false, 'fallback to default_timeout_ms'); + }, + }, + { + name: 'exec_timeout_ms_max_safe_int_clamps', + async run({ call }) { + const r = await call('shell::exec', { + command: 'echo', + args: ['ok'], + timeout_ms: Number.MAX_SAFE_INTEGER, + }); + expectEqual(r.exit_code, 0, 'completes well before clamped cap'); + expect(r.duration_ms < 5000, `duration_ms < 5000 (got ${r.duration_ms})`); + expectEqual(r.timed_out, false, 'not timed out'); + }, + }, + { + name: 'exec_argv_flood_1000_args_no_crash', + async run(ctx: CaseContext) { + const args = Array.from({ length: 1000 }, () => 'a'); + let threw = false; + let exit: number | null = null; + try { + const r = await ctx.call('shell::exec', { command: 'echo', args }); + exit = r.exit_code; + } catch { + threw = true; + } + expect(threw || exit === 0, 'either rejected cleanly or echoed all args'); + const r2 = await ctx.call('shell::exec', { command: 'echo', args: ['after'] }); + expectEqual(r2.exit_code, 0, 'worker survives argv flood'); + }, + }, + { + name: 'exec_unlisted_command_with_extra_fields_still_rejected', + async run({ call, expectError }) { + await expectError( + () => call('shell::exec', { + command: 'nmap', + args: ['-v'], + unknown_field: 'ignored', + another_extra: 42, + }), + 'allowlist', + ); + }, + }, + { + name: 'exec_extra_unknown_fields_on_happy_path_tolerated', + async run({ call }) { + const r = await call('shell::exec', { + command: 'echo', + args: ['hi'], + unknown_field: 'should be ignored', + nested: { also: 'ignored' }, + }); + expectEqual(r.exit_code, 0, 'unknown fields tolerated'); + expectEqual(r.stdout, 'hi\n', 'stdout intact'); + }, + }, +]; diff --git a/shell/tests/e2e/workers/harness/src/cases-fs-encoding.ts b/shell/tests/e2e/workers/harness/src/cases-fs-encoding.ts new file mode 100644 index 0000000000..cc924ff88b --- /dev/null +++ b/shell/tests/e2e/workers/harness/src/cases-fs-encoding.ts @@ -0,0 +1,115 @@ +import { mkdtempSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fsWriteStream, fsReadStream } from './cases-fs-host.ts'; +import { expect, expectEqual, type CaseContext, type TestCase } from './cases.ts'; + +function newWorkdir(prefix: string): string { + return mkdtempSync(join(tmpdir(), `iii-shell-fs-enc-${prefix}-`)); +} + +export const FS_ENCODING_CASES: TestCase[] = [ + { + name: 'fs_write_read_utf8_multiscript_round_trip', + async run(ctx: CaseContext) { + const root = newWorkdir('utf8'); + const f = join(root, 'utf8.txt'); + const payload = Buffer.from( + 'hello 🌍 مرحبا नमस्ते 你好 ñöç combining: é zwj: 👨‍👩‍👧\n', + 'utf8', + ); + const w = await fsWriteStream(ctx, { path: f, bytes: payload }); + expectEqual(w.bytes_written, payload.length, 'bytes_written matches utf8 length'); + const r = await fsReadStream(ctx, { path: f }); + expect(r.data.equals(payload), 'utf8 round-trip exact'); + }, + }, + { + name: 'fs_write_read_binary_with_nul_bytes_round_trip', + async run(ctx: CaseContext) { + const root = newWorkdir('bin'); + const f = join(root, 'bin.dat'); + const payload = Buffer.from([ + 0x00, 0x01, 0xff, 0x00, 0x7f, 0x80, 0x0a, 0x00, 0x0d, 0xfe, 0x00, + ]); + const w = await fsWriteStream(ctx, { path: f, bytes: payload }); + expectEqual(w.bytes_written, payload.length, 'bytes_written matches binary length'); + const r = await fsReadStream(ctx, { path: f }); + expect(r.data.equals(payload), 'binary with NUL bytes round-trips exactly'); + }, + }, + { + name: 'fs_path_with_unicode_filename_round_trip', + async run(ctx: CaseContext) { + const root = newWorkdir('uname'); + const f = join(root, 'файл-🦀-名前.txt'); + const payload = Buffer.from('payload', 'utf8'); + const w = await fsWriteStream(ctx, { path: f, bytes: payload }); + expectEqual(w.bytes_written, payload.length, 'unicode filename writes'); + const s = await ctx.call('shell::fs::stat', { path: f }); + expectEqual(s.size, payload.length, 'stat sees the unicode-named file'); + const r = await fsReadStream(ctx, { path: f }); + expect(r.data.equals(payload), 'unicode filename round-trips'); + }, + }, + { + name: 'fs_path_with_consecutive_slashes_resolves', + async run(ctx: CaseContext) { + const root = newWorkdir('slashes'); + const sub = join(root, 'sub'); + mkdirSync(sub); + const messy = root + '//sub///'; + let succeeded = false; + let threw = false; + try { + const r = await ctx.call('shell::fs::ls', { path: messy }); + expect(Array.isArray(r.entries), 'ls returned entries array'); + succeeded = true; + } catch { + threw = true; + } + expect(succeeded || threw, 'must succeed or reject cleanly'); + }, + }, + { + name: 'fs_path_pathologically_long_no_crash', + async run(ctx: CaseContext) { + const longPath = '/tmp/' + 'x'.repeat(5000); + let threw = false; + try { + await ctx.call('shell::fs::stat', { path: longPath }); + } catch { + threw = true; + } + expect(threw, '5000-char path must be rejected'); + const r = await ctx.call('shell::exec', { command: 'echo', args: ['after-long'] }); + expectEqual(r.exit_code, 0, 'worker survives long-path probe'); + }, + }, + { + name: 'fs_grep_utf8_max_line_bytes_floors_to_char_boundary', + async run(ctx: CaseContext) { + const root = newWorkdir('grep-utf8'); + const f = join(root, 'multibyte.txt'); + const line = '世'.repeat(30) + '\n'; + await fsWriteStream(ctx, { path: f, bytes: line }); + const r = await ctx.call('shell::fs::grep', { + path: root, + pattern: '世', + recursive: true, + ignore_case: false, + include_glob: [], + exclude_glob: [], + max_matches: 10, + max_line_bytes: 50, + }); + expect(Array.isArray(r.matches) && r.matches.length >= 1, 'grep found at least 1 match'); + const content = r.matches[0].content as string; + const buf = Buffer.from(content, 'utf8'); + expect( + buf.toString('utf8') === content, + `truncated content must remain valid UTF-8 (got ${buf.length} bytes)`, + ); + }, + }, +]; diff --git a/shell/tests/e2e/workers/harness/src/cases-fs-errors.ts b/shell/tests/e2e/workers/harness/src/cases-fs-errors.ts new file mode 100644 index 0000000000..121cf84e5f --- /dev/null +++ b/shell/tests/e2e/workers/harness/src/cases-fs-errors.ts @@ -0,0 +1,104 @@ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fsWriteStream } from './cases-fs-host.ts'; +import { expect, expectEqual, type CaseContext, type TestCase } from './cases.ts'; + +function newWorkdir(prefix: string): string { + return mkdtempSync(join(tmpdir(), `iii-shell-fs-err-${prefix}-`)); +} + +export const FS_ERROR_CASES: TestCase[] = [ + { + name: 'fs_rm_nonexistent_returns_s211', + async run({ call, expectError }) { + const root = newWorkdir('rm-404'); + await expectError( + () => call('shell::fs::rm', { + path: join(root, 'never-existed'), + recursive: false, + }), + 'S211', + ); + }, + }, + { + name: 'fs_rm_non_empty_dir_without_recursive_returns_s214', + async run(ctx: CaseContext) { + const root = newWorkdir('rm-full'); + const dir = join(root, 'has-files'); + await ctx.call('shell::fs::mkdir', { path: dir, mode: '0755' }); + await fsWriteStream(ctx, { path: join(dir, 'kid.txt'), bytes: 'hi' }); + await ctx.expectError( + () => ctx.call('shell::fs::rm', { path: dir, recursive: false }), + 'S214', + ); + await ctx.call('shell::fs::rm', { path: dir, recursive: true }); + }, + }, + { + name: 'fs_mkdir_existing_without_parents_returns_s213', + async run({ call, expectError }) { + const root = newWorkdir('mkdir-dup'); + const dir = join(root, 'd'); + await call('shell::fs::mkdir', { path: dir, mode: '0755' }); + await expectError( + () => call('shell::fs::mkdir', { path: dir, mode: '0755', parents: false }), + 'S213', + ); + }, + }, + { + name: 'fs_mkdir_existing_with_parents_is_idempotent', + async run({ call }) { + const root = newWorkdir('mkdir-idem'); + const dir = join(root, 'd'); + const first = await call('shell::fs::mkdir', { path: dir, mode: '0755', parents: true }); + expectEqual(first.created, true, 'first mkdir creates the dir'); + const second = await call('shell::fs::mkdir', { path: dir, mode: '0755', parents: true }); + expect( + second.created === false || second.created === true, + `second mkdir.created is a bool (got ${JSON.stringify(second.created)})`, + ); + }, + }, + { + name: 'fs_mv_overwrite_false_collision_returns_s213', + async run(ctx: CaseContext) { + const root = newWorkdir('mv-coll'); + const src = join(root, 'src.txt'); + const dst = join(root, 'dst.txt'); + await fsWriteStream(ctx, { path: src, bytes: 'src-content' }); + await fsWriteStream(ctx, { path: dst, bytes: 'dst-content' }); + await ctx.expectError( + () => ctx.call('shell::fs::mv', { src, dst, overwrite: false }), + 'S213', + ); + }, + }, + { + name: 'fs_chmod_nonexistent_returns_s211', + async run({ call, expectError }) { + const root = newWorkdir('chmod-404'); + await expectError( + () => call('shell::fs::chmod', { + path: join(root, 'missing'), + mode: '0644', + recursive: false, + }), + 'S211', + ); + }, + }, + { + name: 'fs_stat_on_directory_reports_is_dir_true', + async run({ call }) { + const root = newWorkdir('stat-dir'); + const dir = join(root, 'subdir'); + await call('shell::fs::mkdir', { path: dir, mode: '0755' }); + const s = await call('shell::fs::stat', { path: dir }); + expectEqual(s.is_dir, true, 'is_dir true for a directory'); + expectEqual(s.is_symlink, false, 'is_symlink false for a real dir'); + }, + }, +]; diff --git a/shell/tests/e2e/workers/harness/src/cases-fs-host-jail.ts b/shell/tests/e2e/workers/harness/src/cases-fs-host-jail.ts new file mode 100644 index 0000000000..9dae7a7edb --- /dev/null +++ b/shell/tests/e2e/workers/harness/src/cases-fs-host-jail.ts @@ -0,0 +1,44 @@ +import { type CaseContext, type TestCase } from './cases.ts'; + +export const FS_HOST_JAIL_CASES: TestCase[] = [ + { + name: 'fs_host_relative_path_rejected_s210', + async run(ctx: CaseContext) { + await ctx.expectError( + () => ctx.call('shell::fs::stat', { path: 'relative/path' }), + 'S210', + ); + }, + }, + { + name: 'fs_host_denylist_etc_passwd_rejected_s215', + async run(ctx: CaseContext) { + await ctx.expectError( + () => ctx.call('shell::fs::stat', { path: '/etc/passwd' }), + 'S215', + ); + }, + }, + { + name: 'fs_host_denylist_etc_shadow_rejected_s215', + async run(ctx: CaseContext) { + await ctx.expectError( + () => ctx.call('shell::fs::read', { path: '/etc/shadow' }), + 'S215', + ); + }, + }, + { + name: 'fs_host_unknown_target_kind_rejected_s210', + async run(ctx: CaseContext) { + await ctx.expectError( + () => + ctx.call('shell::fs::ls', { + target: { kind: 'wat' }, + path: '/tmp', + }), + 'S210', + ); + }, + }, +]; diff --git a/shell/tests/e2e/workers/harness/src/cases-fs-host.ts b/shell/tests/e2e/workers/harness/src/cases-fs-host.ts new file mode 100644 index 0000000000..02b3a355a5 --- /dev/null +++ b/shell/tests/e2e/workers/harness/src/cases-fs-host.ts @@ -0,0 +1,259 @@ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ChannelReader } from 'iii-sdk'; +import { expect, expectEqual, type CaseContext, type TestCase } from './cases.ts'; + +const URL = process.env.III_URL ?? 'ws://127.0.0.1:49134'; + +function newWorkdir(prefix: string): string { + return mkdtempSync(join(tmpdir(), `iii-shell-fs-${prefix}-`)); +} + +export async function fsWriteStream( + ctx: CaseContext, + args: { + path: string; + mode?: string; + parents?: boolean; + bytes: Buffer | string; + target?: object; + }, +): Promise<{ bytes_written: number; path: string }> { + const bytes = + typeof args.bytes === 'string' ? Buffer.from(args.bytes, 'utf8') : args.bytes; + const channel = await ctx.iii.createChannel(64); + if (bytes.length === 0) { + // SDK quirk: write(empty) doesn't trigger WS connect, making close() + // a no-op. sendMessage('') forces the socket open. + channel.writer.sendMessage(''); + channel.writer.close(); + return (await ctx.call('shell::fs::write', { + target: args.target ?? { kind: 'host' }, + path: args.path, + mode: args.mode ?? '0644', + parents: args.parents ?? false, + content: channel.readerRef, + })) as { bytes_written: number; path: string }; + } + const writePromise = new Promise((resolve, reject) => { + channel.writer.stream.once('error', reject); + channel.writer.stream.write(bytes, (err: Error | null | undefined) => { + if (err) { + reject(err); + return; + } + channel.writer.stream.end(() => resolve()); + }); + }); + const triggerPromise = ctx.call('shell::fs::write', { + target: args.target ?? { kind: 'host' }, + path: args.path, + mode: args.mode ?? '0644', + parents: args.parents ?? false, + content: channel.readerRef, + }); + await writePromise; + channel.writer.close(); + return (await triggerPromise) as { bytes_written: number; path: string }; +} + +export async function fsReadStream( + ctx: CaseContext, + args: { path: string; target?: object }, +): Promise<{ size: number; mode: string; mtime: number; data: Buffer }> { + const resp = await ctx.call('shell::fs::read', { + target: args.target ?? { kind: 'host' }, + path: args.path, + }); + const reader = new ChannelReader(URL, resp.content); + const data = await reader.readAll(); + return { size: resp.size, mode: resp.mode, mtime: resp.mtime, data }; +} + +export const FS_HOST_CASES: TestCase[] = [ + { + name: 'fs_host_mkdir_then_ls_finds_dir', + async run(ctx: CaseContext) { + const root = newWorkdir('mkdir'); + const child = join(root, 'sub'); + const m = await ctx.call('shell::fs::mkdir', { path: child, mode: '0755' }); + expect(m.created === true, `mkdir.created=${m.created}`); + const ls = await ctx.call('shell::fs::ls', { path: root }); + expect( + Array.isArray(ls.entries) && ls.entries.some((e: any) => e.name === 'sub' && e.is_dir), + `ls did not show sub: ${JSON.stringify(ls)}`, + ); + }, + }, + { + name: 'fs_host_stat_reports_size', + async run(ctx: CaseContext) { + const root = newWorkdir('stat'); + const f = join(root, 'a.txt'); + await fsWriteStream(ctx, { path: f, bytes: 'hello' }); + const s = await ctx.call('shell::fs::stat', { path: f }); + expectEqual(s.size, 5, 'stat.size'); + expect(s.is_dir === false, `is_dir=${s.is_dir}`); + }, + }, + { + name: 'fs_host_write_then_read_round_trips', + async run(ctx: CaseContext) { + const root = newWorkdir('rw'); + const f = join(root, 'r.txt'); + const payload = 'round trip 123'; + await fsWriteStream(ctx, { path: f, mode: '0600', bytes: payload }); + const r = await fsReadStream(ctx, { path: f }); + expectEqual(r.data.toString('utf8'), payload, 'streamed read content'); + }, + }, + { + name: 'fs_host_rm_file', + async run(ctx: CaseContext) { + const root = newWorkdir('rm'); + const f = join(root, 'doomed.txt'); + await fsWriteStream(ctx, { path: f, bytes: 'bye' }); + const r = await ctx.call('shell::fs::rm', { path: f, recursive: false }); + expect(r.removed === true, `rm.removed=${r.removed}`); + await ctx.expectError(() => ctx.call('shell::fs::stat', { path: f }), 'S211'); + }, + }, + { + name: 'fs_host_chmod_sets_mode', + async run(ctx: CaseContext) { + const root = newWorkdir('chmod'); + const f = join(root, 'p.txt'); + await fsWriteStream(ctx, { path: f, bytes: 'x' }); + const c = await ctx.call('shell::fs::chmod', { + path: f, + mode: '0600', + recursive: false, + }); + expectEqual(c.updated, 1, 'chmod.updated'); + const s = await ctx.call('shell::fs::stat', { path: f }); + expectEqual(s.mode, '0600', 'stat.mode after chmod'); + }, + }, + { + name: 'fs_host_mv_renames', + async run(ctx: CaseContext) { + const root = newWorkdir('mv'); + const a = join(root, 'a'); + const b = join(root, 'b'); + await fsWriteStream(ctx, { path: a, bytes: 'm' }); + const m = await ctx.call('shell::fs::mv', { src: a, dst: b, overwrite: false }); + expect(m.moved === true, `mv.moved=${m.moved}`); + await ctx.expectError(() => ctx.call('shell::fs::stat', { path: a }), 'S211'); + const s = await ctx.call('shell::fs::stat', { path: b }); + expectEqual(s.size, 1, 'after mv stat.size'); + }, + }, + { + name: 'fs_host_grep_finds_lines', + async run(ctx: CaseContext) { + const root = newWorkdir('grep'); + const f = join(root, 'g.txt'); + await fsWriteStream(ctx, { path: f, bytes: 'alpha\nbeta\nalpha-beta\n' }); + const g = await ctx.call('shell::fs::grep', { + path: root, + pattern: 'alpha', + recursive: true, + ignore_case: false, + include_glob: [], + exclude_glob: [], + max_matches: 10, + max_line_bytes: 8192, + }); + expectEqual(g.matches.length, 2, 'grep.matches.length'); + expect(!g.truncated, 'grep.truncated should be false'); + }, + }, + { + name: 'fs_host_sed_replaces', + async run(ctx: CaseContext) { + const root = newWorkdir('sed'); + const f = join(root, 's.txt'); + await fsWriteStream(ctx, { path: f, bytes: 'hello hello' }); + const s = await ctx.call('shell::fs::sed', { + files: [f], + recursive: false, + include_glob: [], + exclude_glob: [], + pattern: 'hello', + replacement: 'HI', + regex: false, + first_only: false, + ignore_case: false, + }); + expectEqual(s.total_replacements, 2, 'sed.total_replacements'); + const r = await fsReadStream(ctx, { path: f }); + expectEqual(r.data.toString('utf8'), 'HI HI', 'sed final content'); + }, + }, + { + name: 'fs_host_target_host_is_default', + async run(ctx: CaseContext) { + const root = newWorkdir('default'); + await ctx.call('shell::fs::mkdir', { path: join(root, 'd'), mode: '0755' }); + const ls = await ctx.call('shell::fs::ls', { path: root }); + expect( + ls.entries.some((e: any) => e.name === 'd'), + 'default target=host should have created/listed dir', + ); + }, + }, + { + name: 'fs_host_explicit_target_host', + async run(ctx: CaseContext) { + const root = newWorkdir('explicit'); + await ctx.call('shell::fs::mkdir', { + target: { kind: 'host' }, + path: join(root, 'e'), + mode: '0755', + }); + const ls = await ctx.call('shell::fs::ls', { + target: { kind: 'host' }, + path: root, + }); + expect(ls.entries.some((e: any) => e.name === 'e'), 'explicit host target works'); + }, + }, + { + name: 'fs_host_write_streams_512kib_without_buffering', + async run(ctx: CaseContext) { + const root = newWorkdir('big'); + const f = join(root, 'big.bin'); + const big = Buffer.alloc(512 * 1024); + for (let i = 0; i < big.length; i++) big[i] = i & 0xff; + const w = await fsWriteStream(ctx, { path: f, bytes: big }); + expectEqual(w.bytes_written, big.length, 'bytes_written matches 512 KiB'); + const r = await fsReadStream(ctx, { path: f }); + expectEqual(r.size, big.length, 'read.size matches'); + expect(r.data.equals(big), 'streamed bytes round-trip exactly'); + }, + }, + { + name: 'fs_host_read_succeeds_with_cap_unset', + async run(ctx: CaseContext) { + const root = newWorkdir('cap-off'); + const f = join(root, 'medium.bin'); + const buf = Buffer.alloc(2048); + await fsWriteStream(ctx, { path: f, bytes: buf }); + const r = await fsReadStream(ctx, { path: f }); + expectEqual(r.size, 2048, 'read works with cap unset'); + }, + }, + { + name: 'fs_host_write_empty_stream_creates_zero_byte_file', + async run(ctx: CaseContext) { + const root = newWorkdir('empty'); + const f = join(root, 'empty.bin'); + const w = await fsWriteStream(ctx, { path: f, bytes: Buffer.alloc(0) }); + expectEqual(w.bytes_written, 0, 'bytes_written = 0'); + const r = await fsReadStream(ctx, { path: f }); + expectEqual(r.size, 0, 'file exists with size 0'); + expectEqual(r.data.length, 0, 'streamed body is empty'); + }, + }, +]; diff --git a/shell/tests/e2e/workers/harness/src/cases-fs-protocol-break.ts b/shell/tests/e2e/workers/harness/src/cases-fs-protocol-break.ts new file mode 100644 index 0000000000..d6baebecd4 Binary files /dev/null and b/shell/tests/e2e/workers/harness/src/cases-fs-protocol-break.ts differ diff --git a/shell/tests/e2e/workers/harness/src/cases-fs-sandbox.ts b/shell/tests/e2e/workers/harness/src/cases-fs-sandbox.ts new file mode 100644 index 0000000000..8f67d892b6 --- /dev/null +++ b/shell/tests/e2e/workers/harness/src/cases-fs-sandbox.ts @@ -0,0 +1,378 @@ +import type { ISdk } from 'iii-sdk'; +import { ChannelReader } from 'iii-sdk'; +import { expect, expectEqual, type CaseContext, type TestCase } from './cases.ts'; + +const URL = process.env.III_URL ?? 'ws://127.0.0.1:49134'; + +interface CapturedCall { + function_id: string; + payload: any; +} + +const captured: CapturedCall[] = []; +let nextResponse: any = null; +let nextErrorMessage: string | null = null; +let sandboxWriteDrainedBytes: Buffer | null = null; +let sandboxReadBytesToServe: Buffer | null = null; + +export function resetMocks(): void { + captured.length = 0; + nextResponse = null; + nextErrorMessage = null; + sandboxWriteDrainedBytes = null; + sandboxReadBytesToServe = null; +} + +export function setSandboxReadBytes(bytes: Buffer): void { + sandboxReadBytesToServe = bytes; +} + +export function getSandboxWriteDrainedBytes(): Buffer | null { + return sandboxWriteDrainedBytes; +} + +export function scriptResponse(value: any): void { + nextResponse = value; +} + +export function scriptError(s_code: string, detail: string): void { + nextErrorMessage = `${s_code}: ${detail}`; +} + +export function getCapturedCalls(): readonly CapturedCall[] { + return captured; +} + +export const SANDBOX_ID = '11111111-2222-3333-4444-555555555555'; + +export function setupSandboxMocks(iii: ISdk): void { + const simpleOps = ['ls', 'stat', 'mkdir', 'rm', 'chmod', 'mv', 'grep', 'sed']; + for (const op of simpleOps) { + iii.registerFunction(`sandbox::fs::${op}`, async (payload: any) => { + captured.push({ function_id: `sandbox::fs::${op}`, payload }); + if (nextErrorMessage !== null) { + const m = nextErrorMessage; + nextErrorMessage = null; + throw new Error(m); + } + const r = nextResponse; + nextResponse = null; + return r ?? {}; + }); + } + iii.registerFunction('sandbox::fs::write', async (payload: any) => { + captured.push({ function_id: 'sandbox::fs::write', payload }); + sandboxWriteDrainedBytes = null; + if (nextErrorMessage !== null) { + const m = nextErrorMessage; + nextErrorMessage = null; + throw new Error(m); + } + const reader: ChannelReader = payload.content; + const drained = await reader.readAll(); + sandboxWriteDrainedBytes = drained; + const r = nextResponse; + nextResponse = null; + return r ?? { bytes_written: drained.length, path: payload.path }; + }); + iii.registerFunction('sandbox::fs::read', async (payload: any) => { + captured.push({ function_id: 'sandbox::fs::read', payload }); + if (nextErrorMessage !== null) { + const m = nextErrorMessage; + nextErrorMessage = null; + throw new Error(m); + } + const bytes = sandboxReadBytesToServe ?? Buffer.from(''); + const channel = await iii.createChannel(64); + (async () => { + channel.writer.stream.write(bytes, (err: Error | null | undefined) => { + if (err) { + channel.writer.close(); + return; + } + channel.writer.stream.end(() => channel.writer.close()); + }); + })(); + const r = nextResponse; + nextResponse = null; + return ( + r ?? { + content: channel.readerRef, + size: bytes.length, + mode: '0644', + mtime: 1700000000, + } + ); + }); +} + +export const FS_SANDBOX_CASES: TestCase[] = [ + { + name: 'fs_sandbox_ls_forwards_payload_with_sandbox_id', + async run(ctx: CaseContext) { + resetMocks(); + scriptResponse({ + entries: [ + { name: 'a.txt', is_dir: false, size: 1, mode: '0644', mtime: 0, is_symlink: false }, + ], + }); + const r = await ctx.call('shell::fs::ls', { + target: { kind: 'sandbox', sandbox_id: SANDBOX_ID }, + path: '/x', + }); + expectEqual(r.entries.length, 1, 'response.entries len'); + expectEqual(r.entries[0].name, 'a.txt', 'forwarded entry name'); + expectEqual(captured.length, 1, 'mock invocation count'); + expectEqual(captured[0].function_id, 'sandbox::fs::ls', 'forwarded function id'); + expectEqual(captured[0].payload.sandbox_id, SANDBOX_ID, 'sandbox_id injected'); + expectEqual(captured[0].payload.path, '/x', 'path forwarded'); + }, + }, + { + name: 'fs_sandbox_stat_unwraps_transparent_response', + async run(ctx: CaseContext) { + resetMocks(); + scriptResponse({ + name: 'x.txt', + is_dir: false, + size: 7, + mode: '0640', + mtime: 1700000000, + is_symlink: false, + }); + const r = await ctx.call('shell::fs::stat', { + target: { kind: 'sandbox', sandbox_id: SANDBOX_ID }, + path: '/sb/x.txt', + }); + expectEqual(r.size, 7, 'transparent stat.size'); + expectEqual(r.mode, '0640', 'transparent stat.mode'); + }, + }, + { + name: 'fs_sandbox_mkdir_forwards_mode_and_parents', + async run(ctx: CaseContext) { + resetMocks(); + scriptResponse({ created: true }); + const r = await ctx.call('shell::fs::mkdir', { + target: { kind: 'sandbox', sandbox_id: SANDBOX_ID }, + path: '/sb/new', + mode: '0700', + parents: true, + }); + expectEqual(r.created, true, 'created'); + expectEqual(captured[0].payload.mode, '0700', 'mode forwarded'); + expectEqual(captured[0].payload.parents, true, 'parents forwarded'); + }, + }, + { + name: 'fs_sandbox_rm_recursive_propagates_response', + async run(ctx: CaseContext) { + resetMocks(); + scriptResponse({ removed: true }); + const r = await ctx.call('shell::fs::rm', { + target: { kind: 'sandbox', sandbox_id: SANDBOX_ID }, + path: '/sb/dir', + recursive: true, + }); + expectEqual(r.removed, true, 'removed'); + expectEqual(captured[0].payload.recursive, true, 'recursive forwarded'); + }, + }, + { + name: 'fs_sandbox_chmod_forwards_uid_gid', + async run(ctx: CaseContext) { + resetMocks(); + scriptResponse({ updated: 3 }); + const r = await ctx.call('shell::fs::chmod', { + target: { kind: 'sandbox', sandbox_id: SANDBOX_ID }, + path: '/sb/tree', + mode: '0700', + uid: 1000, + gid: 1000, + recursive: true, + }); + expectEqual(r.updated, 3, 'updated count'); + expectEqual(captured[0].payload.uid, 1000, 'uid forwarded'); + expectEqual(captured[0].payload.gid, 1000, 'gid forwarded'); + }, + }, + { + name: 'fs_sandbox_mv_forwards_overwrite', + async run(ctx: CaseContext) { + resetMocks(); + scriptResponse({ moved: true }); + const r = await ctx.call('shell::fs::mv', { + target: { kind: 'sandbox', sandbox_id: SANDBOX_ID }, + src: '/sb/a', + dst: '/sb/b', + overwrite: true, + }); + expectEqual(r.moved, true, 'moved'); + expectEqual(captured[0].payload.overwrite, true, 'overwrite forwarded'); + expectEqual(captured[0].payload.src, '/sb/a', 'src forwarded'); + expectEqual(captured[0].payload.dst, '/sb/b', 'dst forwarded'); + }, + }, + { + name: 'fs_sandbox_grep_forwards_full_payload', + async run(ctx: CaseContext) { + resetMocks(); + scriptResponse({ + matches: [{ path: '/sb/a.rs', line: 7, content: 'foo' }], + truncated: false, + }); + const r = await ctx.call('shell::fs::grep', { + target: { kind: 'sandbox', sandbox_id: SANDBOX_ID }, + path: '/sb', + pattern: 'foo', + recursive: true, + ignore_case: true, + include_glob: ['*.rs'], + exclude_glob: ['target/*'], + max_matches: 50, + max_line_bytes: 4096, + }); + expectEqual(r.matches.length, 1, 'matches len'); + expectEqual(r.matches[0].line, 7, 'match.line'); + const p = captured[0].payload; + expectEqual(p.ignore_case, true, 'ignore_case forwarded'); + expectEqual(p.max_matches, 50, 'max_matches forwarded'); + expectEqual(p.include_glob[0], '*.rs', 'include_glob forwarded'); + }, + }, + { + name: 'fs_sandbox_sed_forwards_full_payload', + async run(ctx: CaseContext) { + resetMocks(); + scriptResponse({ + results: [{ path: '/sb/a.rs', replacements: 2, success: true }], + total_replacements: 2, + }); + const r = await ctx.call('shell::fs::sed', { + target: { kind: 'sandbox', sandbox_id: SANDBOX_ID }, + files: ['/sb/a.rs'], + recursive: false, + include_glob: [], + exclude_glob: [], + pattern: 'foo', + replacement: 'bar', + regex: false, + first_only: false, + ignore_case: false, + }); + expectEqual(r.total_replacements, 2, 'total_replacements'); + expect(Array.isArray(r.results) && r.results[0].success === true, 'success flag forwarded'); + }, + }, + { + name: 'fs_sandbox_engine_s211_propagates_to_caller', + async run(ctx: CaseContext) { + resetMocks(); + scriptError('S211', 'path not found: /sb/missing'); + await ctx.expectError( + () => + ctx.call('shell::fs::ls', { + target: { kind: 'sandbox', sandbox_id: SANDBOX_ID }, + path: '/sb/missing', + }), + 'S211', + ); + }, + }, + { + name: 'fs_sandbox_engine_s002_sandbox_not_found_propagates', + async run(ctx: CaseContext) { + resetMocks(); + scriptError('S002', 'sandbox not found'); + await ctx.expectError( + () => + ctx.call('shell::fs::stat', { + target: { kind: 'sandbox', sandbox_id: SANDBOX_ID }, + path: '/anything', + }), + 'S002', + ); + }, + }, + { + name: 'fs_sandbox_engine_s217_bad_regex_propagates', + async run(ctx: CaseContext) { + resetMocks(); + scriptError('S217', 'bad regex: unclosed character class'); + await ctx.expectError( + () => + ctx.call('shell::fs::grep', { + target: { kind: 'sandbox', sandbox_id: SANDBOX_ID }, + path: '/sb', + pattern: '[unclosed', + recursive: true, + ignore_case: false, + include_glob: [], + exclude_glob: [], + max_matches: 10, + max_line_bytes: 4096, + }), + 'S217', + ); + }, + }, + { + name: 'fs_sandbox_write_forwards_content_ref_and_passes_bytes_through', + async run(ctx: CaseContext) { + resetMocks(); + const channel = await ctx.iii.createChannel(64); + const writePromise = new Promise((resolve, reject) => { + channel.writer.stream.once('error', reject); + channel.writer.stream.write(Buffer.from('hello sandbox'), (err) => { + if (err) { reject(err); return; } + channel.writer.stream.end(() => resolve()); + }); + }); + const triggerPromise = ctx.call('shell::fs::write', { + target: { kind: 'sandbox', sandbox_id: SANDBOX_ID }, + path: '/sb/x', + mode: '0644', + parents: false, + content: channel.readerRef, + }); + await writePromise; + channel.writer.close(); + const r = await triggerPromise; + expectEqual(r.bytes_written, 13, 'bytes_written round-trips'); + expectEqual(r.path, '/sb/x', 'path forwarded'); + expectEqual(captured.length, 1, 'mock invoked exactly once'); + const drained = getSandboxWriteDrainedBytes(); + expect(drained !== null && drained.equals(Buffer.from('hello sandbox')), + 'engine drained the caller\'s bytes verbatim', + ); + const fwdedPayload = captured[0].payload; + expectEqual(fwdedPayload.path, '/sb/x', 'forwarded payload.path'); + expectEqual(fwdedPayload.mode, '0644', 'forwarded payload.mode'); + expect(fwdedPayload.content instanceof ChannelReader, + 'forwarded payload.content resolved to ChannelReader by SDK', + ); + }, + }, + { + name: 'fs_sandbox_read_returns_engine_channel_ref_and_streams_bytes', + async run(ctx: CaseContext) { + resetMocks(); + setSandboxReadBytes(Buffer.from('sandbox payload')); + const r = await ctx.call('shell::fs::read', { + target: { kind: 'sandbox', sandbox_id: SANDBOX_ID }, + path: '/sb/y', + }); + expectEqual(r.size, 15, 'size from engine'); + expectEqual(r.mode, '0644', 'mode from engine'); + expect(r.content && typeof r.content.channel_id === 'string', + `engine response.content shape: ${JSON.stringify(r.content)}`, + ); + const reader = new ChannelReader(URL, r.content); + const data = await reader.readAll(); + expectEqual(data.toString('utf8'), 'sandbox payload', 'streamed bytes'); + const fwdedPayload = captured[0].payload; + expectEqual(fwdedPayload.path, '/sb/y', 'forwarded payload.path'); + expect(fwdedPayload.content === undefined, 'read request must NOT carry content'); + }, + }, +]; diff --git a/shell/tests/e2e/workers/harness/src/cases-jobs-break.ts b/shell/tests/e2e/workers/harness/src/cases-jobs-break.ts new file mode 100644 index 0000000000..ca2b153288 --- /dev/null +++ b/shell/tests/e2e/workers/harness/src/cases-jobs-break.ts @@ -0,0 +1,120 @@ +import { expect, type TestCase } from './cases.ts'; + +export const JOB_BREAK_CASES: TestCase[] = [ + { + name: 'job_status_unknown_id_rejects', + async run({ call, expectError }) { + await expectError( + () => call('shell::status', { job_id: 'job-does-not-exist' }), + 'no such job', + ); + }, + }, + { + name: 'job_status_empty_id_rejects', + async run({ call, expectError }) { + await expectError( + () => call('shell::status', { job_id: '' }), + 'no such job', + ); + }, + }, + { + name: 'job_status_id_wrong_type_rejects', + async run({ call }) { + let threw = false; + try { + await call('shell::status', { job_id: 42 }); + } catch { + threw = true; + } + expect(threw, 'numeric job_id must be rejected'); + }, + }, + { + name: 'job_kill_twice_second_is_idempotent_no_op', + async run({ call, sleep }) { + const spawned = await call('shell::exec_bg', { command: 'sleep', args: ['5'] }); + const jobId = spawned.job_id; + const first = await call('shell::kill', { job_id: jobId }); + expect(first.killed === true, `first kill returns killed:true (got ${JSON.stringify(first.killed)})`); + await sleep(300); + const second = await call('shell::kill', { job_id: jobId }); + expect( + second.killed === false, + `second kill returns killed:false (got ${JSON.stringify(second.killed)})`, + ); + expect( + typeof second.reason === 'string', + `second kill includes a reason field (got ${JSON.stringify(second.reason)})`, + ); + }, + }, + { + name: 'job_kill_already_finished_is_no_op', + async run({ call, sleep }) { + const spawned = await call('shell::exec_bg', { + command: 'sh', + args: ['-c', 'echo bye'], + }); + const jobId = spawned.job_id; + await sleep(500); + const r = await call('shell::kill', { job_id: jobId }); + expect( + r.killed === false, + `kill on finished job returns killed:false (got ${JSON.stringify(r.killed)})`, + ); + expect( + r.status === 'finished' || r.status === 'killed', + `kill on finished job reports terminal status (got ${JSON.stringify(r.status)})`, + ); + }, + }, + { + name: 'job_exec_bg_concurrent_flood_respects_cap', + async run({ call, sleep }) { + const promises = Array.from({ length: 5 }, () => + call('shell::exec_bg', { command: 'sleep', args: ['5'] }), + ); + const settled = await Promise.allSettled(promises); + const succeeded = settled.filter((s) => s.status === 'fulfilled'); + const rejected = settled.filter((s) => s.status === 'rejected'); + expect( + succeeded.length <= 2, + `at most 2 concurrent spawns should succeed (got ${succeeded.length})`, + ); + expect( + rejected.length >= 3, + `at least 3 must reject under cap (got ${rejected.length})`, + ); + const sawCapMessage = rejected.some((r) => { + const msg = (r as PromiseRejectedResult).reason?.message ?? String((r as PromiseRejectedResult).reason); + return msg.includes('max concurrent jobs'); + }); + expect(sawCapMessage, 'at least one rejection must mention the cap'); + for (const s of succeeded) { + const jobId = (s as PromiseFulfilledResult<{ job_id: string }>).value.job_id; + await call('shell::kill', { job_id: jobId }).catch(() => {}); + } + await sleep(300); + }, + }, + { + name: 'job_list_after_retention_window_is_empty', + async run({ call, sleep }) { + const spawned = await call('shell::exec_bg', { + command: 'sh', + args: ['-c', 'echo done'], + }); + await sleep(500); + await call('shell::list', {}); + await sleep(2500); + const after = await call('shell::list', {}); + const ids = (after.jobs ?? after.records ?? []).map((j: { id: string }) => j.id); + expect( + !ids.includes(spawned.job_id), + `${spawned.job_id} must be pruned after retention window (got ${JSON.stringify(ids)})`, + ); + }, + }, +]; diff --git a/shell/tests/e2e/workers/harness/src/cases-jobs.ts b/shell/tests/e2e/workers/harness/src/cases-jobs.ts new file mode 100644 index 0000000000..b5cc425404 --- /dev/null +++ b/shell/tests/e2e/workers/harness/src/cases-jobs.ts @@ -0,0 +1,109 @@ +import type { TestCase } from './cases.ts'; +import { expect, expectEqual } from './cases.ts'; + +export const JOB_CASES: TestCase[] = [ + { + name: 'exec_bg returns job_id and argv', + async run({ call, sleep }) { + const r = await call('shell::exec_bg', { + command: 'sh', + args: ['-c', 'sleep 0.1; echo done'], + }); + expect(typeof r.job_id === 'string' && r.job_id.length > 0, 'job_id present'); + expectEqual(r.argv, ['sh', '-c', 'sleep 0.1; echo done'], 'argv echoed'); + await sleep(500); + }, + }, + { + name: 'status reports running then finished with stdout', + async run({ call, sleep }) { + const spawned = await call('shell::exec_bg', { + command: 'sh', + args: ['-c', 'sleep 0.3; echo done'], + }); + const jobId = spawned.job_id; + + const running = await call('shell::status', { job_id: jobId }); + expectEqual(running.job.status, 'running', 'status running'); + + await sleep(800); + + const finished = await call('shell::status', { job_id: jobId }); + expectEqual(finished.job.status, 'finished', 'status finished'); + expectEqual(finished.job.exit_code, 0, 'exit_code 0'); + expectEqual(finished.job.stdout, 'done\n', 'stdout captured'); + }, + }, + { + name: 'kill marks running job as killed', + async run({ call, sleep }) { + const spawned = await call('shell::exec_bg', { command: 'sleep', args: ['5'] }); + const jobId = spawned.job_id; + await call('shell::kill', { job_id: jobId }); + await sleep(300); + const after = await call('shell::status', { job_id: jobId }); + expectEqual(after.job.status, 'killed', 'status killed'); + }, + }, + { + name: 'kill on unknown job_id rejects', + async run({ call, expectError }) { + await expectError( + () => call('shell::kill', { job_id: 'job-does-not-exist' }), + 'no such job', + ); + }, + }, + { + name: 'list includes spawned job', + async run({ call, sleep }) { + const spawned = await call('shell::exec_bg', { + command: 'sh', + args: ['-c', 'sleep 0.1'], + }); + const list = await call('shell::list', {}); + const ids = (list.jobs ?? list.records ?? []).map((j: any) => j.id); + expect( + Array.isArray(ids) && ids.includes(spawned.job_id), + `list missing job_id ${spawned.job_id}; got ${JSON.stringify(ids)}`, + ); + await sleep(500); + }, + }, + { + name: 'max_concurrent_jobs cap rejects third spawn', + async run({ call, sleep, expectError }) { + const a = await call('shell::exec_bg', { command: 'sleep', args: ['5'] }); + const b = await call('shell::exec_bg', { command: 'sleep', args: ['5'] }); + try { + await expectError( + () => call('shell::exec_bg', { command: 'sleep', args: ['5'] }), + 'max concurrent jobs', + ); + } finally { + await call('shell::kill', { job_id: a.job_id }).catch(() => {}); + await call('shell::kill', { job_id: b.job_id }).catch(() => {}); + await sleep(300); + } + }, + }, + { + name: 'finished jobs prune after job_retention_secs', + async run({ call, sleep }) { + const spawned = await call('shell::exec_bg', { + command: 'sh', + args: ['-c', 'echo bye'], + }); + const jobId = spawned.job_id; + await sleep(500); + const before = await call('shell::list', {}); + const idsBefore = (before.jobs ?? before.records ?? []).map((j: any) => j.id); + expect(idsBefore.includes(jobId), 'job present before retention window'); + + await sleep(2500); + const after = await call('shell::list', {}); + const idsAfter = (after.jobs ?? after.records ?? []).map((j: any) => j.id); + expect(!idsAfter.includes(jobId), `job ${jobId} not pruned: got ${JSON.stringify(idsAfter)}`); + }, + }, +]; diff --git a/shell/tests/e2e/workers/harness/src/cases-safety.ts b/shell/tests/e2e/workers/harness/src/cases-safety.ts new file mode 100644 index 0000000000..63c0966f0d --- /dev/null +++ b/shell/tests/e2e/workers/harness/src/cases-safety.ts @@ -0,0 +1,97 @@ +import type { TestCase } from './cases.ts'; +import { expect, expectEqual } from './cases.ts'; + +export const SAFETY_CASES: TestCase[] = [ + { + name: 'allowlist rejects unlisted command', + async run({ call, expectError }) { + await expectError( + () => call('shell::exec', { command: 'nmap', args: ['-v'] }), + 'not in allowlist', + ); + }, + }, + { + name: 'allowlist permits via basename match', + async run({ call }) { + const r = await call('shell::exec', { command: '/bin/ls', args: ['-d', '.'] }); + expectEqual(r.exit_code, 0, 'exit_code'); + }, + }, + { + name: 'denylist blocks via regex match on allowlisted command', + async run({ call, expectError }) { + await expectError( + () => call('shell::exec', { command: 'echo', args: ['harness_denylist_marker'] }), + 'denylist', + ); + }, + }, + { + name: 'timeout_ms triggers timed_out=true', + async run({ call }) { + const r = await call('shell::exec', { command: 'sleep', args: ['5'], timeout_ms: 200 }); + expectEqual(r.timed_out, true, 'timed_out'); + expectEqual(r.exit_code, null, 'exit_code is null on timeout'); + }, + }, + { + name: 'max_timeout_ms clamps oversize timeout_ms', + async run({ call }) { + const r = await call('shell::exec', { + command: 'sleep', + args: ['0.05'], + timeout_ms: 999_999, + }); + expectEqual(r.timed_out, false, 'timed_out'); + expectEqual(r.exit_code, 0, 'exit_code'); + expect(r.duration_ms < 5000, `duration_ms < 5000 (got ${r.duration_ms})`); + }, + }, + { + name: 'stdout truncation flags at max_output_bytes', + async run({ call }) { + const r = await call('shell::exec', { + command: 'sh', + args: ['-c', "printf 'x%.0s' $(seq 1 8000)"], + }); + expectEqual(r.exit_code, 0, 'exit_code'); + expectEqual(r.stdout_truncated, true, 'stdout_truncated'); + expectEqual(r.stdout.length, 4096, 'stdout length capped at 4096'); + }, + }, + { + name: 'stderr truncation flags at max_output_bytes', + async run({ call }) { + const r = await call('shell::exec', { + command: 'sh', + args: ['-c', "printf 'y%.0s' $(seq 1 8000) 1>&2"], + }); + expectEqual(r.exit_code, 0, 'exit_code'); + expectEqual(r.stderr_truncated, true, 'stderr_truncated'); + expectEqual(r.stderr.length, 4096, 'stderr length capped at 4096'); + }, + }, + { + name: 'env scrubbing drops non-allowlisted vars', + async run({ call }) { + const r = await call('shell::exec', { command: 'env' }); + expectEqual(r.exit_code, 0, 'exit_code'); + expect( + !r.stdout.includes('HARNESS_NOT_ALLOWED'), + `HARNESS_NOT_ALLOWED leaked through: ${r.stdout}`, + ); + }, + }, + { + name: 'env passthrough forwards allowlisted var', + async run({ call }) { + const r = await call('shell::exec', { command: 'env' }); + expectEqual(r.exit_code, 0, 'exit_code'); + expect( + r.stdout.includes('HARNESS_TEST_VAR=harness-allowed-value'), + `HARNESS_TEST_VAR not forwarded: ${r.stdout}`, + ); + }, + }, +]; diff --git a/shell/tests/e2e/workers/harness/src/cases-sandbox-break.ts b/shell/tests/e2e/workers/harness/src/cases-sandbox-break.ts new file mode 100644 index 0000000000..fcd480bb1b --- /dev/null +++ b/shell/tests/e2e/workers/harness/src/cases-sandbox-break.ts @@ -0,0 +1,205 @@ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { StreamChannelRef } from 'iii-sdk'; +import { ChannelReader } from 'iii-sdk'; +import { expect, expectEqual, type CaseContext, type TestCase } from './cases.ts'; +import { + resetMocks, + scriptResponse, + scriptError, + getCapturedCalls, + SANDBOX_ID, +} from './cases-fs-sandbox.ts'; + +const URL = process.env.III_URL ?? 'ws://127.0.0.1:49134'; + +function newWorkdir(prefix: string): string { + return mkdtempSync(join(tmpdir(), `iii-shell-sb-break-${prefix}-`)); +} + +const SB_TARGET = { kind: 'sandbox' as const, sandbox_id: SANDBOX_ID }; + +export const SANDBOX_BREAK_CASES: TestCase[] = [ + { + name: 'sandbox_mock_returns_empty_object_for_ls_yields_s216', + async run(ctx: CaseContext) { + resetMocks(); + scriptResponse({}); + await ctx.expectError( + () => ctx.call('shell::fs::ls', { target: SB_TARGET, path: '/x' }), + 'S216', + ); + }, + }, + { + name: 'sandbox_mock_returns_wrong_type_for_entries_yields_s216', + async run(ctx: CaseContext) { + resetMocks(); + scriptResponse({ entries: 'this should be an array' }); + await ctx.expectError( + () => ctx.call('shell::fs::ls', { target: SB_TARGET, path: '/x' }), + 'S216', + ); + }, + }, + { + name: 'sandbox_mock_returns_null_yields_s216', + async run(ctx: CaseContext) { + resetMocks(); + scriptResponse(null); + await ctx.expectError( + () => ctx.call('shell::fs::ls', { target: SB_TARGET, path: '/x' }), + 'S216', + ); + }, + }, + { + name: 'sandbox_mock_extra_unknown_fields_tolerated', + async run(ctx: CaseContext) { + resetMocks(); + scriptResponse({ + entries: [ + { name: 'ok.txt', is_dir: false, size: 4, mode: '0644', mtime: 0, is_symlink: false }, + ], + future_field_added_in_v2: 'should be ignored', + another_extra: 42, + nested_extra: { also: 'ignored' }, + }); + const r = await ctx.call('shell::fs::ls', { target: SB_TARGET, path: '/x' }); + expectEqual(r.entries.length, 1, 'entries forwarded despite extras'); + expectEqual(r.entries[0].name, 'ok.txt', 'entry payload intact'); + }, + }, + { + name: 'sandbox_unknown_s_code_collapses_to_s216', + async run(ctx: CaseContext) { + resetMocks(); + scriptError('S299', 'fictional future error code'); + await ctx.expectError( + () => ctx.call('shell::fs::ls', { target: SB_TARGET, path: '/x' }), + 'S216', + ); + }, + }, + { + name: 'sandbox_engine_s213_propagates_to_caller', + async run(ctx: CaseContext) { + resetMocks(); + scriptError('S213', 'destination already exists: /sb/dst'); + await ctx.expectError( + () => ctx.call('shell::fs::mv', { + target: SB_TARGET, + src: '/sb/a', + dst: '/sb/dst', + overwrite: false, + }), + 'S213', + ); + }, + }, + { + name: 'sandbox_engine_error_without_s_code_falls_back_to_s216', + async run(ctx: CaseContext) { + resetMocks(); + scriptError('NOTACODE', 'just a plain old error string with no code'); + await ctx.expectError( + () => ctx.call('shell::fs::ls', { target: SB_TARGET, path: '/x' }), + 'S216', + ); + }, + }, + { + // Sandbox writes are NOT subject to host caps — the sandbox is + // expected to enforce its own limits. + name: 'sandbox_write_path_bypasses_host_cap', + async run(ctx: CaseContext) { + resetMocks(); + const root = newWorkdir('cap-bypass'); + const channel = await ctx.iii.createChannel(64); + const oversize = Buffer.alloc(2 * 1024 * 1024); // 2 MiB > 1 MiB host cap + + const writePromise = new Promise((resolve, reject) => { + channel.writer.stream.once('error', reject); + channel.writer.stream.write(oversize, (err) => { + if (err) return reject(err); + channel.writer.stream.end(() => resolve()); + }); + }); + const trigger = ctx.call('shell::fs::write', { + target: SB_TARGET, + path: join(root, 'big.bin'), + mode: '0644', + parents: false, + content: channel.readerRef, + }); + await writePromise; + channel.writer.close(); + const r = await trigger; + expectEqual( + r.bytes_written, + oversize.length, + 'sandbox write succeeds at 2 MiB despite 1 MiB host cap', + ); + const calls = getCapturedCalls(); + const writeCall = calls.find((c) => c.function_id === 'sandbox::fs::write'); + expect(writeCall !== undefined, 'mock received the sandbox write call'); + }, + }, + { + name: 'sandbox_read_with_bogus_engine_channel_ref_passes_through', + async run(ctx: CaseContext) { + resetMocks(); + const bogusRef: StreamChannelRef = { + channel_id: '99999999-9999-9999-9999-999999999999', + access_key: 'no-such-key', + direction: 'read', + }; + scriptResponse({ + content: bogusRef, + size: 42, + mode: '0644', + mtime: 1700000000, + }); + const resp = await ctx.call('shell::fs::read', { + target: SB_TARGET, + path: '/sb/whatever', + }); + expectEqual(resp.size, 42, 'metadata forwarded verbatim'); + expectEqual(resp.content.channel_id, bogusRef.channel_id, 'bogus channel_id forwarded as-is'); + const reader = new ChannelReader(URL, resp.content); + let threwOrEmpty = false; + try { + const data = await reader.readAll(); + threwOrEmpty = data.length === 0; + } catch { + threwOrEmpty = true; + } + expect(threwOrEmpty, 'bogus ref must fail to drain or be empty'); + const r = await ctx.call('shell::exec', { command: 'echo', args: ['ok'] }); + expectEqual(r.exit_code, 0, 'worker survives bogus engine ref'); + }, + }, + { + name: 'sandbox_mock_null_in_nested_required_field_yields_s216', + async run(ctx: CaseContext) { + resetMocks(); + scriptResponse({ + entries: [ + { + name: null, // non-optional in FsEntry + is_dir: false, + size: 1, + mode: '0644', + mtime: 0, + is_symlink: false, + }, + ], + }); + await ctx.expectError( + () => ctx.call('shell::fs::ls', { target: SB_TARGET, path: '/x' }), + 'S216', + ); + }, + }, +]; diff --git a/shell/tests/e2e/workers/harness/src/cases-streaming-break.ts b/shell/tests/e2e/workers/harness/src/cases-streaming-break.ts new file mode 100644 index 0000000000..c5a9aaac60 --- /dev/null +++ b/shell/tests/e2e/workers/harness/src/cases-streaming-break.ts @@ -0,0 +1,246 @@ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { StreamChannelRef } from 'iii-sdk'; +import { expect, expectEqual, type CaseContext, type TestCase } from './cases.ts'; + +function newWorkdir(prefix: string): string { + return mkdtempSync(join(tmpdir(), `iii-shell-stream-break-${prefix}-`)); +} + +async function expectWorkerHealthy(ctx: CaseContext): Promise { + const r = await ctx.call('shell::exec', { command: 'echo', args: ['ok'] }); + expectEqual(r.exit_code, 0, 'worker still healthy after stream break'); +} + +export const STREAMING_BREAK_CASES: TestCase[] = [ + { + name: 'stream_write_with_bogus_channel_id_rejected', + async run(ctx: CaseContext) { + const root = newWorkdir('bogus-id'); + const ref: StreamChannelRef = { + channel_id: '00000000-0000-0000-0000-000000000000', + access_key: 'definitely-not-a-real-key', + direction: 'read', + }; + let threw = false; + try { + await ctx.call('shell::fs::write', { + target: { kind: 'host' }, + path: join(root, 'never-written.bin'), + mode: '0644', + parents: false, + content: ref, + }); + } catch { + threw = true; + } + expect(threw, 'bogus channel_id should reject; worker must not hang'); + await expectWorkerHealthy(ctx); + }, + }, + { + name: 'stream_write_with_wrong_access_key_rejected', + async run(ctx: CaseContext) { + const root = newWorkdir('bad-key'); + const channel = await ctx.iii.createChannel(64); + const tampered: StreamChannelRef = { + channel_id: channel.readerRef.channel_id, + access_key: 'tampered-' + channel.readerRef.access_key, + direction: 'read', + }; + let threw = false; + try { + await ctx.call('shell::fs::write', { + target: { kind: 'host' }, + path: join(root, 'auth-fail.bin'), + mode: '0644', + parents: false, + content: tampered, + }); + } catch { + threw = true; + } + expect(threw, 'mutated access_key should reject'); + try { channel.writer.close(); } catch { /* ignore */ } + await expectWorkerHealthy(ctx); + }, + }, + { + name: 'stream_write_with_empty_access_key_rejected', + async run(ctx: CaseContext) { + const root = newWorkdir('empty-key'); + const ref: StreamChannelRef = { + channel_id: '11111111-1111-1111-1111-111111111111', + access_key: '', + direction: 'read', + }; + let threw = false; + try { + await ctx.call('shell::fs::write', { + target: { kind: 'host' }, + path: join(root, 'empty-key.bin'), + mode: '0644', + parents: false, + content: ref, + }); + } catch { + threw = true; + } + expect(threw, 'empty access_key should reject'); + await expectWorkerHealthy(ctx); + }, + }, + { + // Reused reader_ref after close: engine treats re-dial as immediate + // EOF, producing a zero-byte file rather than an error. + name: 'stream_write_reader_reused_after_close_yields_zero_bytes', + async run(ctx: CaseContext) { + const root = newWorkdir('reuse'); + const path1 = join(root, 'first.bin'); + const path2 = join(root, 'second.bin'); + const channel = await ctx.iii.createChannel(64); + const payload = Buffer.from('hello world'); + + // First write — drives the channel through completion. + const writePromise = new Promise((resolve, reject) => { + channel.writer.stream.once('error', reject); + channel.writer.stream.write(payload, (err) => { + if (err) return reject(err); + channel.writer.stream.end(() => resolve()); + }); + }); + const trigger = ctx.call('shell::fs::write', { + target: { kind: 'host' }, + path: path1, + mode: '0644', + parents: false, + content: channel.readerRef, + }); + await writePromise; + channel.writer.close(); + const r1 = await trigger; + expectEqual(r1.bytes_written, payload.length, 'first write succeeded'); + + let zeroSize = false; + let threw = false; + try { + const r2 = await ctx.call('shell::fs::write', { + target: { kind: 'host' }, + path: path2, + mode: '0644', + parents: false, + content: channel.readerRef, + }); + zeroSize = r2.bytes_written === 0; + } catch { + threw = true; + } + expect( + zeroSize || threw, + 'reused reader_ref must either yield zero bytes or reject — never crash', + ); + await expectWorkerHealthy(ctx); + }, + }, + { + name: 'stream_write_channel_closed_before_call', + async run(ctx: CaseContext) { + const root = newWorkdir('closed-pre'); + const path = join(root, 'closed.bin'); + const channel = await ctx.iii.createChannel(64); + // sendMessage('') forces WS connect before close() (SDK quirk: + // close() is a no-op when never connected). + channel.writer.sendMessage(''); + channel.writer.close(); + let succeeded = false; + let threw = false; + try { + const r = await ctx.call('shell::fs::write', { + target: { kind: 'host' }, + path, + mode: '0644', + parents: false, + content: channel.readerRef, + }); + expectEqual(r.bytes_written, 0, 'pre-closed channel writes 0 bytes'); + succeeded = true; + } catch { + threw = true; + } + expect(succeeded || threw, 'must either succeed cleanly or reject cleanly'); + await expectWorkerHealthy(ctx); + }, + }, + { + // Text frames are dispatched to an empty callback list and skipped by + // next_binary() — cannot smuggle data through the binary stream. + name: 'stream_write_text_only_frames_yields_zero_bytes', + async run(ctx: CaseContext) { + const root = newWorkdir('text-only'); + const path = join(root, 'text-only.bin'); + const channel = await ctx.iii.createChannel(64); + channel.writer.sendMessage('this is text, not binary'); + channel.writer.sendMessage('still text'); + const trigger = ctx.call('shell::fs::write', { + target: { kind: 'host' }, + path, + mode: '0644', + parents: false, + content: channel.readerRef, + }); + await ctx.sleep(100); + channel.writer.close(); + const r = await trigger; + expectEqual(r.bytes_written, 0, 'text frames must not count as bytes_written'); + await expectWorkerHealthy(ctx); + }, + }, + { + name: 'stream_read_consumer_drops_without_drain', + async run(ctx: CaseContext) { + const root = newWorkdir('drop-read'); + const path = join(root, 'undrained.bin'); + // Write a small file via the streaming write path. + const channel = await ctx.iii.createChannel(64); + const payload = Buffer.from('payload-bytes'); + const wp = new Promise((resolve, reject) => { + channel.writer.stream.once('error', reject); + channel.writer.stream.write(payload, (err) => { + if (err) return reject(err); + channel.writer.stream.end(() => resolve()); + }); + }); + const trigger = ctx.call('shell::fs::write', { + target: { kind: 'host' }, + path, + mode: '0644', + parents: false, + content: channel.readerRef, + }); + await wp; + channel.writer.close(); + await trigger; + + const resp = await ctx.call('shell::fs::read', { + target: { kind: 'host' }, + path, + }); + expect(typeof resp.size === 'number', 'read returns size'); + expect(resp.content && typeof resp.content.channel_id === 'string', + 'read returns a channel ref'); + await ctx.sleep(50); + await expectWorkerHealthy(ctx); + }, + }, + // KNOWN GAP: stream_write_no_eof_hits_deadline — the write-streaming loop + // at shell/src/fs/host.rs:1027-1053 has no per-call deadline. Calling + // shell::fs::write with a never-EOF channel hangs forever. Adding a + // streaming deadline knob is tracked separately; testing that gap with a + // green-pass assertion would require either an arbitrary JS-side timeout + // (flaky in CI) or a worker-config change. + // KNOWN GAP: stream_read_path_deleted_mid_stream — the read pump opens the + // file before allocating the channel, so deleting the path after fs::read + // returns has no observable effect on the stream content. Asserting that + // is more of a unix-semantics test than a protocol test. +]; diff --git a/shell/tests/e2e/workers/harness/src/cases-vuln-repro-jailed.ts b/shell/tests/e2e/workers/harness/src/cases-vuln-repro-jailed.ts new file mode 100644 index 0000000000..38004332d9 --- /dev/null +++ b/shell/tests/e2e/workers/harness/src/cases-vuln-repro-jailed.ts @@ -0,0 +1,138 @@ +// Regression test for S-C1 (symlink-parent jail escape). Originally +// this case demonstrated the vuln by writing through a symlink whose +// target was outside host_root and observing the bytes land outside; +// after the fix in `shell/src/fs/host.rs` (canonicalize_with_fallback +// + lexical normalization of the non-existent tail), the same +// invocation must reject with S215. Runs only against +// `config-jailed.yaml`. + +import { + existsSync, + mkdirSync, + rmSync, + symlinkSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { expect, type CaseContext, type TestCase } from './cases.ts'; +import { fsWriteStream } from './cases-fs-host.ts'; + +const JAIL_ROOT = '/private/tmp/iii-shell-jailed-root'; + +export const VULN_REPRO_JAILED_CASES: TestCase[] = [ + { + name: 'vuln_repro_c1_symlink_parent_write_rejected_by_canonicalize_with_fallback', + async run(ctx: CaseContext) { + const escapeId = randomUUID(); + const externalDir = `/private/tmp/iii-shell-vuln-c1-escape-${escapeId}`; + const escapeName = 'planted.txt'; + const externalTarget = join(externalDir, escapeName); + + mkdirSync(JAIL_ROOT, { recursive: true }); + mkdirSync(externalDir, { recursive: true }); + const linkInsideJail = join(JAIL_ROOT, `escape-${escapeId}`); + try { rmSync(linkInsideJail, { force: true, recursive: false }); } catch { /* ok */ } + symlinkSync(externalDir, linkInsideJail); + + const escapePath = join(linkInsideJail, escapeName); + expect(!existsSync(escapePath), 'precondition: escape path must not exist yet'); + + // FIX: validate_path now canonicalizes the longest existing + // ancestor (which resolves the symlink to externalDir, outside + // host_root) and lexically appends the non-existent tail. The + // resulting path no longer starts_with(host_root) — engine + // rejects with S215. + let rejected = false; + let observed = ''; + try { + await fsWriteStream(ctx, { + path: escapePath, + mode: '0644', + parents: false, + bytes: 'should-be-rejected\n', + }); + } catch (e: any) { + rejected = true; + observed = e?.message ?? String(e); + } + + expect( + rejected, + `regression: write to symlinked-out-of-jail path was accepted (must reject with S215)`, + ); + expect( + observed.includes('S215'), + `regression: rejection code wrong (want S215, got: ${observed})`, + ); + expect( + !existsSync(externalTarget), + `regression: bytes still landed outside the jail at ${externalTarget}`, + ); + + try { rmSync(externalDir, { recursive: true, force: true }); } catch { /* ok */ } + try { rmSync(linkInsideJail, { force: true }); } catch { /* ok */ } + }, + }, + { + // S-C1 second site: shell/src/fs/host.rs (write parents:true block). + // The original lexical-fallback flaw existed both in validate_path + // and in the parents:true defense-in-depth re-check. The fix to + // validate_path closes the upstream side; the defense-in-depth now + // uses host_root_canon (precomputed at HostFsBackend::new()) to + // re-check the parent. This case exercises the parents:true path + // explicitly: the external escape directory does NOT pre-exist, so + // create_dir_all has to walk through the symlink, and the + // defense-in-depth check is what catches the escape attempt. + name: 'vuln_repro_c1b_symlink_parent_with_parents_true_rejected', + async run(ctx: CaseContext) { + const escapeId = randomUUID(); + const externalDir = `/private/tmp/iii-shell-vuln-c1b-escape-${escapeId}`; + const escapeName = 'planted.txt'; + const externalTarget = join(externalDir, escapeName); + + mkdirSync(JAIL_ROOT, { recursive: true }); + // Intentionally do NOT create externalDir — parents:true should + // try to mkdir through the symlink and the validation must + // reject before any kernel mkdir call lands. + const linkInsideJail = join(JAIL_ROOT, `escape-${escapeId}`); + try { rmSync(linkInsideJail, { force: true, recursive: false }); } catch { /* ok */ } + symlinkSync(externalDir, linkInsideJail); + + const escapePath = join(linkInsideJail, escapeName); + + let rejected = false; + let observed = ''; + try { + await fsWriteStream(ctx, { + path: escapePath, + mode: '0644', + parents: true, + bytes: 'should-be-rejected-via-parents-true\n', + }); + } catch (e: any) { + rejected = true; + observed = e?.message ?? String(e); + } + + expect( + rejected, + `regression: parents:true write to symlinked-out-of-jail path was accepted`, + ); + expect( + observed.includes('S215'), + `regression: wrong rejection code (want S215, got: ${observed})`, + ); + expect( + !existsSync(externalTarget), + `regression: bytes still landed outside the jail at ${externalTarget}`, + ); + expect( + !existsSync(externalDir), + `regression: defense-in-depth let create_dir_all create ${externalDir} outside the jail`, + ); + + try { rmSync(externalDir, { recursive: true, force: true }); } catch { /* ok */ } + try { rmSync(linkInsideJail, { force: true }); } catch { /* ok */ } + }, + }, +]; diff --git a/shell/tests/e2e/workers/harness/src/cases-vuln-repro.ts b/shell/tests/e2e/workers/harness/src/cases-vuln-repro.ts new file mode 100644 index 0000000000..2d599bc098 --- /dev/null +++ b/shell/tests/e2e/workers/harness/src/cases-vuln-repro.ts @@ -0,0 +1,248 @@ +// Regression tests for the security findings raised in the +// `feat/shell-e2e-harness` review. Originally these cases passed +// _because the vuln was present_; after the fix they assert the +// post-fix behavior (error code, redaction, opt-in flag). Names start +// with `vuln_repro_` for grep continuity. +// +// Coverage in this file (default config = host_root: null, +// allow_unjailed: true): +// - S-H1: chmod -R no longer rewrites symlink targets +// - S-H2: unjailed mode requires explicit allow_unjailed: true (the +// test config sets it, demonstrating the opt-in surface) +// - S-H3: denylist is advisory; literal-form patterns reject, but +// trivially-bypassable. Documented as such — the test pins both +// halves of the contract. +// - S-H4: shell::list returns summary records only; argv/stdout are +// reachable only via shell::status (the UUID is the cap). +// +// S-C1 (symlink-parent escape) is tested in `cases-vuln-repro-jailed.ts` +// against `config-jailed.yaml` (host_root set). + +import { mkdtempSync, mkdirSync, writeFileSync, statSync, symlinkSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { expect, expectEqual, type CaseContext, type TestCase } from './cases.ts'; +import { fsReadStream, fsWriteStream } from './cases-fs-host.ts'; + +function newWorkdir(prefix: string): string { + return mkdtempSync(join(tmpdir(), `iii-shell-vuln-${prefix}-`)); +} + +function modeOctal(path: string): string { + return (statSync(path).mode & 0o777).toString(8).padStart(3, '0'); +} + +export const VULN_REPRO_CASES: TestCase[] = [ + { + // S-H1: shell/src/fs/host.rs (chmod handler). + // Fix: the recursive walk skips entries whose file_type is a + // symlink, so chmod(2)/chown(2) never deref to a target outside + // the walk root. The link itself remains untouched (lchmod isn't + // portable; leaving the link mode alone is the safe default). + // Companion case below pins the related "walk root is a symlink" + // surprise: that one returns S212 instead of a silent no-op. + name: 'vuln_repro_h1_chmod_recursive_skips_symlinks', + async run(ctx: CaseContext) { + const root = newWorkdir('h1'); + const sub = join(root, 'sub'); + const target = join(newWorkdir('h1-target'), 'external-file'); + mkdirSync(sub, { recursive: true }); + writeFileSync(target, 'guarded\n', { mode: 0o600 }); + symlinkSync(target, join(sub, 'link-to-external')); + + const before = modeOctal(target); + expectEqual(before, '600', 'precondition: external target starts at 0600'); + + await ctx.call('shell::fs::chmod', { + path: sub, + mode: '0777', + recursive: true, + }); + + const after = modeOctal(target); + // FIX: external target's mode unchanged because the recursive + // walk skipped the symlink entry. A regression here means the + // skip-symlinks branch in chmod() was reverted or weakened. + expectEqual( + after, + '600', + `regression: external target mode changed via symlink (got ${after}, must stay 600)`, + ); + rmSync(root, { recursive: true, force: true }); + rmSync(target, { force: true }); + }, + }, + { + // S-H1 follow-up: walk root is itself a symlink. Without the + // explicit S212, the per-entry skip-symlink branch would silently + // skip the only entry walkdir yields and the call would return + // {updated: 0} masquerading as success. The fix in chmod() checks + // symlink_metadata on the root before entering the loop and + // refuses with S212 (wrong file type). + name: 'vuln_repro_h1b_chmod_recursive_on_symlink_root_returns_s212', + async run(ctx: CaseContext) { + const root = newWorkdir('h1b'); + const targetDir = newWorkdir('h1b-target'); + const link = join(root, 'link-to-dir'); + symlinkSync(targetDir, link); + + let observed = ''; + let rejected = false; + try { + await ctx.call('shell::fs::chmod', { + path: link, + mode: '0777', + recursive: true, + }); + } catch (e: any) { + rejected = true; + observed = e?.message ?? String(e); + } + expect( + rejected, + `regression: recursive chmod on a symlink path silently succeeded`, + ); + expect( + observed.includes('S212'), + `regression: wrong rejection code (want S212, got: ${observed})`, + ); + rmSync(root, { recursive: true, force: true }); + rmSync(targetDir, { recursive: true, force: true }); + }, + }, + { + // S-H2: shell/src/config.rs (validate_fs_jail). + // Fix: the worker refuses to start when host_root is null AND + // fs.allow_unjailed is false. The test config sets allow_unjailed: + // true (test harness writes to OS tmpdirs scattered across the + // FS), so the writes-anywhere behavior is preserved as the + // explicit opt-in. Verifying the refuse-to-start path requires a + // negative startup test — covered in unit tests via + // ShellConfig::validate_fs_jail. Here we just confirm the opt-in + // surface still functions. + name: 'vuln_repro_h2_unjailed_mode_is_opt_in_via_allow_unjailed', + async run(ctx: CaseContext) { + const path = `/var/tmp/iii-shell-vuln-h2-${randomUUID()}`; + + const writeRes = await fsWriteStream(ctx, { + path, + mode: '0644', + bytes: 'unjailed-by-explicit-opt-in\n', + }); + expect( + writeRes.bytes_written > 0, + `regression: opt-in unjailed write failed (path=${path}, got=${writeRes.bytes_written})`, + ); + + const readRes = await fsReadStream(ctx, { path }); + expectEqual( + readRes.data.toString('utf8'), + 'unjailed-by-explicit-opt-in\n', + 'round-trip', + ); + + rmSync(path, { force: true }); + }, + }, + { + // S-H3: shell/src/config.rs:148-171 + shell/config.yaml + README.md. + // The denylist is advisory; the README and the shipped config + // comment now state this explicitly. We pin both halves of the + // contract so a future "let's tighten it" attempt is forced to + // either invalidate this test or actually upgrade the boundary. + name: 'vuln_repro_h3_denylist_is_advisory_literal_match_rejects_construction_bypasses', + async run(ctx: CaseContext) { + // (a) Literal form is still rejected — the tripwire works for + // honest mistakes. + await ctx.expectError( + () => + ctx.call('shell::exec', { + command: 'echo', + args: ['harness_denylist_marker'], + }), + 'denylist', + ); + + // (b) Bypass via shell-variable construction still works, + // because regex against argv.join(" ") was never a real + // boundary. If a future change blocks this, the README's + // advisory-only framing is also stale and needs updating. + const r = await ctx.call('shell::exec', { + command: 'sh', + args: ['-c', 'h=harness;d=denylist_marker;echo ${h}_${d}'], + }); + expectEqual(r.exit_code, 0, 'bypass exec succeeded'); + expectEqual( + r.stdout, + 'harness_denylist_marker\n', + 'regression-or-rewrite: denylist behavior changed; reconcile README + this test', + ); + }, + }, + { + // S-H4: shell/src/jobs.rs + shell/src/functions/list.rs + + // shell/src/functions/types.rs. + // Fix: shell::list returns JobSummary records (id, status, + // timestamps, exit_code, *_truncated) — argv/stdout/stderr + // omitted. The full record stays reachable through + // shell::status , where the UUID job_id is the + // unguessable capability for that record. + name: 'vuln_repro_h4_list_redacts_argv_and_stdout_status_keeps_full_record', + async run(ctx: CaseContext) { + const fakeToken = `vuln-repro-h4-${randomUUID()}`; + const spawned = await ctx.call('shell::exec_bg', { + command: 'sh', + args: ['-c', `printf '%s\\n' "${fakeToken}"`], + }); + const jobId = spawned.job_id as string; + + let finished = false; + for (let i = 0; i < 20 && !finished; i++) { + await ctx.sleep(50); + const s = await ctx.call('shell::status', { job_id: jobId }); + finished = s?.job?.status === 'finished'; + } + expect(finished, 'job finished within 1s'); + + const list = await ctx.call('shell::list', {}); + const records = (list.jobs ?? []) as Array>; + const ours = records.find((j) => j.id === jobId); + expect(!!ours, 'spawned job present in list'); + + // FIX assertions — argv and stdout/stderr must NOT appear in + // the summary shape. If a regression re-adds them, the + // structural cross-caller leak is back. + expect( + ours!.argv === undefined, + `regression: shell::list still leaks argv (got ${JSON.stringify(ours!.argv)})`, + ); + expect( + ours!.stdout === undefined, + `regression: shell::list still leaks stdout (got ${JSON.stringify(ours!.stdout)})`, + ); + expect( + ours!.stderr === undefined, + `regression: shell::list still leaks stderr (got ${JSON.stringify(ours!.stderr)})`, + ); + + // Useful summary fields stay visible. + expectEqual(ours!.status, 'finished', 'summary keeps status'); + expectEqual(ours!.exit_code, 0, 'summary keeps exit_code'); + expect(typeof ours!.started_at_ms === 'number', 'summary keeps started_at_ms'); + + // Full record (with argv + stdout) still reachable via + // shell::status using the job_id capability. + const status = await ctx.call('shell::status', { job_id: jobId }); + const job = status.job; + expect( + Array.isArray(job.argv) && JSON.stringify(job.argv).includes(fakeToken), + `shell::status returns argv with the token (cap-gated by job_id)`, + ); + expect( + String(job.stdout ?? '').includes(fakeToken), + `shell::status returns full stdout (cap-gated by job_id)`, + ); + }, + }, +]; diff --git a/shell/tests/e2e/workers/harness/src/cases.ts b/shell/tests/e2e/workers/harness/src/cases.ts new file mode 100644 index 0000000000..d6c6808a5c --- /dev/null +++ b/shell/tests/e2e/workers/harness/src/cases.ts @@ -0,0 +1,82 @@ +import type { ISdk } from 'iii-sdk'; + +export interface CaseContext { + call: (functionId: string, payload: unknown) => Promise; + sleep: (ms: number) => Promise; + expectError: (fn: () => Promise, substring: string) => Promise; + iii: ISdk; +} + +export interface TestCase { + name: string; + run(ctx: CaseContext): Promise; +} + +export function expect(cond: boolean, msg: string): asserts cond { + if (!cond) throw new Error(msg); +} + +export function expectEqual(actual: unknown, expected: unknown, msg: string): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`${msg}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +export const FUNCTION_CASES: TestCase[] = [ + { + name: 'exec echo hi', + async run({ call }) { + const r = await call('shell::exec', { command: 'echo', args: ['hi'] }); + expectEqual(r.exit_code, 0, 'exit_code'); + expectEqual(r.stdout, 'hi\n', 'stdout'); + expectEqual(r.stderr, '', 'stderr'); + expectEqual(r.timed_out, false, 'timed_out'); + expectEqual(r.stdout_truncated, false, 'stdout_truncated'); + expectEqual(r.stderr_truncated, false, 'stderr_truncated'); + }, + }, + { + name: 'exec parses shell-words when args omitted', + async run({ call }) { + const r = await call('shell::exec', { command: 'echo "a b"' }); + expectEqual(r.exit_code, 0, 'exit_code'); + expectEqual(r.stdout, 'a b\n', 'stdout'); + }, + }, + { + name: 'exec uses args field verbatim', + async run({ call }) { + const r = await call('shell::exec', { command: 'echo', args: ['a', 'b'] }); + expectEqual(r.exit_code, 0, 'exit_code'); + expectEqual(r.stdout, 'a b\n', 'stdout'); + }, + }, + { + name: 'exec propagates non-zero exit code', + async run({ call }) { + const r = await call('shell::exec', { command: 'false' }); + expectEqual(r.exit_code, 1, 'exit_code from `false`'); + expectEqual(r.timed_out, false, 'timed_out'); + }, + }, + { + name: 'exec captures stderr separately from stdout', + async run({ call }) { + const r = await call('shell::exec', { + command: 'sh', + args: ['-c', 'echo err 1>&2'], + }); + expectEqual(r.exit_code, 0, 'exit_code'); + expectEqual(r.stdout, '', 'stdout empty'); + expectEqual(r.stderr, 'err\n', 'stderr captured'); + }, + }, + { + name: 'exec returns finite duration_ms', + async run({ call }) { + const r = await call('shell::exec', { command: 'echo', args: ['x'] }); + expect(typeof r.duration_ms === 'number', 'duration_ms is number'); + expect(Number.isFinite(r.duration_ms) && r.duration_ms >= 0, 'duration_ms >= 0'); + }, + }, +]; diff --git a/shell/tests/e2e/workers/harness/src/runner.ts b/shell/tests/e2e/workers/harness/src/runner.ts new file mode 100644 index 0000000000..c5c3ff0875 --- /dev/null +++ b/shell/tests/e2e/workers/harness/src/runner.ts @@ -0,0 +1,152 @@ +import { writeFileSync, mkdirSync } from 'node:fs'; +import { resolve } from 'node:path'; +import type { ISdk } from 'iii-sdk'; +import { FUNCTION_CASES, type CaseContext, type TestCase } from './cases.ts'; +import { SAFETY_CASES } from './cases-safety.ts'; +import { JOB_CASES } from './cases-jobs.ts'; +import { EDGE_CASES } from './cases-edge.ts'; +import { FS_HOST_CASES } from './cases-fs-host.ts'; +import { FS_HOST_JAIL_CASES } from './cases-fs-host-jail.ts'; +import { FS_SANDBOX_CASES } from './cases-fs-sandbox.ts'; +import { FS_PROTOCOL_BREAK_CASES } from './cases-fs-protocol-break.ts'; +import { STREAMING_BREAK_CASES } from './cases-streaming-break.ts'; +import { EXEC_BREAK_CASES } from './cases-exec-break.ts'; +import { JOB_BREAK_CASES } from './cases-jobs-break.ts'; +import { FS_ENCODING_CASES } from './cases-fs-encoding.ts'; +import { FS_ERROR_CASES } from './cases-fs-errors.ts'; +import { CONCURRENCY_CASES } from './cases-concurrency.ts'; +import { SANDBOX_BREAK_CASES } from './cases-sandbox-break.ts'; +import { VULN_REPRO_CASES } from './cases-vuln-repro.ts'; +import { VULN_REPRO_JAILED_CASES } from './cases-vuln-repro-jailed.ts'; + +interface CaseResult { + case: string; + status: 'PASS' | 'FAIL'; + error?: string; + duration_ms: number; +} + +export interface RunnerOptions { + iii: ISdk; + reportPath: string; +} + +export class Runner { + constructor(private opts: RunnerOptions) {} + + private async call(functionId: string, payload: unknown): Promise { + return await this.opts.iii.trigger({ function_id: functionId, payload }); + } + + private sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); + } + + private async expectError(fn: () => Promise, substring: string): Promise { + try { + await fn(); + } catch (e: any) { + const msg = e?.message ?? String(e); + if (!msg.includes(substring)) { + throw new Error(`expected error containing "${substring}", got: ${msg}`); + } + return; + } + throw new Error(`expected throw containing "${substring}", but call resolved`); + } + + private async runCase(c: TestCase): Promise { + const start = Date.now(); + const ctx: CaseContext = { + call: (id, payload) => this.call(id, payload), + sleep: (ms) => this.sleep(ms), + expectError: (fn, substring) => this.expectError(fn, substring), + iii: this.opts.iii, + }; + try { + await c.run(ctx); + return { case: c.name, status: 'PASS', duration_ms: Date.now() - start }; + } catch (e: any) { + return { + case: c.name, + status: 'FAIL', + error: e?.message ?? String(e), + duration_ms: Date.now() - start, + }; + } + } + + private async waitForWorker(): Promise { + const deadline = Date.now() + 30_000; + let lastErr: unknown; + while (Date.now() < deadline) { + try { + await this.call('shell::exec', { command: 'echo', args: ['ready'] }); + return; + } catch (e) { + lastErr = e; + await this.sleep(200); + } + } + throw new Error(`shell worker not reachable within 30s: ${lastErr}`); + } + + async runAll(): Promise<{ pass: number; total: number; results: CaseResult[] }> { + await this.waitForWorker(); + + const useColor = process.stdout.isTTY === true; + const GREEN = useColor ? '\x1b[32m' : ''; + const RED = useColor ? '\x1b[31m' : ''; + const RESET = useColor ? '\x1b[0m' : ''; + + const results: CaseResult[] = []; + const record = (r: CaseResult): CaseResult => { + const color = r.status === 'PASS' ? GREEN : RED; + const err = r.error ? ' — ' + r.error : ''; + console.log(`[harness] ${color}${r.status}${RESET} :: ${r.case} (${r.duration_ms}ms)${err}`); + results.push(r); + return r; + }; + + // HARNESS_SUITE selects which case set runs. `default` (no var + // set) is the full unjailed suite + the unjailed vuln repros. + // `jailed` runs ONLY the symlink-jail-escape repro against an + // engine started with config-jailed.yaml — the rest of the suite + // assumes host_root: null and would mis-fail there. + const suite = process.env.HARNESS_SUITE ?? 'default'; + const allCases: TestCase[] = + suite === 'jailed' + ? [...VULN_REPRO_JAILED_CASES] + : [ + ...FUNCTION_CASES, + ...SAFETY_CASES, + ...JOB_CASES, + ...EDGE_CASES, + ...FS_HOST_CASES, + ...FS_HOST_JAIL_CASES, + ...FS_SANDBOX_CASES, + ...FS_PROTOCOL_BREAK_CASES, + ...STREAMING_BREAK_CASES, + ...EXEC_BREAK_CASES, + ...JOB_BREAK_CASES, + ...FS_ENCODING_CASES, + ...FS_ERROR_CASES, + ...CONCURRENCY_CASES, + ...SANDBOX_BREAK_CASES, + ...VULN_REPRO_CASES, + ]; + for (const c of allCases) { + record(await this.runCase(c)); + } + + const pass = results.filter((r) => r.status === 'PASS').length; + + mkdirSync(resolve(this.opts.reportPath, '..'), { recursive: true }); + writeFileSync( + this.opts.reportPath, + JSON.stringify({ pass, total: results.length, results }, null, 2), + ); + + return { pass, total: results.length, results }; + } +} diff --git a/shell/tests/e2e/workers/harness/src/worker.ts b/shell/tests/e2e/workers/harness/src/worker.ts new file mode 100644 index 0000000000..202fdfa003 --- /dev/null +++ b/shell/tests/e2e/workers/harness/src/worker.ts @@ -0,0 +1,43 @@ +import { registerWorker, Logger } from 'iii-sdk'; +import { resolve } from 'node:path'; +import { Runner } from './runner.ts'; +import { setupSandboxMocks } from './cases-fs-sandbox.ts'; + +const URL = process.env.III_URL ?? 'ws://127.0.0.1:49134'; +const REPORT_PATH = resolve(process.env.HARNESS_REPORT_PATH ?? './reports/report.json'); + +const iii = registerWorker(URL); +const logger = new Logger(); + +process.on('unhandledRejection', (reason) => { + const msg = (reason as { message?: string })?.message ?? String(reason); + logger.warn('unhandledRejection (suppressed by harness)', { reason: msg }); +}); + +setupSandboxMocks(iii); + +const runner = new Runner({ iii, reportPath: REPORT_PATH }); + +logger.info('harness: registered, kicking off suite', { url: URL, reportPath: REPORT_PATH }); + +(async () => { + const useColor = process.stdout.isTTY === true; + const GREEN = useColor ? '\x1b[32m' : ''; + const RED = useColor ? '\x1b[31m' : ''; + const RESET = useColor ? '\x1b[0m' : ''; + let exitCode = 1; + try { + const { pass, total } = await runner.runAll(); + const status = pass === total ? 'PASS' : 'FAIL'; + const color = status === 'PASS' ? GREEN : RED; + console.log(`HARNESS_DONE: ${color}${status}${RESET} ${pass}/${total}`); + exitCode = status === 'PASS' ? 0 : 1; + } catch (e: any) { + console.error('[harness] fatal:', e?.stack ?? e); + console.log(`HARNESS_DONE: ${RED}FAIL${RESET} 0/0`); + exitCode = 1; + } + await new Promise((r) => setTimeout(r, 200)); + await iii.shutdown(); + process.exit(exitCode); +})(); diff --git a/shell/tests/e2e/workers/harness/tsconfig.json b/shell/tests/e2e/workers/harness/tsconfig.json new file mode 100644 index 0000000000..91c7e3885f --- /dev/null +++ b/shell/tests/e2e/workers/harness/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/shell/tests/function_handlers.rs b/shell/tests/function_handlers.rs new file mode 100644 index 0000000000..3a97460f3c --- /dev/null +++ b/shell/tests/function_handlers.rs @@ -0,0 +1,470 @@ +//! Integration tests for the typed `functions::*::handle` async fns. + +use std::sync::Arc; + +use iii_sdk::III; +use serde_json::{json, Value}; + +use iii_shell::config::ShellConfig; +use iii_shell::functions; +use iii_shell::functions::types::{KillRequest, StatusRequest}; +use iii_shell::jobs::{self, now_ms, JobHandle, JobRecord, JobStatus}; + +async fn seed(handle: JobHandle) -> String { + match jobs::try_reserve_and_insert(handle, usize::MAX).await { + Ok(id) => id, + Err(_) => panic!("usize::MAX cap must always accept"), + } +} + +fn cfg_with_allow(allow: &[&str]) -> Arc { + let mut c = ShellConfig { + allowlist: allow.iter().map(|s| s.to_string()).collect(), + max_timeout_ms: 5000, + default_timeout_ms: 1500, + max_output_bytes: 4096, + ..Default::default() + }; + c.compile_denylist().unwrap(); + Arc::new(c) +} + +fn fresh_iii() -> III { + III::new("ws://stub-not-connected:0") +} + +fn tmpdir(prefix: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("iii-shell-fn-{}-{}", prefix, uuid::Uuid::new_v4())); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn typed(v: Value) -> T { + serde_json::from_value(v).expect("test fixture must match the typed shape") +} + +fn resp(t: T) -> Value { + serde_json::to_value(t).expect("typed response must serialize") +} + +#[tokio::test] +async fn exec_handler_runs_allowlisted_command() { + let cfg = cfg_with_allow(&["echo"]); + let r = resp( + functions::exec::handle(cfg, json!({"command": "echo", "args": ["ok"]})) + .await + .unwrap(), + ); + assert_eq!(r["exit_code"], 0); + assert_eq!(r["stdout"], "ok\n"); + assert_eq!(r["timed_out"], false); +} + +#[tokio::test] +async fn exec_handler_rejects_missing_command() { + let cfg = cfg_with_allow(&["echo"]); + let err = functions::exec::handle(cfg, json!({"args": ["ok"]})) + .await + .unwrap_err(); + assert!(err.contains("missing 'command'"), "got: {err}"); +} + +#[tokio::test] +async fn exec_handler_rejects_unlisted_command() { + let cfg = cfg_with_allow(&["echo"]); + let err = functions::exec::handle(cfg, json!({"command": "nmap", "args": ["-v"]})) + .await + .unwrap_err(); + assert!(err.contains("allowlist")); +} + +#[tokio::test] +async fn exec_handler_rejects_non_string_arg() { + let cfg = cfg_with_allow(&["echo"]); + let err = functions::exec::handle(cfg, json!({"command": "echo", "args": ["a", 5]})) + .await + .unwrap_err(); + assert!(err.contains("must be a string"), "got: {err}"); +} + +#[tokio::test] +async fn exec_handler_returns_truncated_flag_at_max_output_bytes() { + let cfg = cfg_with_allow(&["sh"]); + let r = resp( + functions::exec::handle( + cfg, + json!({ + "command": "sh", + "args": ["-c", "printf 'x%.0s' $(seq 1 8000)"], + }), + ) + .await + .unwrap(), + ); + assert_eq!(r["exit_code"], 0); + assert_eq!(r["stdout_truncated"], true); + assert_eq!(r["stdout"].as_str().unwrap().len(), 4096); +} + +#[tokio::test] +async fn exec_bg_handler_spawns_returns_job_id_and_argv() { + let cfg = cfg_with_allow(&["sleep"]); + let r = resp( + functions::exec_bg::handle(cfg, json!({"command": "sleep", "args": ["0.1"]})) + .await + .unwrap(), + ); + assert!(r["job_id"].is_string()); + assert_eq!(r["argv"], json!(["sleep", "0.1"])); + tokio::time::sleep(std::time::Duration::from_millis(400)).await; +} + +#[tokio::test] +async fn exec_bg_handler_rejects_non_string_arg() { + let cfg = cfg_with_allow(&["echo"]); + let err = functions::exec_bg::handle(cfg, json!({"command": "echo", "args": [42]})) + .await + .unwrap_err(); + assert!(err.contains("must be a string"), "got: {err}"); +} + +#[tokio::test] +async fn status_handler_returns_record_for_inserted_job() { + let id = "fn-status-handler-test-1"; + seed(JobHandle { + record: JobRecord { + id: id.into(), + argv: vec!["echo".into()], + started_at_ms: now_ms(), + finished_at_ms: None, + status: JobStatus::Running, + exit_code: None, + stdout: String::new(), + stderr: String::new(), + stdout_truncated: false, + stderr_truncated: false, + }, + child: None, + }) + .await; + let r = resp( + functions::status::handle(typed::(json!({"job_id": id}))) + .await + .unwrap(), + ); + assert_eq!(r["job"]["id"], id); + assert_eq!(r["job"]["status"], "running"); +} + +#[tokio::test] +async fn status_handler_rejects_unknown_job_id() { + let err = functions::status::handle(typed::( + json!({"job_id": "fn-status-never-existed"}), + )) + .await + .unwrap_err(); + assert!(err.contains("no such job")); +} + +#[tokio::test] +async fn status_handler_rejects_missing_job_id() { + let r: Result = serde_json::from_value(json!({})); + assert!(r.is_err()); +} + +#[tokio::test] +async fn kill_handler_rejects_unknown_job_id() { + let err = functions::kill::handle(typed::( + json!({"job_id": "fn-kill-never-existed"}), + )) + .await + .unwrap_err(); + assert!(err.contains("no such job")); +} + +#[tokio::test] +async fn kill_handler_returns_killed_false_when_job_already_terminal() { + let id = "fn-kill-handler-finished"; + seed(JobHandle { + record: JobRecord { + id: id.into(), + argv: vec!["echo".into()], + started_at_ms: now_ms(), + finished_at_ms: Some(now_ms()), + status: JobStatus::Finished, + exit_code: Some(0), + stdout: String::new(), + stderr: String::new(), + stdout_truncated: false, + stderr_truncated: false, + }, + child: None, + }) + .await; + let r = resp( + functions::kill::handle(typed::(json!({"job_id": id}))) + .await + .unwrap(), + ); + assert_eq!(r["killed"], false); + assert_eq!(r["status"], "finished"); + assert!(r["reason"].is_string()); +} + +#[tokio::test] +async fn list_handler_returns_jobs_array_and_count() { + let id = "fn-list-handler-marker"; + seed(JobHandle { + record: JobRecord { + id: id.into(), + argv: vec!["sleep".into(), "0.01".into()], + started_at_ms: now_ms(), + finished_at_ms: None, + status: JobStatus::Running, + exit_code: None, + stdout: String::new(), + stderr: String::new(), + stdout_truncated: false, + stderr_truncated: false, + }, + child: None, + }) + .await; + let r = resp( + functions::list::handle(cfg_with_allow(&["sleep"])) + .await + .unwrap(), + ); + let jobs_arr = r["jobs"].as_array().unwrap(); + assert!( + jobs_arr.iter().any(|j| j["id"] == id), + "list missing {id}: {jobs_arr:?}", + ); + assert!(r["count"].is_number()); +} + +fn fs_host_backend() -> Arc { + use std::fmt; + + #[derive(Debug)] + struct StubChan; + #[async_trait::async_trait] + impl iii_shell::fs::host::ChannelMaker for StubChan { + async fn create_channel(&self, _: usize) -> Result { + Err(iii_sdk::IIIError::Handler("stub channel".into())) + } + fn engine_address(&self) -> String { + "ws://stub:0".into() + } + } + impl fmt::Display for StubChan { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "StubChan") + } + } + + Arc::new(iii_shell::fs::host::HostFsBackend::new( + Arc::new(iii_shell::fs::host::HostFsConfig::default()), + Arc::new(StubChan), + )) +} + +#[tokio::test] +async fn fs_ls_handler_lists_directory_entries() { + let root = tmpdir("fn-fs-ls"); + std::fs::write(root.join("a.txt"), b"hi").unwrap(); + let r = resp( + functions::fs_ls::handle( + fs_host_backend(), + fresh_iii(), + true, + json!({"path": root.to_string_lossy()}), + ) + .await + .unwrap(), + ); + let entries = r["entries"].as_array().unwrap(); + assert!(entries.iter().any(|e| e["name"] == "a.txt")); +} + +#[tokio::test] +async fn fs_stat_handler_returns_entry_shape() { + let root = tmpdir("fn-fs-stat"); + let f = root.join("a.txt"); + std::fs::write(&f, b"hello").unwrap(); + let r = resp( + functions::fs_stat::handle( + fs_host_backend(), + fresh_iii(), + true, + json!({"path": f.to_string_lossy()}), + ) + .await + .unwrap(), + ); + assert_eq!(r["size"], 5); + assert_eq!(r["is_dir"], false); +} + +#[tokio::test] +async fn fs_mkdir_handler_creates_directory() { + let root = tmpdir("fn-fs-mkdir"); + let d = root.join("new"); + let r = resp( + functions::fs_mkdir::handle( + fs_host_backend(), + fresh_iii(), + true, + json!({"path": d.to_string_lossy(), "mode": "0755"}), + ) + .await + .unwrap(), + ); + assert_eq!(r["created"], true); + assert!(d.is_dir()); +} + +#[tokio::test] +async fn fs_rm_handler_removes_file() { + let root = tmpdir("fn-fs-rm"); + let f = root.join("doomed.txt"); + std::fs::write(&f, b"x").unwrap(); + let r = resp( + functions::fs_rm::handle( + fs_host_backend(), + fresh_iii(), + true, + json!({"path": f.to_string_lossy()}), + ) + .await + .unwrap(), + ); + assert_eq!(r["removed"], true); + assert!(!f.exists()); +} + +#[tokio::test] +async fn fs_chmod_handler_sets_mode() { + use std::os::unix::fs::PermissionsExt; + let root = tmpdir("fn-fs-chmod"); + let f = root.join("a.txt"); + std::fs::write(&f, b"x").unwrap(); + let r = resp( + functions::fs_chmod::handle( + fs_host_backend(), + fresh_iii(), + true, + json!({"path": f.to_string_lossy(), "mode": "0640"}), + ) + .await + .unwrap(), + ); + assert!(r["updated"].as_u64().unwrap() >= 1); + let mode = std::fs::metadata(&f).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o640); +} + +#[tokio::test] +async fn fs_mv_handler_renames() { + let root = tmpdir("fn-fs-mv"); + let src = root.join("src.txt"); + let dst = root.join("dst.txt"); + std::fs::write(&src, b"x").unwrap(); + let r = resp( + functions::fs_mv::handle( + fs_host_backend(), + fresh_iii(), + true, + json!({ + "src": src.to_string_lossy(), + "dst": dst.to_string_lossy(), + }), + ) + .await + .unwrap(), + ); + assert_eq!(r["moved"], true); + assert!(dst.exists() && !src.exists()); +} + +#[tokio::test] +async fn fs_grep_handler_finds_matches() { + let root = tmpdir("fn-fs-grep"); + std::fs::write(root.join("a.txt"), b"line one\nfind me\nline three\n").unwrap(); + let r = resp( + functions::fs_grep::handle( + fs_host_backend(), + fresh_iii(), + true, + json!({ + "path": root.to_string_lossy(), + "pattern": "find me", + "recursive": true, + "ignore_case": false, + "include_glob": [], + "exclude_glob": [], + "max_matches": 10, + "max_line_bytes": 4096, + }), + ) + .await + .unwrap(), + ); + let matches = r["matches"].as_array().unwrap(); + assert_eq!(matches.len(), 1); + assert!(matches[0]["content"].as_str().unwrap().contains("find me")); +} + +#[tokio::test] +async fn fs_sed_handler_replaces_in_files() { + let root = tmpdir("fn-fs-sed"); + let f = root.join("a.txt"); + std::fs::write(&f, b"foo bar foo\n").unwrap(); + let r = resp( + functions::fs_sed::handle( + fs_host_backend(), + fresh_iii(), + true, + json!({ + "files": [f.to_string_lossy()], + "recursive": false, + "include_glob": [], + "exclude_glob": [], + "pattern": "foo", + "replacement": "qux", + "regex": false, + "first_only": false, + "ignore_case": false, + }), + ) + .await + .unwrap(), + ); + assert_eq!(r["total_replacements"], 2); + assert_eq!(std::fs::read_to_string(&f).unwrap(), "qux bar qux\n"); +} + +#[tokio::test] +async fn fs_dispatch_split_target_rejects_unknown_kind() { + let err = functions::fs_ls::handle( + fs_host_backend(), + fresh_iii(), + true, + json!({"target": {"kind": "not_real"}, "path": "/tmp"}), + ) + .await + .unwrap_err(); + assert!(err.contains("S210"), "got: {err}"); +} + +#[tokio::test] +async fn fs_handler_rejects_bad_payload_shape() { + // path must be a string. Hits the S210 mapping in fs_ls::handle. + let err = functions::fs_ls::handle(fs_host_backend(), fresh_iii(), true, json!({"path": 42})) + .await + .unwrap_err(); + assert!(err.contains("S210"), "got: {err}"); +} + +#[allow(dead_code)] +fn _unused_value_marker(_: Value) {} diff --git a/shell/tests/host_fs_branches.rs b/shell/tests/host_fs_branches.rs new file mode 100644 index 0000000000..ba69590726 --- /dev/null +++ b/shell/tests/host_fs_branches.rs @@ -0,0 +1,279 @@ +//! Integration tests targeting under-covered branches in `src/fs/host.rs`. + +use std::sync::Arc; + +use async_trait::async_trait; +use iii_sdk::{Channel, IIIError}; + +use iii_shell::fs::host::{ChannelMaker, HostFsBackend, HostFsConfig}; +use iii_shell::fs::{ChmodArgs, FsBackend, GrepArgs, MkdirArgs, MvArgs, RmArgs, SedArgs, StatArgs}; + +#[derive(Debug)] +struct StubChan; + +#[async_trait] +impl ChannelMaker for StubChan { + async fn create_channel(&self, _: usize) -> Result { + Err(IIIError::Handler( + "stub channel maker — non-streaming tests only".into(), + )) + } + fn engine_address(&self) -> String { + "ws://stub:0".into() + } +} + +fn backend() -> HostFsBackend { + HostFsBackend::new(Arc::new(HostFsConfig::default()), Arc::new(StubChan)) +} + +fn tmpdir(prefix: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!( + "iii-shell-host-{}-{}", + prefix, + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&d).unwrap(); + d +} + +#[tokio::test] +async fn mkdir_parents_true_creates_deeply_nested_path() { + let root = tmpdir("mkdir-deep"); + let deep = root.join("a/b/c/d/e/leaf"); + let r = backend() + .mkdir(MkdirArgs { + path: deep.to_string_lossy().into_owned(), + mode: "0755".into(), + parents: true, + }) + .await + .expect("mkdir parents=true"); + assert!(r.created, "leaf created"); + assert!(deep.is_dir(), "leaf exists on disk"); + let again = backend() + .mkdir(MkdirArgs { + path: deep.to_string_lossy().into_owned(), + mode: "0755".into(), + parents: true, + }) + .await + .expect("idempotent re-mkdir"); + assert!(!again.created, "second mkdir reports not created"); +} + +#[tokio::test] +async fn mv_overwrite_true_replaces_existing_dst() { + let root = tmpdir("mv-over"); + let src = root.join("src.txt"); + let dst = root.join("dst.txt"); + std::fs::write(&src, b"new").unwrap(); + std::fs::write(&dst, b"old").unwrap(); + let r = backend() + .mv(MvArgs { + src: src.to_string_lossy().into_owned(), + dst: dst.to_string_lossy().into_owned(), + overwrite: true, + }) + .await + .expect("mv overwrite=true succeeds"); + assert!(r.moved); + assert!(!src.exists(), "src removed after rename"); + assert_eq!(std::fs::read(&dst).unwrap(), b"new"); +} + +#[tokio::test] +async fn chmod_recursive_walks_subtree_and_counts() { + let root = tmpdir("chmod-rec"); + std::fs::create_dir_all(root.join("sub")).unwrap(); + std::fs::write(root.join("a.txt"), b"a").unwrap(); + std::fs::write(root.join("sub/b.txt"), b"b").unwrap(); + let r = backend() + .chmod(ChmodArgs { + path: root.to_string_lossy().into_owned(), + // 0750 preserves +x on dirs so walkdir can descend. + mode: "0750".into(), + uid: None, + gid: None, + recursive: true, + }) + .await + .expect("chmod recursive succeeds"); + assert!( + r.updated >= 3, + "expected ≥3 paths walked, got {}", + r.updated + ); +} + +#[tokio::test] +async fn grep_include_glob_filters_paths() { + let root = tmpdir("grep-inc"); + std::fs::write(root.join("keep.rs"), b"needle\n").unwrap(); + std::fs::write(root.join("skip.txt"), b"needle\n").unwrap(); + let r = backend() + .grep(GrepArgs { + path: root.to_string_lossy().into_owned(), + pattern: "needle".into(), + recursive: true, + ignore_case: false, + include_glob: vec!["*.rs".into()], + exclude_glob: vec![], + max_matches: 100, + max_line_bytes: 4096, + }) + .await + .expect("grep with include_glob succeeds"); + assert_eq!(r.matches.len(), 1, "only the .rs file should match"); + assert!( + r.matches[0].path.ends_with("keep.rs"), + "matched path: {}", + r.matches[0].path, + ); +} + +#[tokio::test] +async fn grep_exclude_glob_skips_paths() { + let root = tmpdir("grep-exc"); + std::fs::write(root.join("a.log"), b"needle\n").unwrap(); + std::fs::write(root.join("b.log"), b"needle\n").unwrap(); + std::fs::write(root.join("c.txt"), b"needle\n").unwrap(); + let r = backend() + .grep(GrepArgs { + path: root.to_string_lossy().into_owned(), + pattern: "needle".into(), + recursive: true, + ignore_case: false, + include_glob: vec![], + exclude_glob: vec!["*.log".into()], + max_matches: 100, + max_line_bytes: 4096, + }) + .await + .expect("grep with exclude_glob succeeds"); + assert_eq!(r.matches.len(), 1, "only c.txt should match"); + assert!(r.matches[0].path.ends_with("c.txt")); +} + +#[tokio::test] +async fn sed_walks_directory_with_include_exclude_globs() { + let root = tmpdir("sed-glob"); + std::fs::create_dir_all(root.join("sub")).unwrap(); + std::fs::write(root.join("a.rs"), b"foo\n").unwrap(); + std::fs::write(root.join("b.rs"), b"foo\n").unwrap(); + std::fs::write(root.join("ignore.rs"), b"foo\n").unwrap(); + std::fs::write(root.join("sub/c.rs"), b"foo\n").unwrap(); + std::fs::write(root.join("d.txt"), b"foo\n").unwrap(); // wrong extension + + let r = backend() + .sed(SedArgs { + files: vec![], + path: Some(root.to_string_lossy().into_owned()), + recursive: true, + include_glob: vec!["*.rs".into()], + exclude_glob: vec!["ignore*".into()], + pattern: "foo".into(), + replacement: "bar".into(), + regex: false, + first_only: false, + ignore_case: false, + }) + .await + .expect("sed with globs succeeds"); + assert_eq!(r.results.len(), 3, "expected 3 .rs files post-filter"); + assert_eq!(r.total_replacements, 3); + for result in &r.results { + assert!(result.success, "sed succeeded on {}", result.path); + assert!(!result.path.ends_with("ignore.rs")); + assert!(result.path.ends_with(".rs")); + } +} + +#[tokio::test] +async fn sed_recursive_false_on_directory_rejected_with_s210() { + let root = tmpdir("sed-shallow"); + std::fs::write(root.join("top.rs"), b"foo\n").unwrap(); + let err = backend() + .sed(SedArgs { + files: vec![], + path: Some(root.to_string_lossy().into_owned()), + recursive: false, + include_glob: vec![], + exclude_glob: vec![], + pattern: "foo".into(), + replacement: "bar".into(), + regex: false, + first_only: false, + ignore_case: false, + }) + .await + .expect_err("recursive=false on dir must reject"); + assert_eq!(err.code, "S210", "got: {err:?}"); +} + +#[tokio::test] +async fn sed_first_only_replaces_just_the_first_match_per_line() { + let root = tmpdir("sed-first"); + let f = root.join("a.txt"); + std::fs::write(&f, b"foo foo foo\n").unwrap(); + let r = backend() + .sed(SedArgs { + files: vec![f.to_string_lossy().into_owned()], + path: None, + recursive: false, + include_glob: vec![], + exclude_glob: vec![], + pattern: "foo".into(), + replacement: "bar".into(), + regex: false, + first_only: true, + ignore_case: false, + }) + .await + .expect("sed first_only succeeds"); + assert_eq!(r.total_replacements, 1, "first_only stops at one per line"); + let content = std::fs::read_to_string(&f).unwrap(); + assert_eq!(content, "bar foo foo\n"); +} + +#[tokio::test] +async fn rm_recursive_true_removes_non_empty_dir() { + let root = tmpdir("rm-rec"); + let target = root.join("doomed"); + std::fs::create_dir_all(target.join("nested")).unwrap(); + std::fs::write(target.join("a.txt"), b"a").unwrap(); + std::fs::write(target.join("nested/b.txt"), b"b").unwrap(); + let r = backend() + .rm(RmArgs { + path: target.to_string_lossy().into_owned(), + recursive: true, + }) + .await + .expect("rm -r succeeds"); + assert!(r.removed); + assert!(!target.exists()); +} + +#[tokio::test] +async fn stat_reports_is_symlink_for_symlink_target() { + use std::os::unix::fs::symlink; + let root = tmpdir("stat-link"); + let target = root.join("target.txt"); + let link = root.join("link.txt"); + std::fs::write(&target, b"hello").unwrap(); + symlink(&target, &link).unwrap(); + let s = backend() + .stat(StatArgs { + path: link.to_string_lossy().into_owned(), + }) + .await + .expect("stat through symlink"); + // Worker's wire shape exposes both is_dir and is_symlink. With + // canonicalization, is_symlink may be false and the size matches the + // target. This locks in current behavior so a regression that + // changes follow-symlink semantics shows up. + assert!( + s.0.size > 0 || s.0.is_symlink, + "symlink stat reports either size or is_symlink", + ); +} diff --git a/shell/tests/jobs_lifecycle.rs b/shell/tests/jobs_lifecycle.rs new file mode 100644 index 0000000000..34270de7d9 --- /dev/null +++ b/shell/tests/jobs_lifecycle.rs @@ -0,0 +1,183 @@ +//! Integration tests for the jobs lifecycle (`list_all`, `running_count`, +//! `remove_old`). Each test uses a unique ID prefix to avoid collisions in +//! the global `JOBS` map shared across the integration-test binary. + +use iii_shell::jobs::{self, now_ms, JobHandle, JobRecord, JobStatus}; + +async fn seed(handle: JobHandle) -> String { + match jobs::try_reserve_and_insert(handle, usize::MAX).await { + Ok(id) => id, + Err(_) => panic!("usize::MAX cap must always accept"), + } +} + +fn rec(id: &str, status: JobStatus, finished_at_ms: Option) -> JobRecord { + JobRecord { + id: id.to_string(), + argv: vec!["echo".to_string(), id.to_string()], + started_at_ms: now_ms(), + finished_at_ms, + status, + exit_code: None, + stdout: String::new(), + stderr: String::new(), + stdout_truncated: false, + stderr_truncated: false, + } +} + +#[tokio::test] +async fn now_ms_returns_a_recent_unix_ms() { + let t = now_ms(); + assert!(t > 1_704_067_200_000, "now_ms looks pre-2024: {t}"); +} + +#[tokio::test] +async fn insert_then_get_round_trips_the_record() { + let id = "lifecycle-insert-get"; + seed(JobHandle { + record: rec(id, JobStatus::Running, None), + child: None, + }) + .await; + let h = jobs::get(id).await.expect("inserted job exists"); + let got = h.lock().await.record.clone(); + assert_eq!(got.id, id); + assert_eq!(got.status, JobStatus::Running); + assert_eq!(got.argv, vec!["echo".to_string(), id.to_string()]); +} + +#[tokio::test] +async fn get_returns_none_for_unknown_id() { + let h = jobs::get("never-inserted-9876").await; + assert!(h.is_none()); +} + +#[tokio::test] +async fn list_all_includes_inserted_jobs() { + let id1 = "lifecycle-list-all-1"; + let id2 = "lifecycle-list-all-2"; + seed(JobHandle { + record: rec(id1, JobStatus::Running, None), + child: None, + }) + .await; + seed(JobHandle { + record: rec(id2, JobStatus::Finished, Some(now_ms())), + child: None, + }) + .await; + let all = jobs::list_all().await; + let ids: Vec<&str> = all.iter().map(|r| r.id.as_str()).collect(); + assert!(ids.contains(&id1), "list missing {id1}"); + assert!(ids.contains(&id2), "list missing {id2}"); +} + +#[tokio::test] +async fn running_count_excludes_terminal_states() { + let running_id = "lifecycle-rc-running"; + let finished_id = "lifecycle-rc-finished"; + let killed_id = "lifecycle-rc-killed"; + let failed_id = "lifecycle-rc-failed"; + seed(JobHandle { + record: rec(running_id, JobStatus::Running, None), + child: None, + }) + .await; + seed(JobHandle { + record: rec(finished_id, JobStatus::Finished, Some(now_ms())), + child: None, + }) + .await; + seed(JobHandle { + record: rec(killed_id, JobStatus::Killed, Some(now_ms())), + child: None, + }) + .await; + seed(JobHandle { + record: rec(failed_id, JobStatus::Failed, Some(now_ms())), + child: None, + }) + .await; + + let n = jobs::running_count().await; + assert!(n >= 1, "running_count should include our running job"); + + let all = jobs::list_all().await; + for id in [finished_id, killed_id, failed_id] { + let r = all + .iter() + .find(|r| r.id == id) + .expect("inserted job present"); + assert_ne!(r.status, JobStatus::Running, "{id} must not be Running"); + } +} + +#[tokio::test] +async fn remove_old_retention_matrix() { + let stale_id = "lifecycle-remove-old-stale"; + let running_id = "lifecycle-remove-old-running"; + let fresh_id = "lifecycle-remove-old-fresh"; + + let stale_finished = now_ms().saturating_sub(60 * 60 * 1000); + seed(JobHandle { + record: rec(stale_id, JobStatus::Finished, Some(stale_finished)), + child: None, + }) + .await; + assert!(jobs::get(stale_id).await.is_some(), "pre-prune sanity"); + jobs::remove_old(1).await; + assert!( + jobs::get(stale_id).await.is_none(), + "stale finished job should have been pruned", + ); + + seed(JobHandle { + record: rec(running_id, JobStatus::Running, None), + child: None, + }) + .await; + jobs::remove_old(0).await; + assert!( + jobs::get(running_id).await.is_some(), + "running jobs (finished_at_ms = None) must survive remove_old(0)", + ); + + let recent = now_ms().saturating_sub(10); + seed(JobHandle { + record: rec(fresh_id, JobStatus::Finished, Some(recent)), + child: None, + }) + .await; + jobs::remove_old(60).await; + assert!( + jobs::get(fresh_id).await.is_some(), + "fresh job should remain within 60s retention window", + ); +} + +#[tokio::test] +async fn concurrent_inserts_and_lookups_dont_deadlock() { + let mut handles = Vec::new(); + for i in 0..50 { + let h = tokio::spawn(async move { + let id = format!("lifecycle-concurrent-{i}"); + seed(JobHandle { + record: rec(&id, JobStatus::Running, None), + child: None, + }) + .await; + jobs::get(&id).await.is_some() + }); + handles.push(h); + } + for h in handles { + assert!(h.await.unwrap(), "concurrent insert/get failed"); + } + let all = jobs::list_all().await; + let ours: usize = all + .iter() + .filter(|r| r.id.starts_with("lifecycle-concurrent-")) + .count(); + assert_eq!(ours, 50, "all 50 concurrent jobs visible in list_all"); +} diff --git a/shell/tests/sandbox_dispatch.rs b/shell/tests/sandbox_dispatch.rs new file mode 100644 index 0000000000..dcb2c0d5cc --- /dev/null +++ b/shell/tests/sandbox_dispatch.rs @@ -0,0 +1,389 @@ +//! Integration tests for `SandboxFsBackend`. + +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use iii_sdk::IIIError; +use serde_json::{json, Value}; +use uuid::Uuid; + +use iii_shell::fs::sandbox::{SandboxFsBackend, TriggerFwd}; +use iii_shell::fs::{ + ChmodArgs, FsBackend, GrepArgs, LsArgs, MkdirArgs, MvArgs, ReadArgs, RmArgs, SedArgs, StatArgs, + WriteArgs, +}; + +#[derive(Debug)] +enum Response { + Ok(Value), + HandlerErr(String), + RemoteErr { code: String, message: String }, +} + +struct StubFwd { + captured: Mutex>, + next: Mutex>, +} + +impl StubFwd { + fn new(resp: Response) -> Arc { + Arc::new(Self { + captured: Mutex::new(Vec::new()), + next: Mutex::new(Some(resp)), + }) + } + fn last_call(&self) -> (String, Value) { + self.captured + .lock() + .unwrap() + .last() + .cloned() + .expect("no call captured") + } +} + +#[async_trait] +impl TriggerFwd for StubFwd { + async fn trigger(&self, fid: &str, payload: Value) -> Result { + self.captured.lock().unwrap().push((fid.into(), payload)); + match self.next.lock().unwrap().take() { + Some(Response::Ok(v)) => Ok(v), + Some(Response::HandlerErr(s)) => Err(IIIError::Handler(s)), + Some(Response::RemoteErr { code, message }) => Err(IIIError::Remote { + code, + message, + stacktrace: None, + }), + None => panic!("StubFwd called more than once with no scripted response"), + } + } +} + +fn backend(stub: Arc) -> SandboxFsBackend { + SandboxFsBackend::new(stub, true, Uuid::new_v4()) +} + +#[tokio::test] +async fn stat_forwards_path_with_sandbox_id() { + let stub = StubFwd::new(Response::Ok(json!({ + "name": "x.txt", "is_dir": false, "size": 5, "mode": "0644", + "mtime": 0, "is_symlink": false, + }))); + let resp = backend(stub.clone()) + .stat(StatArgs { + path: "/sb/x.txt".into(), + }) + .await + .expect("stat round-trip"); + assert_eq!(resp.0.size, 5); + let (fid, payload) = stub.last_call(); + assert_eq!(fid, "sandbox::fs::stat"); + assert_eq!(payload["path"], "/sb/x.txt"); + assert!(payload["sandbox_id"].is_string(), "sandbox_id injected"); +} + +#[tokio::test] +async fn mkdir_forwards_mode_and_parents_flag() { + let stub = StubFwd::new(Response::Ok(json!({"created": true}))); + let resp = backend(stub.clone()) + .mkdir(MkdirArgs { + path: "/sb/dir".into(), + mode: "0700".into(), + parents: true, + }) + .await + .unwrap(); + assert!(resp.created); + let (_, payload) = stub.last_call(); + assert_eq!(payload["mode"], "0700"); + assert_eq!(payload["parents"], true); +} + +#[tokio::test] +async fn rm_forwards_recursive_flag() { + let stub = StubFwd::new(Response::Ok(json!({"removed": true}))); + backend(stub.clone()) + .rm(RmArgs { + path: "/sb/d".into(), + recursive: true, + }) + .await + .unwrap(); + let (_, payload) = stub.last_call(); + assert_eq!(payload["recursive"], true); +} + +#[tokio::test] +async fn chmod_forwards_uid_gid_recursive() { + let stub = StubFwd::new(Response::Ok(json!({"updated": 7}))); + let resp = backend(stub.clone()) + .chmod(ChmodArgs { + path: "/sb/d".into(), + mode: "0644".into(), + uid: Some(1000), + gid: Some(1000), + recursive: true, + }) + .await + .unwrap(); + assert_eq!(resp.updated, 7); + let (_, payload) = stub.last_call(); + assert_eq!(payload["uid"], 1000); + assert_eq!(payload["gid"], 1000); + assert_eq!(payload["recursive"], true); +} + +#[tokio::test] +async fn mv_forwards_overwrite_flag() { + let stub = StubFwd::new(Response::Ok(json!({"moved": true}))); + backend(stub.clone()) + .mv(MvArgs { + src: "/sb/a".into(), + dst: "/sb/b".into(), + overwrite: true, + }) + .await + .unwrap(); + let (_, payload) = stub.last_call(); + assert_eq!(payload["src"], "/sb/a"); + assert_eq!(payload["dst"], "/sb/b"); + assert_eq!(payload["overwrite"], true); +} + +#[tokio::test] +async fn grep_forwards_full_payload() { + let stub = StubFwd::new(Response::Ok(json!({ + "matches": [{"path": "/sb/f", "line": 1, "content": "x"}], + "truncated": false, + }))); + backend(stub.clone()) + .grep(GrepArgs { + path: "/sb".into(), + pattern: "needle".into(), + recursive: true, + ignore_case: true, + include_glob: vec!["*.rs".into()], + exclude_glob: vec!["*.bak".into()], + max_matches: 50, + max_line_bytes: 8192, + }) + .await + .unwrap(); + let (_, p) = stub.last_call(); + assert_eq!(p["pattern"], "needle"); + assert_eq!(p["ignore_case"], true); + assert_eq!(p["max_matches"], 50); + assert_eq!(p["max_line_bytes"], 8192); + assert_eq!(p["include_glob"], json!(["*.rs"])); + assert_eq!(p["exclude_glob"], json!(["*.bak"])); +} + +#[tokio::test] +async fn sed_forwards_full_payload() { + let stub = StubFwd::new(Response::Ok(json!({ + "results": [{"path": "/sb/a", "replacements": 1, "success": true}], + "total_replacements": 1, + }))); + backend(stub.clone()) + .sed(SedArgs { + files: vec!["/sb/a".into()], + path: None, + recursive: false, + include_glob: vec![], + exclude_glob: vec![], + pattern: "foo".into(), + replacement: "bar".into(), + regex: true, + first_only: false, + ignore_case: false, + }) + .await + .unwrap(); + let (_, p) = stub.last_call(); + assert_eq!(p["pattern"], "foo"); + assert_eq!(p["replacement"], "bar"); + assert_eq!(p["regex"], true); + assert_eq!(p["first_only"], false); +} + +#[tokio::test] +async fn read_forwards_path_and_returns_engine_response() { + let stub = StubFwd::new(Response::Ok(json!({ + "content": {"channel_id": "c1", "access_key": "k1", "direction": "read"}, + "size": 100, + "mode": "0644", + "mtime": 1700000000, + }))); + let resp = backend(stub.clone()) + .read(ReadArgs { + path: "/sb/file".into(), + }) + .await + .unwrap(); + assert_eq!(resp.size, 100); + assert_eq!(resp.mode, "0644"); + let (_, p) = stub.last_call(); + assert_eq!(p["path"], "/sb/file"); +} + +#[tokio::test] +async fn write_forwards_content_ref_verbatim() { + let stub = StubFwd::new(Response::Ok(json!({ + "bytes_written": 42, "path": "/sb/x", + }))); + let content_ref = iii_sdk::channels::StreamChannelRef { + channel_id: "c-input".into(), + access_key: "k-input".into(), + direction: iii_sdk::channels::ChannelDirection::Read, + }; + let resp = backend(stub.clone()) + .write(WriteArgs { + path: "/sb/x".into(), + mode: "0600".into(), + parents: false, + content: content_ref.clone(), + }) + .await + .unwrap(); + assert_eq!(resp.bytes_written, 42); + let (_, p) = stub.last_call(); + assert_eq!(p["content"]["channel_id"], "c-input"); + assert_eq!(p["content"]["access_key"], "k-input"); + assert_eq!(p["mode"], "0600"); + assert_eq!(p["parents"], false); +} + +#[tokio::test] +async fn remote_error_with_s_code_round_trips() { + let stub = StubFwd::new(Response::RemoteErr { + code: "S213".into(), + message: "destination exists".into(), + }); + let err = backend(stub) + .ls(LsArgs { path: "/sb".into() }) + .await + .unwrap_err(); + assert_eq!(err.code, "S213"); + assert!(err.message.contains("forwarded from engine")); +} + +#[tokio::test] +async fn remote_error_with_unknown_s_code_collapses_to_s216() { + let stub = StubFwd::new(Response::RemoteErr { + code: "S299".into(), + message: "future code".into(), + }); + let err = backend(stub) + .ls(LsArgs { path: "/sb".into() }) + .await + .unwrap_err(); + assert_eq!(err.code, "S216"); +} + +#[tokio::test] +async fn remote_error_invocation_failed_with_s_code_in_message_recovers() { + let stub = StubFwd::new(Response::RemoteErr { + code: "invocation_failed".into(), + message: "handler error: S211: not found in /sb/missing".into(), + }); + let err = backend(stub) + .stat(StatArgs { + path: "/sb/missing".into(), + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S211"); + assert!(err.message.contains("wrapped")); +} + +#[tokio::test] +async fn remote_error_invocation_failed_without_s_code_falls_back_to_s216() { + let stub = StubFwd::new(Response::RemoteErr { + code: "invocation_failed".into(), + message: "handler error: just a string with no code".into(), + }); + let err = backend(stub) + .ls(LsArgs { path: "/sb".into() }) + .await + .unwrap_err(); + assert_eq!(err.code, "S216"); +} + +#[tokio::test] +async fn handler_error_with_json_payload_recovers_code() { + let stub = StubFwd::new(Response::HandlerErr( + r#"{"code":"S214","message":"directory not empty"}"#.into(), + )); + let err = backend(stub) + .rm(RmArgs { + path: "/sb/d".into(), + recursive: false, + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S214"); +} + +#[tokio::test] +async fn handler_error_with_raw_s_code_in_string_recovers_via_scan() { + let stub = StubFwd::new(Response::HandlerErr( + "something broke S217 bad regex".into(), + )); + let err = backend(stub) + .grep(GrepArgs { + path: "/sb".into(), + pattern: "[".into(), + recursive: true, + ignore_case: false, + include_glob: vec![], + exclude_glob: vec![], + max_matches: 10, + max_line_bytes: 4096, + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S217"); +} + +#[tokio::test] +async fn handler_error_without_s_code_falls_back_to_s216() { + let stub = StubFwd::new(Response::HandlerErr("just a plain old string".into())); + let err = backend(stub) + .ls(LsArgs { path: "/sb".into() }) + .await + .unwrap_err(); + assert_eq!(err.code, "S216"); +} + +#[tokio::test] +async fn engine_response_missing_required_field_is_s216() { + let stub = StubFwd::new(Response::Ok(json!({}))); + let err = backend(stub) + .ls(LsArgs { path: "/sb".into() }) + .await + .unwrap_err(); + assert_eq!(err.code, "S216"); + assert!(err.message.contains("bad engine response")); +} + +#[tokio::test] +async fn disabled_sandbox_returns_s210_without_calling_engine() { + let stub = StubFwd::new(Response::Ok(json!({"entries": []}))); + let b = SandboxFsBackend::new(stub.clone(), false, Uuid::new_v4()); + let err = b.ls(LsArgs { path: "/sb".into() }).await.unwrap_err(); + assert_eq!(err.code, "S210"); + assert!( + stub.captured.lock().unwrap().is_empty(), + "disabled sandbox should short-circuit before forwarding", + ); +} + +#[tokio::test] +async fn scan_s_code_skips_codes_glued_to_an_identifier() { + let stub = StubFwd::new(Response::HandlerErr("fooS211bar should not match".into())); + let err = backend(stub) + .ls(LsArgs { path: "/sb".into() }) + .await + .unwrap_err(); + assert_eq!(err.code, "S216"); +}