From 9bacb5c6a0938f934273fa80b979eb849c1a2702 Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Tue, 5 May 2026 14:52:07 -0300 Subject: [PATCH 1/8] feat(shell): unix shell + filesystem worker with typed RegisterFunction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `shell` worker exposing 15 functions: 5 shell::* (exec, exec_bg, kill, status, list) and 10 shell::fs::* (ls, stat, mkdir, rm, chmod, mv, grep, sed, write, read). Wire-compatible with the engine daemon's sandbox::fs::* surface; sandbox-target requests forward through `iii.trigger` with the caller's StreamChannelRef flowing verbatim (worker is never in the byte path). Built on iii-sdk 0.11.3's typed `RegisterFunction::new_async`. shell:: kill / shell::status take typed request structs; the other 13 handlers accept Value at the registration boundary to preserve legacy wire-error contracts (S210 for malformed fs payloads, "missing 'command'" / "must be a string" for exec, silent timeout_ms fallback) that schemars-derived deserialization can't reproduce. schemars 0.8 with the uuid1 feature is added because Target carries a Uuid. Security model: - Allowlist: command basename gate (empty list = open). - Denylist patterns: advisory tripwires only — regex on argv.join(" "), trivially bypassable once any shell or interpreter is allowlisted (variable construction, eval, IFS). README documents this explicitly. - host_root jail: validate_path canonicalizes every existing ancestor (resolving symlinks), lexically collapses the non-existent tail, and reflects each tail component through symlink_metadata to refuse dangling-symlink jail escapes. Both host_root and denylist entries are precomputed at startup (HostFsBackend::try_new); zero canonicalize syscalls per fs op. - allow_unjailed: explicit opt-in for running with host_root null. Worker refuses to start otherwise (validate_fs_jail), in both load_config success and fallback paths. - chmod recursive walk skips symlink entries; refuses with S212 if the walk root itself is a symlink (no silent no-op). - shell::list returns JobSummary records (id, status, timestamps, exit_code, *_truncated). argv/stdout/stderr cap-gated via shell::status by random UUID job_id — the global JOBS map has no per-caller scope. - exec_bg uses atomic try_reserve_and_insert: counts running handles under the map mutex, inserts only if under the cap, conservatively counts locked handles as running. Rejection returns the spawned child so it can be killed; no zombies. - max_concurrent_jobs cap, max_output_bytes truncation, timeout clamp, env scrubbing, working_dir pin, optional fs read/write caps, atomic write via temp+rename. Tests: - 277 unit + integration tests across 7 suites (lib, jobs lifecycle, host fs branches, sandbox dispatch, function handlers, config validation, manifest). - E2E TS harness: 144 default cases (happy paths, safety, jobs, fs host + sandbox, encoding, concurrency, adversarial protocol breaks for streaming/exec/jobs/fs/sandbox, plus 5 vuln-regression cases under cases-vuln-repro.ts). - Jailed e2e: 2 cases under cases-vuln-repro-jailed.ts covering the symlink-parent escape (validate_path site + parents:true defense-in-depth site), runs against config-jailed.yaml via run-tests-jailed.sh. README documents the full surface, threat model, allow_unjailed contract, JobSummary redaction, and the canonicalize- with-fallback semantics. --- .gitignore | 1 + shell/Cargo.lock | 2710 +++++++++++++++++ shell/Cargo.toml | 32 + shell/README.md | 350 +++ shell/build.rs | 6 + shell/config.yaml | 82 + shell/iii.worker.yaml | 7 + shell/src/config.rs | 349 +++ shell/src/exec.rs | 204 ++ shell/src/fs/error.rs | 99 + shell/src/fs/host.rs | 2130 +++++++++++++ shell/src/fs/mod.rs | 607 ++++ shell/src/fs/sandbox.rs | 481 +++ shell/src/fs/wire.rs | 114 + shell/src/functions/exec.rs | 62 + shell/src/functions/exec_bg.rs | 185 ++ shell/src/functions/fs_chmod.rs | 20 + shell/src/functions/fs_dispatch.rs | 33 + shell/src/functions/fs_grep.rs | 20 + shell/src/functions/fs_ls.rs | 20 + shell/src/functions/fs_mkdir.rs | 20 + shell/src/functions/fs_mv.rs | 20 + shell/src/functions/fs_read.rs | 21 + shell/src/functions/fs_rm.rs | 20 + shell/src/functions/fs_sed.rs | 20 + shell/src/functions/fs_stat.rs | 20 + shell/src/functions/fs_write.rs | 20 + shell/src/functions/kill.rs | 37 + shell/src/functions/list.rs | 16 + shell/src/functions/mod.rs | 18 + shell/src/functions/status.rs | 12 + shell/src/functions/types.rs | 91 + shell/src/jobs.rs | 206 ++ shell/src/lib.rs | 10 + shell/src/main.rs | 291 ++ shell/src/manifest.rs | 55 + shell/tests/e2e/.gitignore | 8 + shell/tests/e2e/README.md | 110 + shell/tests/e2e/config-jailed.yaml | 47 + shell/tests/e2e/config.yaml | 53 + shell/tests/e2e/data/.gitkeep | 0 shell/tests/e2e/reports/.gitkeep | 0 shell/tests/e2e/run-tests-jailed.sh | 169 + shell/tests/e2e/run-tests.sh | 189 ++ .../tests/e2e/workers/harness/iii.worker.yaml | 8 + .../e2e/workers/harness/package-lock.json | 1160 +++++++ shell/tests/e2e/workers/harness/package.json | 20 + .../workers/harness/src/cases-concurrency.ts | 75 + .../e2e/workers/harness/src/cases-edge.ts | 49 + .../workers/harness/src/cases-exec-break.ts | 189 ++ .../workers/harness/src/cases-fs-encoding.ts | 115 + .../workers/harness/src/cases-fs-errors.ts | 104 + .../workers/harness/src/cases-fs-host-jail.ts | 44 + .../e2e/workers/harness/src/cases-fs-host.ts | 259 ++ .../harness/src/cases-fs-protocol-break.ts | Bin 0 -> 11040 bytes .../workers/harness/src/cases-fs-sandbox.ts | 378 +++ .../workers/harness/src/cases-jobs-break.ts | 120 + .../e2e/workers/harness/src/cases-jobs.ts | 109 + .../e2e/workers/harness/src/cases-safety.ts | 97 + .../harness/src/cases-sandbox-break.ts | 205 ++ .../harness/src/cases-streaming-break.ts | 246 ++ .../harness/src/cases-vuln-repro-jailed.ts | 138 + .../workers/harness/src/cases-vuln-repro.ts | 248 ++ shell/tests/e2e/workers/harness/src/cases.ts | 82 + shell/tests/e2e/workers/harness/src/runner.ts | 152 + shell/tests/e2e/workers/harness/src/worker.ts | 43 + shell/tests/e2e/workers/harness/tsconfig.json | 14 + shell/tests/function_handlers.rs | 470 +++ shell/tests/host_fs_branches.rs | 279 ++ shell/tests/jobs_lifecycle.rs | 183 ++ shell/tests/sandbox_dispatch.rs | 389 +++ 71 files changed, 14141 insertions(+) create mode 100644 shell/Cargo.lock create mode 100644 shell/Cargo.toml create mode 100644 shell/README.md create mode 100644 shell/build.rs create mode 100644 shell/config.yaml create mode 100644 shell/iii.worker.yaml create mode 100644 shell/src/config.rs create mode 100644 shell/src/exec.rs create mode 100644 shell/src/fs/error.rs create mode 100644 shell/src/fs/host.rs create mode 100644 shell/src/fs/mod.rs create mode 100644 shell/src/fs/sandbox.rs create mode 100644 shell/src/fs/wire.rs create mode 100644 shell/src/functions/exec.rs create mode 100644 shell/src/functions/exec_bg.rs create mode 100644 shell/src/functions/fs_chmod.rs create mode 100644 shell/src/functions/fs_dispatch.rs create mode 100644 shell/src/functions/fs_grep.rs create mode 100644 shell/src/functions/fs_ls.rs create mode 100644 shell/src/functions/fs_mkdir.rs create mode 100644 shell/src/functions/fs_mv.rs create mode 100644 shell/src/functions/fs_read.rs create mode 100644 shell/src/functions/fs_rm.rs create mode 100644 shell/src/functions/fs_sed.rs create mode 100644 shell/src/functions/fs_stat.rs create mode 100644 shell/src/functions/fs_write.rs create mode 100644 shell/src/functions/kill.rs create mode 100644 shell/src/functions/list.rs create mode 100644 shell/src/functions/mod.rs create mode 100644 shell/src/functions/status.rs create mode 100644 shell/src/functions/types.rs create mode 100644 shell/src/jobs.rs create mode 100644 shell/src/lib.rs create mode 100644 shell/src/main.rs create mode 100644 shell/src/manifest.rs create mode 100644 shell/tests/e2e/.gitignore create mode 100644 shell/tests/e2e/README.md create mode 100644 shell/tests/e2e/config-jailed.yaml create mode 100644 shell/tests/e2e/config.yaml create mode 100644 shell/tests/e2e/data/.gitkeep create mode 100644 shell/tests/e2e/reports/.gitkeep create mode 100755 shell/tests/e2e/run-tests-jailed.sh create mode 100755 shell/tests/e2e/run-tests.sh create mode 100644 shell/tests/e2e/workers/harness/iii.worker.yaml create mode 100644 shell/tests/e2e/workers/harness/package-lock.json create mode 100644 shell/tests/e2e/workers/harness/package.json create mode 100644 shell/tests/e2e/workers/harness/src/cases-concurrency.ts create mode 100644 shell/tests/e2e/workers/harness/src/cases-edge.ts create mode 100644 shell/tests/e2e/workers/harness/src/cases-exec-break.ts create mode 100644 shell/tests/e2e/workers/harness/src/cases-fs-encoding.ts create mode 100644 shell/tests/e2e/workers/harness/src/cases-fs-errors.ts create mode 100644 shell/tests/e2e/workers/harness/src/cases-fs-host-jail.ts create mode 100644 shell/tests/e2e/workers/harness/src/cases-fs-host.ts create mode 100644 shell/tests/e2e/workers/harness/src/cases-fs-protocol-break.ts create mode 100644 shell/tests/e2e/workers/harness/src/cases-fs-sandbox.ts create mode 100644 shell/tests/e2e/workers/harness/src/cases-jobs-break.ts create mode 100644 shell/tests/e2e/workers/harness/src/cases-jobs.ts create mode 100644 shell/tests/e2e/workers/harness/src/cases-safety.ts create mode 100644 shell/tests/e2e/workers/harness/src/cases-sandbox-break.ts create mode 100644 shell/tests/e2e/workers/harness/src/cases-streaming-break.ts create mode 100644 shell/tests/e2e/workers/harness/src/cases-vuln-repro-jailed.ts create mode 100644 shell/tests/e2e/workers/harness/src/cases-vuln-repro.ts create mode 100644 shell/tests/e2e/workers/harness/src/cases.ts create mode 100644 shell/tests/e2e/workers/harness/src/runner.ts create mode 100644 shell/tests/e2e/workers/harness/src/worker.ts create mode 100644 shell/tests/e2e/workers/harness/tsconfig.json create mode 100644 shell/tests/function_handlers.rs create mode 100644 shell/tests/host_fs_branches.rs create mode 100644 shell/tests/jobs_lifecycle.rs create mode 100644 shell/tests/sandbox_dispatch.rs diff --git a/.gitignore b/.gitignore index 92245e39bc..9f8a137fac 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ package-lock.json pnpm-lock.yaml yarn.lock !iii-database/tests/e2e/workers/harness/package-lock.json +!shell/tests/e2e/workers/harness/package-lock.json iii_workers/ \ 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..e9812c25fe --- /dev/null +++ b/shell/config.yaml @@ -0,0 +1,82 @@ +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) are left out on purpose — add them +# per deployment after you've decided on the threat model. This worker is +# NOT a sandbox. +allowlist: + - ls + - cat + - pwd + - echo + - grep + - wc + - head + - tail + - sort + - uniq + - cut + - date + - whoami + - hostname + - which + - jq + - uname + - df + - du + - ps + - env + - 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..d727e7c284 --- /dev/null +++ b/shell/src/config.rs @@ -0,0 +1,349 @@ +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()); + } + + #[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..181b46acda --- /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::new(ErrorKind::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..10ac50e0f6 --- /dev/null +++ b/shell/src/fs/host.rs @@ -0,0 +1,2130 @@ +//! 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)] +pub struct HostFsConfig { + pub host_root: Option, + pub max_read_bytes: usize, + pub max_write_bytes: usize, + pub denylist_paths: Vec, +} + +impl Default for HostFsConfig { + fn default() -> Self { + Self { + host_root: None, + max_read_bytes: 0, + max_write_bytes: 0, + denylist_paths: Vec::new(), + } + } +} + +#[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 escaped_replacement = replacement.replace('$', "$$"); + let pattern = format!("(?i){escaped_needle}"); + let re = match regex::Regex::new(&pattern) { + Ok(r) => r, + Err(_) => return (line.to_string(), 0), + }; + let mut count = 0u64; + let out = if first_only { + re.replacen(line, 1, |_caps: ®ex::Captures| { + count += 1; + escaped_replacement.clone() + }) + .into_owned() + } else { + re.replace_all(line, |_caps: ®ex::Captures| { + count += 1; + escaped_replacement.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"); + } +} 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..9368e1794b --- /dev/null +++ b/shell/src/manifest.rs @@ -0,0 +1,55 @@ +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)", + }, + ], + }) +} + +#[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(), 5); + } + + #[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")); + } +} 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 0000000000000000000000000000000000000000..d6baebecd47c4befbfad91a82c57e8946697a77b GIT binary patch literal 11040 zcmeHN|8E*O7QdhQE4f!hs?Vsz3biN0YpcHfGL-IIuF zraOu5jHNbRQ`v*frz|pU;{IQM%4pgqW-(>t3T9nNVb~lV+h0B~ZSdFCrQMQ^ND6Jp z+d+8s+xzg%pC7IVK;G{rivwe-Y429E=H~V z19@l=qL|5)D1#gzFoDA_Xtl{l&Ur_VS(+k~(;TR=i!W-aX6xCNJlKpGJb513;xMA-bP#X^J(9nUcULdW~;s5of3S^Vk3#tFs4Gm2i@2 z6AoGE{MA=sQ5$Nk**jhBo&NLer$hDA@?BqtK*r$xEBY5O3 zjyUH7+=O?nJGoQ~yLF@@OT*#9JjF;{_)=0Dh)<;u+Jv>5e)~g$OU{!IB6g#f5K9s% z|1qR$EuK6rn#9t;UuYEl%#@~Sn6f#C02d0WCN$-csC)}+vI=<8?Vd0bolL3Lvv`fs z-cOrY-Q+4Jkn{m#B9xH<7HK*i(|rtWqsVcCr^&n&I5G2BOD!Ok*H$>szb`yrou8i{ z9ZCtdm!xubbY1Mo_QEc{rga8Z+I8GiG%vK1{h_HoU^~^WMKs%OehSNmlfk7|Kke>;atGQ>j_p87Y~8;#Ab5qy$D~torb8qXW6n z)=tKo900%p@jXnS+N1G2u65eRzXi>PwagBVN zGPqoK-dypusJnf#?)gi~Qe)cx*A-t8L|uUzuvtLV0TX~k)!V{RGXc~VyUW*8mvYuG zq|Tx-T0^t7qCO=xc{ZQ2<~07ME`>fp+CI0n`D=93d_i6LBl z;*l!Ra$pKo700u61xf_hCdbbv3y)l<(g?tk7ryqBMFAL3AJ1~I&QjYQL zQ+LS4>ZoCca8kuwfp@%`(nvw#yVp&HSwmu5tYW>Jcb7T`` zIIOGrf7u#Mm^%}ELVV>43Ci)zW7{^-OJC9ov50H6pj36rOU1i{xUHIii|15r(ghr| zhc=Lxq_={*cRUs7A?kG_Fd)OOoLdThK7(oAl{n(9fZxOY8fY8AWXvi2w|{?@JpMHZ z6w@;O%!do-BXU_NV^Gp8e$YYajAkV4*<~Bg*c1Vd>!CDBj}{T)hzcEvU~icO-2&3 z%pV_M@Z-m&jCV=5>+iVKIOH5RciX8~WWt`{?M@cOY+Wn*Xj}IySM`tPUvZl+`Bx)l zXUIo~x>#!qZ~cA50OM+w6tdeNc*{ZkEi`|i@kd9DkyVl8D6W;+>*Jmk$ungj literal 0 HcmV?d00001 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"); +} From ca59875a641f39c977c55cc4e31f1b9cf2b14024 Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Tue, 5 May 2026 15:00:00 -0300 Subject: [PATCH 2/8] ci(shell): add shell-e2e workflow mirroring iii-database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs the shell tests/e2e harness on PRs touching shell/**. Two suites: - ./run-tests.sh — 144 default cases (host_root unset, allow_unjailed=true) - ./run-tests-jailed.sh --no-build — 2 cases for S-C1 (host_root set, reusing the binary the default run built) Pre-creates /private/tmp 1777 on the Linux runner because the jailed suite hardcodes JAIL_ROOT=/private/tmp/iii-shell-jailed-root (macOS's canonical /tmp), which doesn't exist on Ubuntu. --- .github/workflows/shell-e2e.yml | 78 +++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 .github/workflows/shell-e2e.yml 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 From 65327a66200180e117e9b86fa9903978a2e8018c Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Tue, 5 May 2026 15:08:27 -0300 Subject: [PATCH 3/8] refactor(shell): reformat code for consistent style and improved readability - Compact multi-line function signatures and error handling into single-line equivalents. - Simplified string formatting in error messages. - No functional changes. --- shell/src/fs/host.rs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/shell/src/fs/host.rs b/shell/src/fs/host.rs index 10ac50e0f6..5729f4846d 100644 --- a/shell/src/fs/host.rs +++ b/shell/src/fs/host.rs @@ -118,10 +118,7 @@ impl HostFsBackend { /// 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 { + 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( @@ -226,10 +223,7 @@ fn canonicalize_with_fallback(p: &Path) -> std::io::Result { if md.file_type().is_symlink() { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, - format!( - "dangling symlink in path: {}", - walk.display() - ), + format!("dangling symlink in path: {}", walk.display()), )); } } @@ -655,8 +649,8 @@ impl FsBackend for HostFsBackend { // 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))?; + 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", From 323e63e174eaa139bc22993d5487a5b8e01acbc0 Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Tue, 5 May 2026 15:11:58 -0300 Subject: [PATCH 4/8] fix(shell): clippy clean (derivable Default + io::Error::other) --- shell/src/fs/error.rs | 2 +- shell/src/fs/host.rs | 13 +------------ 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/shell/src/fs/error.rs b/shell/src/fs/error.rs index 181b46acda..4e9e32e3ed 100644 --- a/shell/src/fs/error.rs +++ b/shell/src/fs/error.rs @@ -65,7 +65,7 @@ mod tests { 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::new(ErrorKind::Other, "io")); + let e = FsError::from_io("/x", io::Error::other("io")); assert_eq!(e.code, "S216"); } diff --git a/shell/src/fs/host.rs b/shell/src/fs/host.rs index 5729f4846d..fa6d4552a4 100644 --- a/shell/src/fs/host.rs +++ b/shell/src/fs/host.rs @@ -67,7 +67,7 @@ impl ChannelMaker for IiiChannelMaker { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct HostFsConfig { pub host_root: Option, pub max_read_bytes: usize, @@ -75,17 +75,6 @@ pub struct HostFsConfig { pub denylist_paths: Vec, } -impl Default for HostFsConfig { - fn default() -> Self { - Self { - host_root: None, - max_read_bytes: 0, - max_write_bytes: 0, - denylist_paths: Vec::new(), - } - } -} - #[derive(Debug, Clone)] pub struct HostFsBackend { cfg: Arc, From 2f8fc8ea497af5a076603de4b48a8d329142c1dc Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Tue, 5 May 2026 15:55:38 -0300 Subject: [PATCH 5/8] fix(shell): manifest advertises all 15 functions build_manifest() listed only the 5 shell::* ids; the 10 shell::fs::* ids registered at runtime were missing, so `--manifest` under-reported the worker's capability surface. Added the fs entries with descriptions copied verbatim from the runtime .description() calls in main.rs and updated the count assertion to 15 with fs id presence checks. --- shell/src/manifest.rs | 45 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/shell/src/manifest.rs b/shell/src/manifest.rs index 9368e1794b..2e1309d1c0 100644 --- a/shell/src/manifest.rs +++ b/shell/src/manifest.rs @@ -26,6 +26,46 @@ pub fn build_manifest() -> Value { "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", + }, ], }) } @@ -41,7 +81,7 @@ mod tests { 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(), 5); + assert_eq!(fns.len(), 15); } #[test] @@ -51,5 +91,8 @@ mod tests { 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")); } } From 69f9b9fdb91e473b3450be3458d96030df9d8533 Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Tue, 5 May 2026 16:47:27 -0300 Subject: [PATCH 6/8] fix(shell): drop `env` from default allowlist (read-only intent) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `is_command_allowed` only checks argv[0]'s basename, so allowlisting `env` let `argv = ["env", "nmap", "host"]` pass the gate even though the config comment block explicitly states the default allowlist is "intentionally read-only" and lists exec-escapes (`node -e`, `python3 -c`, `npm run`, etc.) as left-out-on-purpose. `printenv` is also allowlisted and covers the legitimate read-only env-inspection use case without the exec capability — `env` was redundant. Adding an `env ` denylist regex would be the wrong layer: the config comment block (lines 42-47) calls denylist patterns "advisory, not a security boundary" because allowlisted shells/interpreters can defeat any pattern by token construction. Removing the entry is the clean fix. Regression test parses the shipped config.yaml and asserts `printenv` is allowed while `env nmap host` is rejected with "not in allowlist". --- shell/config.yaml | 9 +++++---- shell/src/config.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/shell/config.yaml b/shell/config.yaml index e9812c25fe..6744b3fc27 100644 --- a/shell/config.yaml +++ b/shell/config.yaml @@ -11,9 +11,11 @@ allowed_env: - 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) are left out on purpose — add them -# per deployment after you've decided on the threat model. This worker is -# NOT a sandbox. +# 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 @@ -35,7 +37,6 @@ allowlist: - df - du - ps - - env - printenv - basename - dirname diff --git a/shell/src/config.rs b/shell/src/config.rs index d727e7c284..98cc373cea 100644 --- a/shell/src/config.rs +++ b/shell/src/config.rs @@ -276,6 +276,32 @@ mod tests { 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(); From 20ae4b38269d397f8658508d1078ce53e5abbd2f Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Tue, 5 May 2026 16:47:36 -0300 Subject: [PATCH 7/8] fix(shell): preserve literal $ in case-insensitive sed replacement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit literal_replace_line's case-insensitive branch pre-escaped $ to $$ before returning from a closure passed to Regex::replacen / replace_all, on the assumption that closure returns go through $N capture substitution. They don't — the regex crate's Replacer blanket impl for `FnMut(&Captures) -> String` inserts the returned string verbatim; the $N rewrite only fires for the &str / String Replacer impls. So regex=false, ignore_case=true, replacement="$1" emitted "$$1" instead of the user-intended literal "$1". Drop the escaped_replacement line and clone the original replacement inside the closures. The regex=true path is unchanged: it correctly routes through expand_regex_replacement (caps.expand) where capture substitution is supposed to happen. Regression test drives the public sed API with the bug-triggering inputs (HELLO + needle "hello" + replacement "$1", regex=false, ignore_case=true) and asserts the output is "$1 world\n". --- shell/src/fs/host.rs | 42 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/shell/src/fs/host.rs b/shell/src/fs/host.rs index fa6d4552a4..3f08751bb2 100644 --- a/shell/src/fs/host.rs +++ b/shell/src/fs/host.rs @@ -347,23 +347,28 @@ fn literal_replace_line( } if case_insensitive { let escaped_needle = regex::escape(needle); - let escaped_replacement = replacement.replace('$', "$$"); 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; - escaped_replacement.clone() + owned.clone() }) .into_owned() } else { re.replace_all(line, |_caps: ®ex::Captures| { count += 1; - escaped_replacement.clone() + owned.clone() }) .into_owned() }; @@ -2110,4 +2115,35 @@ mod tests { 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"); + } } From 49423c7f0a093888c0cd3dc4612def23ecd02407 Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Tue, 5 May 2026 16:50:48 -0300 Subject: [PATCH 8/8] refactor(shell): condense error handling and parsing for ShellConfig in tests --- .gitignore | 3 ++- shell/src/config.rs | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 9f8a137fac..5434be87b8 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ pnpm-lock.yaml yarn.lock !iii-database/tests/e2e/workers/harness/package-lock.json !shell/tests/e2e/workers/harness/package-lock.json -iii_workers/ \ No newline at end of file +iii_workers/ +graphify-out \ No newline at end of file diff --git a/shell/src/config.rs b/shell/src/config.rs index 98cc373cea..37ce2c0b83 100644 --- a/shell/src/config.rs +++ b/shell/src/config.rs @@ -292,8 +292,7 @@ mod tests { 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"); + 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