From ef5c8fefc5ecebbbf973b01e667330828ae8345f Mon Sep 17 00:00:00 2001 From: Sergio Marcelino Date: Tue, 26 May 2026 08:05:05 -0300 Subject: [PATCH 1/3] feat: introducing coder worker --- coder/Cargo.lock | 3193 ++++++++++++++++++++ coder/Cargo.toml | 52 + coder/README.md | 162 + coder/build.rs | 6 + coder/config.yaml | 36 + coder/iii.worker.yaml | 22 + coder/skills/coder.md | 614 ++++ coder/skills/index.md | 39 + coder/src/config.rs | 166 + coder/src/error.rs | 121 + coder/src/functions/create_file.rs | 341 +++ coder/src/functions/delete_file.rs | 255 ++ coder/src/functions/list_folder.rs | 309 ++ coder/src/functions/mod.rs | 152 + coder/src/functions/read_file.rs | 194 ++ coder/src/functions/search.rs | 606 ++++ coder/src/functions/tree.rs | 372 +++ coder/src/functions/update_file.rs | 937 ++++++ coder/src/lib.rs | 9 + coder/src/main.rs | 107 + coder/src/manifest.rs | 69 + coder/src/path/mod.rs | 295 ++ coder/tests/bdd.rs | 49 + coder/tests/common/engine.rs | 64 + coder/tests/common/helpers.rs | 110 + coder/tests/common/mod.rs | 7 + coder/tests/common/workers.rs | 103 + coder/tests/common/world.rs | 50 + coder/tests/features/create_file.feature | 95 + coder/tests/features/delete_file.feature | 99 + coder/tests/features/lifecycle.feature | 95 + coder/tests/features/list_folder.feature | 137 + coder/tests/features/path_security.feature | 51 + coder/tests/features/read_file.feature | 66 + coder/tests/features/search.feature | 169 ++ coder/tests/features/tree.feature | 101 + coder/tests/features/update_file.feature | 251 ++ coder/tests/integration.rs | 283 ++ coder/tests/manifest.rs | 46 + coder/tests/path_jail.rs | 99 + coder/tests/steps/common.rs | 124 + coder/tests/steps/create.rs | 70 + coder/tests/steps/delete.rs | 43 + coder/tests/steps/lifecycle.rs | 6 + coder/tests/steps/list.rs | 138 + coder/tests/steps/mod.rs | 16 + coder/tests/steps/read.rs | 60 + coder/tests/steps/search.rs | 131 + coder/tests/steps/security.rs | 38 + coder/tests/steps/tree.rs | 130 + coder/tests/steps/update.rs | 45 + coder/tests/update_ops.rs | 186 ++ 52 files changed, 10919 insertions(+) create mode 100644 coder/Cargo.lock create mode 100644 coder/Cargo.toml create mode 100644 coder/README.md create mode 100644 coder/build.rs create mode 100644 coder/config.yaml create mode 100644 coder/iii.worker.yaml create mode 100644 coder/skills/coder.md create mode 100644 coder/skills/index.md create mode 100644 coder/src/config.rs create mode 100644 coder/src/error.rs create mode 100644 coder/src/functions/create_file.rs create mode 100644 coder/src/functions/delete_file.rs create mode 100644 coder/src/functions/list_folder.rs create mode 100644 coder/src/functions/mod.rs create mode 100644 coder/src/functions/read_file.rs create mode 100644 coder/src/functions/search.rs create mode 100644 coder/src/functions/tree.rs create mode 100644 coder/src/functions/update_file.rs create mode 100644 coder/src/lib.rs create mode 100644 coder/src/main.rs create mode 100644 coder/src/manifest.rs create mode 100644 coder/src/path/mod.rs create mode 100644 coder/tests/bdd.rs create mode 100644 coder/tests/common/engine.rs create mode 100644 coder/tests/common/helpers.rs create mode 100644 coder/tests/common/mod.rs create mode 100644 coder/tests/common/workers.rs create mode 100644 coder/tests/common/world.rs create mode 100644 coder/tests/features/create_file.feature create mode 100644 coder/tests/features/delete_file.feature create mode 100644 coder/tests/features/lifecycle.feature create mode 100644 coder/tests/features/list_folder.feature create mode 100644 coder/tests/features/path_security.feature create mode 100644 coder/tests/features/read_file.feature create mode 100644 coder/tests/features/search.feature create mode 100644 coder/tests/features/tree.feature create mode 100644 coder/tests/features/update_file.feature create mode 100644 coder/tests/integration.rs create mode 100644 coder/tests/manifest.rs create mode 100644 coder/tests/path_jail.rs create mode 100644 coder/tests/steps/common.rs create mode 100644 coder/tests/steps/create.rs create mode 100644 coder/tests/steps/delete.rs create mode 100644 coder/tests/steps/lifecycle.rs create mode 100644 coder/tests/steps/list.rs create mode 100644 coder/tests/steps/mod.rs create mode 100644 coder/tests/steps/read.rs create mode 100644 coder/tests/steps/search.rs create mode 100644 coder/tests/steps/security.rs create mode 100644 coder/tests/steps/tree.rs create mode 100644 coder/tests/steps/update.rs create mode 100644 coder/tests/update_ops.rs diff --git a/coder/Cargo.lock b/coder/Cargo.lock new file mode 100644 index 000000000..ff379b061 --- /dev/null +++ b/coder/Cargo.lock @@ -0,0 +1,3193 @@ +# 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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[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 = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[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.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +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", + "terminal_size", +] + +[[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 = "coder" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "cucumber", + "futures", + "globset", + "iii-sdk", + "regex", + "schemars", + "serde", + "serde_json", + "serde_yaml", + "tempfile", + "thiserror", + "tokio", + "tracing", + "tracing-subscriber", + "walkdir", + "which", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + +[[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 = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[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 = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[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 = "cucumber" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16cbb27bc2064274afa3a3d8bc9a0e71333589850573aa632ec4520e4af14d94" +dependencies = [ + "anyhow", + "clap", + "console", + "cucumber-codegen", + "cucumber-expressions", + "derive_more", + "either", + "futures", + "gherkin", + "globwalk", + "humantime", + "inventory", + "itertools", + "linked-hash-map", + "pin-project", + "ref-cast", + "regex", + "sealed", + "smart-default", +] + +[[package]] +name = "cucumber-codegen" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a1afaf9c422380861111c6be56f39b324e351fd9efc07a1486268798bf79cfd" +dependencies = [ + "cucumber-expressions", + "inflections", + "itertools", + "proc-macro2", + "quote", + "regex", + "syn", + "synthez", +] + +[[package]] +name = "cucumber-expressions" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6401038de3af44fe74e6fccdb8a5b7db7ba418f480c8e9ad584c6f65c05a27a6" +dependencies = [ + "derive_more", + "either", + "nom", + "nom_locate", + "regex", + "regex-syntax", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + +[[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.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[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" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[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-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[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-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "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 = "gherkin" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70197ce7751bfe8bc828e3a855502d3a869a1e9416b58b10c4bde5cf8a0a3cb3" +dependencies = [ + "heck", + "peg", + "quote", + "serde", + "serde_json", + "syn", + "textwrap", + "thiserror", + "typed-builder", +] + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags", + "ignore", + "walkdir", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "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 = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[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 = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "iii-sdk" +version = "0.13.0-next.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17cf728e12b30b11c37295304e0a5807cf677cca430b66609b7ae3c6044c358d" +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 = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inflections" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[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.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +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 = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.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 = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom_locate" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" +dependencies = [ + "bytecount", + "memchr", + "nom", +] + +[[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 = "peg" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f76678828272f177ac33b7e2ac2e3e73cc6c1cd1e3e387928aa69562fa51367" +dependencies = [ + "peg-macros", + "peg-runtime", +] + +[[package]] +name = "peg-macros" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "636d60acf97633e48d266d7415a9355d4389cea327a193f87df395d88cd2b14d" +dependencies = [ + "peg-runtime", + "proc-macro2", + "quote", +] + +[[package]] +name = "peg-runtime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555b1514d2d99d78150d3c799d4c357a3e2c2a8062cd108e93a06d9057629c5" + +[[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.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +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 = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "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", +] + +[[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 = "sealed" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107" +dependencies = [ + "proc-macro2", + "quote", + "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.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +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 = "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 = "smart-default" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + +[[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 = "synthez" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d8a928f38f1bc873f28e0d2ba8298ad65374a6ac2241dabd297271531a736cd" +dependencies = [ + "syn", + "synthez-codegen", + "synthez-core", +] + +[[package]] +name = "synthez-codegen" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fb83b8df4238e11746984dfb3819b155cd270de0e25847f45abad56b3671047" +dependencies = [ + "syn", + "synthez-core", +] + +[[package]] +name = "synthez-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "906fba967105d822e7c7ed60477b5e76116724d33de68a585681fb253fc30d5c" +dependencies = [ + "proc-macro2", + "quote", + "sealed", + "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 = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +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.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[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 = "typed-builder" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[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.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +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.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +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 = "which" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" +dependencies = [ + "libc", +] + +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +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/coder/Cargo.toml b/coder/Cargo.toml new file mode 100644 index 000000000..d1ce6c153 --- /dev/null +++ b/coder/Cargo.toml @@ -0,0 +1,52 @@ +[workspace] + +[package] +name = "coder" +version = "0.1.0" +edition = "2021" +publish = false + +[[bin]] +name = "coder" +path = "src/main.rs" + +[lib] +path = "src/lib.rs" + +[dependencies] +iii-sdk = "=0.13.0-next.1" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal", "time", "fs", "io-util"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +schemars = "0.8" +anyhow = "1" +thiserror = "2" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +clap = { version = "4", features = ["derive", "env"] } +globset = "0.4" +walkdir = "2" +regex = "1" + +[dev-dependencies] +tempfile = "3" +serde_json = "1" +which = "8" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "signal"] } +# BDD test runner. Drives Gherkin .feature files under tests/features/. +cucumber = "0.22" +# Used by step defs that need to drive multiple awaits in fixed order. +futures = "0.3" + +# Single BDD entry point. cucumber needs full control of the runner, so +# `harness = false` is required. All .feature files under +# `tests/features/**` are discovered at runtime; tags (`@engine`, +# `@security`, ...) let you filter to a subset: +# +# cargo test --test bdd # everything +# cargo test --test bdd -- --tags @search # one tag +# cargo test --test bdd -- --tags "@engine and not @unix" +[[test]] +name = "bdd" +harness = false diff --git a/coder/README.md b/coder/README.md new file mode 100644 index 000000000..1fdfa7218 --- /dev/null +++ b/coder/README.md @@ -0,0 +1,162 @@ +# coder + +A path-jailed code worker for iii agents. `coder::*` lets agents read, +search, edit, create, and delete files inside a single configured +`base_path` — without ever escaping it via `..`, absolute paths, or +symlinks. A glob-based `non_accessible` list keeps sensitive files +(`.env`, `*.pem`, anything under `secrets/`) visible to directory +listings but unreadable and unwritable. + +## Install + +```bash +iii worker add coder +``` + +`iii worker add` fetches the binary, writes a config block into +`~/.iii/config.yaml`, and the engine starts the worker on the next +`iii start`. + +## Quickstart + +```rust +use iii_sdk::{register_worker, InitOptions, TriggerRequest}; +use serde_json::json; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let iii = register_worker("ws://localhost:49134", InitOptions::default()); + + // Create a file. + iii.trigger(TriggerRequest { + function_id: "coder::create-file".into(), + payload: json!({ + "files": [{ + "path": "notes.md", + "content": "# notes\n- one\n- two\n", + "overwrite": false + }] + }), + action: None, + timeout_ms: Some(5_000), + }).await?; + + // Apply two ops bottom-up in a single batch. + iii.trigger(TriggerRequest { + function_id: "coder::update-file".into(), + payload: json!({ + "files": [{ + "path": "notes.md", + "ops": [ + { "op": "insert", "at_line": 2, "content": "draft" }, + { "op": "update_lines", "from_line": 3, "to_line": 3, "content": "- ONE" } + ] + }] + }), + action: None, + timeout_ms: Some(5_000), + }).await?; + + // Read it back. + let read = iii.trigger(TriggerRequest { + function_id: "coder::read-file".into(), + payload: json!({ "path": "notes.md" }), + action: None, + timeout_ms: Some(5_000), + }).await?; + println!("{read:#?}"); + + Ok(()) +} +``` + +## Functions + +| Function id | What it does | +|---|---| +| `coder::read-file` | Read a single file (capped at `max_read_bytes`). | +| `coder::search` | Search file contents (literal/regex) and/or paths under `base_path`. | +| `coder::update-file` | Apply batched `insert` / `remove` / `update_lines` / regex `replace` ops across one or more files. Line ops bottom-up; atomic per file. | +| `coder::create-file` | Create one or more files with `overwrite` and `parents` flags. | +| `coder::delete-file` | Remove one or more paths; `recursive: true` required for non-empty dirs. | +| `coder::list-folder` | Paginated single-folder listing; non-accessible entries flagged. | +| `coder::tree` | Recursive snapshot bounded by `max_depth` and `per_folder_limit`. | + +### `coder::update-file` semantics + +Line ops (`insert`, `remove`, `update_lines`) use **1-based inclusive** +line numbers and are applied **bottom-up** (highest affected line +first), so each op still references the original line numbers from the +caller's perspective. Overlapping line ops are rejected (`C210`). +Regex `replace` ops run after line ops on the full file body. The +whole batch is committed via a sibling temp file + rename, so a failure +mid-write leaves the original file intact. + +```jsonc +{ + "files": [{ + "path": "schema.sql", + "ops": [ + { "op": "insert", "at_line": 1, "content": "-- header\n-- v2" }, + { "op": "remove", "from_line": 5, "to_line": 12 }, + { "op": "update_lines", "from_line": 30, "to_line": 30, "content": "PRIMARY KEY (id)" }, + { "op": "replace", "pattern": "OLD_", "replacement": "NEW_" } + ] + }] +} +``` + +### Error codes + +All errors return as JSON strings of the form `{"code":"C2xx","message":"..."}`. + +| Code | Meaning | +|---|---| +| `C210` | Bad input (malformed payload, illegal line numbers, overlapping ops, absolute path, …) | +| `C211` | Path not found OR matches a `non_accessible_globs` entry | +| `C213` | File exceeds `max_read_bytes` or `max_write_bytes` | +| `C215` | Path escapes `base_path` lexically or through a symlink | +| `C216` | Underlying I/O error | +| `C217` | `coder::create-file` saw an existing file with `overwrite=false` | + +## Configuration + +```yaml +base_path: ./ # root every coder::* call is scoped under +non_accessible_globs: # listable but unreadable/unwritable + - "**/.env" + - "**/.env.*" + - "**/*.pem" + - "**/*.key" + - "**/secrets/**" +max_read_bytes: 10485760 # per-file read cap (10 MiB) +max_write_bytes: 10485760 # per-file create/update cap (10 MiB) +tree_default_depth: 4 # coder::tree depth when unset +tree_per_folder_limit: 50 # children before tree truncates a folder +list_default_page_size: 100 # coder::list-folder default page size +list_max_page_size: 1000 # hard cap on coder::list-folder page_size +search_default_max_matches: 1000 # coder::search match cap +search_default_max_line_bytes: 4096 # per-line cap when scanning content +``` + +`non_accessible_globs` uses the same syntax as the `globset` crate (so +`**/`, `*`, `?`, character classes, …). Matching is done against the +*relative path* from `base_path`, so `**/.env` blocks `.env`, +`a/.env`, and `a/b/.env`. + +## Security boundary + +- `base_path` is canonicalised at startup; the worker refuses to start + if it can't be reached. +- Every wire path must be **relative** to `base_path`; absolute paths + return `C210` rather than being silently re-jailed. +- `..` and symlinks are resolved against the longest existing ancestor + and rejected if they leave `base_path` (`C215`). Dangling symlinks + in the tail are also rejected because the kernel would otherwise + follow them on the next syscall. +- Non-accessible globs apply to reads as well as writes — the same + glob hides the file from `coder::read-file`, `coder::update-file`, + `coder::create-file`, `coder::delete-file`, and from + `coder::search`'s content/path matches. +- Recursive `coder::delete-file` refuses to descend through a subtree + that contains a non-accessible entry rather than removing it. diff --git a/coder/build.rs b/coder/build.rs new file mode 100644 index 000000000..0d01da975 --- /dev/null +++ b/coder/build.rs @@ -0,0 +1,6 @@ +fn main() { + println!( + "cargo:rustc-env=TARGET={}", + std::env::var("TARGET").expect("TARGET must be set by Cargo build scripts") + ); +} diff --git a/coder/config.yaml b/coder/config.yaml new file mode 100644 index 000000000..a545ca34c --- /dev/null +++ b/coder/config.yaml @@ -0,0 +1,36 @@ +# Root directory the worker operates inside. Every path argument in the +# coder::* API is resolved relative to this directory; absolute paths and +# `..` segments that would escape are rejected with C215. Required; the +# worker refuses to start if this can't be canonicalized. +base_path: ./ + +# Glob patterns (matched against the path relative to base_path) for files +# the worker will refuse to read, modify, or delete. Matching entries are +# still visible to coder::list-folder and coder::tree with a +# `non_accessible: true` flag so callers can see they exist. +non_accessible_globs: + - "**/.env" + - "**/.env.*" + - "**/*.pem" + - "**/*.key" + - "**/secrets/**" + +# Per-file caps. read returns C213 when a file exceeds max_read_bytes; +# create/update reject payloads larger than max_write_bytes. +max_read_bytes: 10485760 +max_write_bytes: 10485760 + +# coder::tree defaults. Depth counts the base folder as depth 0. Per-folder +# limit truncates very large folders and surfaces a hint pointing to +# coder::list-folder for pagination. +tree_default_depth: 4 +tree_per_folder_limit: 50 + +# coder::list-folder pagination. page_size is capped at list_max_page_size +# regardless of what the caller requests. +list_default_page_size: 100 +list_max_page_size: 1000 + +# coder::search caps. truncated:true is set whenever either cap is hit. +search_default_max_matches: 1000 +search_default_max_line_bytes: 4096 diff --git a/coder/iii.worker.yaml b/coder/iii.worker.yaml new file mode 100644 index 000000000..d1f1f94f1 --- /dev/null +++ b/coder/iii.worker.yaml @@ -0,0 +1,22 @@ +iii: v1 +name: coder +language: rust +deploy: binary +manifest: Cargo.toml +bin: coder +description: Path-jailed code worker — read/search/update/create/delete files plus paginated list-folder and tree, with non-accessible glob protection. + +targets: + - x86_64-apple-darwin + - aarch64-apple-darwin + - x86_64-unknown-linux-gnu + - x86_64-unknown-linux-musl + - aarch64-unknown-linux-gnu + - armv7-unknown-linux-gnueabihf + +runtime: + kind: rust + +scripts: + install: cargo build + start: cargo run diff --git a/coder/skills/coder.md b/coder/skills/coder.md new file mode 100644 index 000000000..8ff5ba39e --- /dev/null +++ b/coder/skills/coder.md @@ -0,0 +1,614 @@ +--- +type: how-to +functions: [coder::read-file, coder::search, coder::list-folder, coder::tree, coder::create-file, coder::update-file, coder::delete-file] +title: Read, search, edit, and manage files inside the jailed base_path +--- + +# When to use + +The `coder::*` surface is the agent's single tool for filesystem work +inside the operator-configured `base_path`. Every call resolves its +`path` argument relative to that root, refuses anything that would +escape it (absolute inputs, `..` segments, symlinks that point out), +and screens reads + writes against `non_accessible_globs` so secret +files (`.env`, `*.pem`, `secrets/**`) stay invisible to the +content-touching functions even when they appear in directory +listings. Each function applies the same security model — callers +never have to re-check. + +| Question | Use this | +|-------------------------------------------------------|-----------------------| +| Get a bird's-eye view of an unfamiliar repo | `coder::tree` | +| List one folder, paginated | `coder::list-folder` | +| Find a string or regex across many files | `coder::search` | +| Read one file's contents | `coder::read-file` | +| Make a new file (optionally in a fresh subtree) | `coder::create-file` | +| Edit existing files line-by-line | `coder::update-file` | +| Remove files or directories | `coder::delete-file` | + +Reach for `shell::exec` (in the [`shell`](../../shell/README.md) +worker) instead when you need to run a process — build, test, format, +git, package manager, anything that spawns an executable. `coder::*` +is filesystem-only; it never shells out. + +# `coder::tree` + +Recursive directory snapshot, bounded so the response stays small even +for monorepo roots. Use it as the first call against an unfamiliar +codebase: one round-trip gives you the shape of the project up to +`max_depth` levels, and folders that hit `per_folder_limit` are +flagged with a `truncated` block that points you at +`coder::list-folder` for paginated drilldown. + +## Inputs + +```json +{ + "path": ".", // optional; folder relative to base_path; defaults to "." + "max_depth": 4, // optional; falls back to config.tree_default_depth (4) + "per_folder_limit": 50 // optional; falls back to config.tree_per_folder_limit (50) +} +``` + +No fields are required — `coder::tree {}` snapshots the whole +`base_path` at default depth and per-folder limit. `path` must point +at a directory; pointing it at a file returns `C210`. The root node is +depth 0, so `max_depth: 1` lists the root's immediate children and +stops. + +## Outputs + +```json +{ + "root": { + "name": "base", // basename of `path` + "path": "", // relative path under base_path; "" for the root + "kind": "dir", // "file" | "dir" | "symlink" | "other" + "size": 4096, + "mtime": 1716470000, // Unix epoch seconds + "non_accessible": false, // omitted when false + "children": [ + { + "name": "src", + "path": "src", + "kind": "dir", + "size": 4096, + "mtime": 1716470000, + "children": [ + { "name": "main.rs", "path": "src/main.rs", "kind": "file", "size": 312, "mtime": 1716470000 } + ] + } + ], + "truncated": { // omitted when the folder fit + "reason": "per_folder_limit", // "per_folder_limit" | "max_depth" | "io_error" + "shown": 50, + "total": 237, // populated only when reason == "per_folder_limit" + "hint": "use coder::list-folder for paginated access to all entries" + } + } +} +``` + +- Children are sorted lex by `name` per folder. +- `children` is omitted on file nodes and on truncated `max_depth` + nodes (the walk stops before reading the folder). +- `non_accessible: true` propagates from a `non_accessible_globs` + match on the entry's relative path — the entry is still listed in + the tree, but the content-touching functions will refuse it. +- `truncated.reason` is `"per_folder_limit"` when the folder had more + than `per_folder_limit` children (the response carries the first + `shown` children and the real `total`), `"max_depth"` when the walk + hit the depth cap (no `children`, no `total`), or `"io_error"` when + the folder couldn't be read (the `hint` carries the underlying + message). + +# `coder::list-folder` + +Paginated single-folder listing. Use it when `coder::tree` returns a +truncated folder you need to enumerate fully, or when you already know +which folder you want and don't need its subtree. Non-accessible +entries are still returned with a `non_accessible: true` flag so the +agent can tell that a `.env` exists without being able to read it. + +## Inputs + +```json +{ + "path": "src", // optional; folder relative to base_path; defaults to "." + "page": 1, // optional; 1-based; defaults to 1 + "page_size": 100 // optional; falls back to config.list_default_page_size (100); capped at list_max_page_size (1000) +} +``` + +`path` must resolve to a directory; pointing it at a file returns +`C210`. `page_size` above `list_max_page_size` is silently clamped +down. `page` values past the end return `entries: []` with +`has_more: false`, not an error. + +## Outputs + +```json +{ + "path": "src", + "entries": [ + { + "name": ".env", + "kind": "file", // "file" | "dir" | "symlink" | "other" + "size": 512, + "mtime": 1716470000, // Unix epoch seconds + "non_accessible": true // matched a non_accessible_globs pattern + }, + { + "name": "main.rs", + "kind": "file", + "size": 312, + "mtime": 1716470000, + "non_accessible": false + } + ], + "total": 2, // total entries in the folder, across all pages + "page": 1, + "page_size": 100, + "has_more": false +} +``` + +- Entries are sorted lex by `name` (dotfiles first per Unix + convention). +- `total` reflects the full folder, not the current page — divide by + `page_size` to know how many pages exist. +- `has_more` is `true` when there's at least one more page after this + one. + +# `coder::search` + +Combined content + path search across the whole jailed tree. Walks +`base_path`, applies the include/exclude globs, skips non-accessible +files entirely (their bytes never reach the matcher), and reports +content hits and path hits in separate arrays of one response. + +## Inputs + +```json +{ + "query": "TODO", // required; non-empty + "path": ".", // optional; folder relative to base_path scoping the walk; default "."; must be a directory + "regex": false, // optional; default false (literal substring); when true, query is a regex::Regex pattern + "ignore_case": false, // optional; default false; applies to both literal and regex modes + "include_globs": ["**/*.rs"], // optional; globs relative to base_path; empty = include everything + "exclude_globs": ["build/**"], // optional; globs relative to base_path; empty = exclude nothing + "max_matches": 1000, // optional; falls back to config.search_default_max_matches (1000) + "max_line_bytes": 4096, // optional; falls back to config.search_default_max_line_bytes (4096) + "search_content": true, // optional; default true; scan file contents + "search_paths": true // optional; default true; match the relative path itself +} +``` + +`query` is required and must be non-empty. At least one of +`search_content` / `search_paths` must be `true` — both `false` +returns `C210`. Glob syntax is the `globset` crate's (`**/`, `*`, +`?`, character classes); a bad glob or bad regex returns `C210` with +the offending pattern in the message. + +Binary files (any NUL byte in the first read) are skipped from +content scanning. Files larger than `max_read_bytes` are also skipped +to keep the search from loading multi-GB blobs. + +## Outputs + +```json +{ + "content_matches": [ + { + "path": "src/main.rs", // relative to base_path + "line": 42, // 1-based + "column": 5, // 1-based, byte offset of the first match on the line + "text": " // TODO: handle EOF" // the full matched line, truncated to max_line_bytes + } + ], + "path_matches": [ + { "path": "docs/TODO.md" } + ], + "truncated": false // true when either match list was capped at max_matches +} +``` + +- A line that exceeds `max_line_bytes` is truncated for both matching + and reporting; the matcher never sees the overflowed tail. +- Each line emits at most one match (the first hit) — `truncated` + reports per-file/per-line saturation, not per-character. +- `path` defaults to `.` (the whole jail). Set it to a subfolder + (e.g. `"src"`) to scope the walk; globs and result `path`s remain + anchored at `base_path` so `include_globs: ["src/**/*.rs"]` keeps + working regardless of `path`. Pointing `path` at a file (not a + directory) returns `C210`. +- When `truncated: true`, tighten the pattern or narrow with + `include_globs` / `exclude_globs` rather than bumping `max_matches` + blindly; the cap exists to keep responses bounded. + +# `coder::read-file` + +Read one file's bytes and metadata. Use it after `coder::search` +surfaces an interesting hit, or whenever you have a path in hand and +want the full content (not just a snippet). The single-file shape +keeps the call cheap; for many small files prefer a `coder::tree` plus +targeted reads over scanning the whole tree. + +## Inputs + +```json +{ "path": "src/main.rs" } // required; file relative to base_path +``` + +`path` is required. Pointing it at a directory returns `C210`; +pointing it at a non-accessible match returns `C211` (folded with +"not found" so callers can't probe). Files above `max_read_bytes` +(default 10 MiB) return `C213` before any bytes are loaded. + +## Outputs + +```json +{ + "path": "src/main.rs", // echoed for caller correlation + "content": "fn main() {\n println!(\"hi\");\n}\n", + "is_utf8": true, // false when invalid bytes were lossily replaced with U+FFFD + "size": 34, // raw on-disk size in bytes + "mode": 420, // Unix permission bits as decimal (0o644 == 420); 0o644 on non-Unix + "mtime": 1716470000 // Unix epoch seconds; 0 when the FS can't expose it +} +``` + +- `content` is always a string — binary or invalid-UTF-8 inputs are + decoded with `String::from_utf8_lossy` and the `is_utf8: false` + flag tells callers the byte count won't match exactly. +- `size` reflects on-disk bytes; `content.len()` may differ when + `is_utf8: false` because of U+FFFD substitution. + +# `coder::create-file` + +Batched file creation. Each entry in `files[]` is treated +independently — a single bad path doesn't fail the rest of the batch; +instead its `results[i]` slot carries `success: false` and the JSON +error envelope. Use it to scaffold a fresh subtree (one call with +several entries, `parents: true`) or to write a single new file +without the read-modify-write dance of `coder::update-file`. + +## Inputs + +```json +{ + "files": [ + { + "path": "tests/foo_test.rs", // required; relative to base_path + "content": "#[test]\nfn it_works() {}",// required; UTF-8 string written verbatim + "mode": "0644", // optional; octal as string; default "0644"; Unix only + "parents": true, // optional; create missing parent dirs; default true + "overwrite": false // optional; refuse on existing path when false; default false + } + ] +} +``` + +`files` must contain at least one entry; `[]` returns `C210`. Per +entry, `path` and `content` are required. `parents: true` runs +`create_dir_all` on the parent before writing. `overwrite: false` on +an existing path yields `C217` in that entry's result (the rest of +the batch still runs). + +## Outputs + +```json +{ + "results": [ + { + "path": "tests/foo_test.rs", + "success": true, + "bytes_written": 28 + // `error` omitted on success + }, + { + "path": ".env", + "success": false, + "bytes_written": 0, + "error": "{\"code\":\"C211\",\"message\":\".env: matches non_accessible_globs\"}" + } + ] +} +``` + +- One result per input entry, in input order. +- `error` is omitted on success; on failure it's the per-function + JSON envelope (`{"code":"C2xx","message":"..."}`) the same way + top-level errors are reported. +- `bytes_written` is `content.as_bytes().len()` on success, `0` on + failure. + +# `coder::update-file` + +Batched line-oriented and regex edits. Each file in `files[]` carries +an array of ops; all ops on one file commit **atomically** via a +sibling temp file + `rename`, so a mid-write failure leaves the +original byte-identical. Across files the batch is independent — one +file's failure doesn't roll back another's. + +Line numbers are **1-based and inclusive**. Line ops (`insert`, +`remove`, `update_lines`) within one file are applied **bottom-up** +(highest affected line first), so each op still references the original +line numbers the caller wrote — there's no need to recompute offsets +after an earlier op. Overlapping line ops in original-line space are +rejected up-front with `C210`. Regex `replace` ops run **after** all +line ops on the serialized file body, in declaration order, and do not +participate in line-space overlap checks. + +## Inputs + +```json +{ + "files": [ + { + "path": "schema.sql", // required; relative to base_path + "ops": [ + { "op": "insert", "at_line": 1, "content": "-- header\n-- v2" }, + { "op": "remove", "from_line": 5, "to_line": 12 }, + { "op": "update_lines", "from_line": 30, "to_line": 30, "content": "PRIMARY KEY (id)" }, + { "op": "replace", "pattern": "OLD_NAME", "replacement": "NEW_NAME" } + ] + } + ] +} +``` + +`files` must contain at least one entry, and each entry's `ops` must +contain at least one op — both empties return `C210`. + +Each op shape: + +- `insert` — insert `content` **before** `at_line` (1-based). The + special value `at_line = lines + 1` appends to the end. +- `remove` — delete the inclusive range `from_line..=to_line`. +- `update_lines` — overwrite the inclusive range with `content` (split + by `\n`). +- `replace` — substitute all regex matches in the file body. Fields: + `pattern` (required regex), `replacement` (required; Rust capture + syntax such as `$1` / `$name`), optional `ignore_case` (default + `false`). Runs after line ops in the same batch. Empty `pattern` or + invalid regex returns `C210`. No matches is a no-op; the op still + counts toward `applied`. + +`content` on line ops may be multi-line; lines are split on `\n` and +`\r` from CRLF inputs is trimmed. File line endings (`\n` vs `\r\n`) +and the presence/absence of a trailing newline are preserved across +the rewrite (regex `replace` operates on the joined body and may change +line count if the replacement introduces or removes newlines). + +## Outputs + +```json +{ + "results": [ + { + "path": "schema.sql", + "success": true, + "applied": 3, // count of ops applied (only meaningful on success) + "new_line_count": 27 // final line count after the rewrite + // `error` omitted on success + }, + { + "path": ".env", + "success": false, + "applied": 0, + "new_line_count": 0, + "error": "{\"code\":\"C211\",\"message\":\".env: matches non_accessible_globs\"}" + } + ] +} +``` + +- One result per input file, in input order. +- `applied` and `new_line_count` are `0` on failure; on success + `applied` equals `ops.len()` for that file. +- `error` is omitted on success; on failure it carries the per-file + JSON error envelope. + +Overlap semantics for line ops (each rejected with `C210`): + +- Two `remove` / `update_lines` ranges sharing any line. +- An `insert` at a line covered by a `remove` / `update_lines` range. +- Two `insert`s at the same `at_line`. +- Any range with `from_line == 0`, `from_line > to_line`, or + `to_line` past EOF. + +# `coder::delete-file` + +Batched removal. Like `create-file` and `update-file`, per-path errors +land in `results[i]` instead of failing the whole batch. Missing paths +are **idempotent successes** (`success: true, removed: false`) — safe +to retry. Directories require an explicit `recursive: true`; without +it, a non-empty directory returns an error in its result slot. + +## Inputs + +```json +{ + "paths": [".cache/build", "stale.log"], // required; non-empty; each relative to base_path + "recursive": true // optional; default false; required for non-empty directories +} +``` + +`paths` must contain at least one entry; `[]` returns `C210`. Trying +to delete `base_path` itself (e.g. `"."` resolving to the root) is +rejected with `C210` regardless of `recursive`. + +When `recursive: true`, the walk **refuses to descend through any +subtree containing a `non_accessible_globs` match**. The whole +directory remains untouched and the result reports `C211` with the +offending child — agents can't wipe out a `secrets/` folder by +deleting its parent. + +## Outputs + +```json +{ + "results": [ + { + "path": ".cache/build", + "success": true, + "removed": true // false when the path was already gone + // `error` omitted on success + }, + { + "path": "stale.log", + "success": true, + "removed": false // file didn't exist; treated as a no-op success + }, + { + "path": "secrets", + "success": false, + "removed": false, + "error": "{\"code\":\"C211\",\"message\":\"recursive delete blocked: secrets contains non-accessible secrets/api.pem\"}" + } + ] +} +``` + +- One result per input path, in input order. +- `removed: false` with `success: true` means the path didn't exist + at call time — the post-condition (the path is absent) is true, so + the call is idempotent. + +# Errors + +All `coder::*` errors serialize as `{"code":"C2xx","message":"..."}`. +Top-level failures (e.g. an empty `files: []`) come back as the +function's own `Result::Err`; the batched functions +(`create-file`, `update-file`, `delete-file`) instead embed the same +envelope inside each `results[i].error` so a single bad path never +aborts the whole call. + +| Code | Meaning | +|--------|-----------------------------------------------------------------------------------------------| +| `C210` | Bad input — malformed payload, illegal line numbers, overlapping ops, absolute path, bad glob/regex, refusing to delete `base_path` itself. | +| `C211` | Path not found **or** matches `non_accessible_globs`. Folded into one code so callers can't probe for the existence of a denied file. | +| `C213` | File exceeds `max_read_bytes` (read side) or `max_write_bytes` (create/update). For `update-file` this fires on either the pre-edit size or the post-edit size. | +| `C215` | Path escapes `base_path` lexically (`..`) or via a symlink, or the symlink target dangles outside the jail. | +| `C216` | Underlying I/O error (permission denied, EIO, …). The `message` carries the OS error string. | +| `C217` | `coder::create-file` saw an existing path with `overwrite: false`. Set `overwrite: true` to replace, or pick a different `path`. | + +# Worked example + +A typical read-explore-edit pass: snapshot the project, find an open +TODO, read it in context, rewrite the offending block, then add a +companion test file. + +1. Get the shape of the project: + + ```json + { "path": "src" } + ``` + + Sent to `coder::tree`. If a folder comes back with + `truncated.reason == "per_folder_limit"`, follow up with + `coder::list-folder` against that folder. + +2. Find every open TODO in the Rust sources: + + ```json + { + "query": "TODO", + "include_globs": ["**/*.rs"], + "exclude_globs": ["build/**", "target/**"] + } + ``` + + Sent to `coder::search`. The response carries + `content_matches[i]` with `path`, `line`, `column`, and the + matched line `text`. + +3. Read the file around a hit: + + ```json + { "path": "src/foo.rs" } + ``` + + Sent to `coder::read-file`. `content` is the full file as a + string; index into it using the `line` / `column` from step 2. + +4. Rewrite the block: + + ```json + { + "files": [{ + "path": "src/foo.rs", + "ops": [ + { "op": "update_lines", "from_line": 42, "to_line": 45, + "content": " let payload = parse(input)?;\n handle(payload)?;\n Ok(())" } + ] + }] + } + ``` + + Sent to `coder::update-file`. The response's `results[0]` carries + `success: true`, `applied: 1`, and the new `new_line_count`. + + To rename a symbol everywhere in the file without touching line + numbers, add a regex `replace` after any line ops: + + ```json + { "op": "replace", "pattern": "fn old_name", "replacement": "fn new_name" } + ``` + +5. Scaffold a companion test in one call (notice `parents: true` + creates `tests/` if it isn't there yet): + + ```json + { + "files": [{ + "path": "tests/foo_test.rs", + "content": "use my_crate::foo;\n\n#[test]\nfn it_works() {\n assert!(foo::handle(\"x\").is_ok());\n}\n", + "parents": true, + "overwrite": false + }] + } + ``` + + Sent to `coder::create-file`. If `tests/foo_test.rs` already + exists, the result carries `C217` and the original file stays put + — flip `overwrite: true` and resend if you meant to replace it. + +# Side effects + +Three functions write to disk. None of them fire engine triggers or +emit events — the only observable effect is the filesystem mutation +described below. + +- `coder::create-file` writes each entry's `content.as_bytes()` to + `base_path/path` with `std::fs::write`. When `parents: true`, runs + `std::fs::create_dir_all` on the parent first. On Unix, applies + `mode` (octal string parsed with `u32::from_str_radix(..., 8)`, + masked with `0o777`); on non-Unix the `mode` field is accepted but + ignored. +- `coder::update-file` writes via a sibling temp file named + `.coder-tmp--` in the target's parent + directory, then `std::fs::rename`s it over the original. A crash + mid-write leaves the original byte-identical; in rare cases an + orphan temp file may remain (it's safe to remove manually). Line + endings (`\n` vs `\r\n`) and trailing-newline presence are + preserved from the original file. +- `coder::delete-file` calls `std::fs::remove_file` for files and + empty dirs, `std::fs::remove_dir_all` when `recursive: true`. The + recursive path walks the subtree first and aborts with `C211` + (without removing anything) if **any** descendant matches + `non_accessible_globs` — protecting against accidentally wiping a + `secrets/` subtree by deleting its parent. + +# Related + +- [`shell::exec`](../../shell/README.md) and `shell::fs::*` — when + you need to run a process (build, test, format, git) or stream + bytes through a channel. `coder::*` never shells out. +- [`directory::skills::get`](iii://directory/skills/get) — the + iii-directory worker that surfaces this how-to (and others) to + agents at bootstrap time. +- The "Security boundary" section of [coder/README.md](../README.md) + — operator-facing detail on `base_path` canonicalisation, + `non_accessible_globs` syntax, and the symlink rejection rules. diff --git a/coder/skills/index.md b/coder/skills/index.md new file mode 100644 index 000000000..6fe923134 --- /dev/null +++ b/coder/skills/index.md @@ -0,0 +1,39 @@ +--- +type: index +title: coder +--- + +# coder + +A path-jailed code worker for iii agents. Every `coder::*` call resolves +its `path` argument relative to a single configured `base_path` and +refuses anything that would escape it — absolute inputs, `..` segments, +or crafted symlinks all return an error rather than being silently +re-jailed. A glob-based `non_accessible_globs` list keeps sensitive +files (`.env`, `*.pem`, anything under `secrets/`) visible to directory +listings but unreadable, unwritable, and unsearchable. One worker, one +namespace, one security boundary. + +The surface covers the full read-explore-edit cycle: `coder::tree` and +`coder::list-folder` for navigation, `coder::search` for content/path +discovery, `coder::read-file` for inspection, and the batched +`coder::create-file` / `coder::update-file` / `coder::delete-file` +mutators for writes. Write functions commit atomically per file (temp +file + rename) so a mid-write failure leaves the original intact. + +- **Files** (`coder::*`) — read, search, explore, create, edit, and + delete files and folders, all scoped to `base_path` and screened by + `non_accessible_globs`. Caps on per-file read/write bytes, per-folder + listing pages, and search match counts are operator-configured. + +## How-tos + +### `coder::*` + +- [`coder::tree`](iii://coder/coder) — recursive snapshot bounded by `max_depth` and `per_folder_limit`; reach for it first on an unfamiliar repo. +- [`coder::list-folder`](iii://coder/coder) — paginated single-folder listing sorted by name; non-accessible entries appear with `non_accessible: true`. +- [`coder::search`](iii://coder/coder) — literal or regex search over file content and/or paths with include/exclude globs. +- [`coder::read-file`](iii://coder/coder) — read a single file as UTF-8 plus `size` / `mode` / `mtime`, capped by `max_read_bytes`. +- [`coder::create-file`](iii://coder/coder) — batched file creation with per-entry `overwrite` and `parents` flags. +- [`coder::update-file`](iii://coder/coder) — batched `insert` / `remove` / `update_lines` / regex `replace` ops; line ops are 1-based, inclusive, applied bottom-up; atomic per file. +- [`coder::delete-file`](iii://coder/coder) — batched removal; `recursive: true` required for non-empty directories, missing paths are idempotent successes. diff --git a/coder/src/config.rs b/coder/src/config.rs new file mode 100644 index 000000000..370301df8 --- /dev/null +++ b/coder/src/config.rs @@ -0,0 +1,166 @@ +//! Operator-facing config. Every field has a `#[serde(default = "…")]` +//! attribute so an empty YAML object still produces a fully-populated +//! struct; `impl Default` mirrors those functions so the binary can fall +//! back to defaults when `--config` is missing or unparseable (see +//! [`binary-worker.md`](../../binary-worker.md) §5). + +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CoderConfig { + /// Root directory the worker operates inside. Every wire path is + /// resolved relative to this. + #[serde(default = "default_base_path")] + pub base_path: PathBuf, + + /// Glob patterns matched against the *relative* path. Matching files + /// can be listed but not read/written/deleted/created. + #[serde(default)] + pub non_accessible_globs: Vec, + + #[serde(default = "default_max_read_bytes")] + pub max_read_bytes: u64, + + #[serde(default = "default_max_write_bytes")] + pub max_write_bytes: u64, + + #[serde(default = "default_tree_default_depth")] + pub tree_default_depth: u32, + + #[serde(default = "default_tree_per_folder_limit")] + pub tree_per_folder_limit: u32, + + #[serde(default = "default_list_default_page_size")] + pub list_default_page_size: u32, + + #[serde(default = "default_list_max_page_size")] + pub list_max_page_size: u32, + + #[serde(default = "default_search_max_matches")] + pub search_default_max_matches: u32, + + #[serde(default = "default_search_max_line_bytes")] + pub search_default_max_line_bytes: u32, +} + +fn default_base_path() -> PathBuf { + PathBuf::from("./") +} +fn default_max_read_bytes() -> u64 { + 10 * 1024 * 1024 +} +fn default_max_write_bytes() -> u64 { + 10 * 1024 * 1024 +} +fn default_tree_default_depth() -> u32 { + 4 +} +fn default_tree_per_folder_limit() -> u32 { + 50 +} +fn default_list_default_page_size() -> u32 { + 100 +} +fn default_list_max_page_size() -> u32 { + 1_000 +} +fn default_search_max_matches() -> u32 { + 1_000 +} +fn default_search_max_line_bytes() -> u32 { + 4_096 +} + +impl Default for CoderConfig { + fn default() -> Self { + Self { + base_path: default_base_path(), + non_accessible_globs: Vec::new(), + max_read_bytes: default_max_read_bytes(), + max_write_bytes: default_max_write_bytes(), + tree_default_depth: default_tree_default_depth(), + tree_per_folder_limit: default_tree_per_folder_limit(), + list_default_page_size: default_list_default_page_size(), + list_max_page_size: default_list_max_page_size(), + search_default_max_matches: default_search_max_matches(), + search_default_max_line_bytes: default_search_max_line_bytes(), + } + } +} + +pub fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path).with_context(|| format!("read {}", path))?; + let cfg: CoderConfig = + serde_yaml::from_str(&content).with_context(|| format!("parse {}", path))?; + Ok(cfg) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_yaml_parses_to_defaults() { + let cfg: CoderConfig = serde_yaml::from_str("{}").expect("empty yaml parses"); + assert_eq!(cfg.base_path, PathBuf::from("./")); + assert!(cfg.non_accessible_globs.is_empty()); + assert_eq!(cfg.max_read_bytes, 10 * 1024 * 1024); + assert_eq!(cfg.max_write_bytes, 10 * 1024 * 1024); + assert_eq!(cfg.tree_default_depth, 4); + assert_eq!(cfg.tree_per_folder_limit, 50); + assert_eq!(cfg.list_default_page_size, 100); + assert_eq!(cfg.list_max_page_size, 1_000); + assert_eq!(cfg.search_default_max_matches, 1_000); + assert_eq!(cfg.search_default_max_line_bytes, 4_096); + } + + #[test] + fn impl_default_matches_yaml_defaults() { + let from_yaml: CoderConfig = serde_yaml::from_str("{}").unwrap(); + let from_default = CoderConfig::default(); + // Compare via JSON so we don't have to derive PartialEq. + let a = serde_json::to_value(&from_yaml).unwrap(); + let b = serde_json::to_value(&from_default).unwrap(); + assert_eq!(a, b); + } + + #[test] + fn custom_yaml_overrides_each_field() { + let yaml = r#" +base_path: /tmp/c +non_accessible_globs: + - "**/.env" +max_read_bytes: 42 +max_write_bytes: 43 +tree_default_depth: 7 +tree_per_folder_limit: 9 +list_default_page_size: 11 +list_max_page_size: 13 +search_default_max_matches: 17 +search_default_max_line_bytes: 19 +"#; + let cfg: CoderConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(cfg.base_path, PathBuf::from("/tmp/c")); + assert_eq!(cfg.non_accessible_globs, vec!["**/.env".to_string()]); + assert_eq!(cfg.max_read_bytes, 42); + assert_eq!(cfg.max_write_bytes, 43); + assert_eq!(cfg.tree_default_depth, 7); + assert_eq!(cfg.tree_per_folder_limit, 9); + assert_eq!(cfg.list_default_page_size, 11); + assert_eq!(cfg.list_max_page_size, 13); + assert_eq!(cfg.search_default_max_matches, 17); + assert_eq!(cfg.search_default_max_line_bytes, 19); + } + + #[test] + fn shipped_config_yaml_parses() { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/config.yaml"); + let content = std::fs::read_to_string(path).expect("read config.yaml"); + let cfg: CoderConfig = serde_yaml::from_str(&content).expect("config.yaml parses"); + assert_eq!(cfg.base_path, PathBuf::from("./")); + assert!(cfg.non_accessible_globs.iter().any(|g| g.contains(".env"))); + } +} diff --git a/coder/src/error.rs b/coder/src/error.rs new file mode 100644 index 000000000..a0949e76a --- /dev/null +++ b/coder/src/error.rs @@ -0,0 +1,121 @@ +//! Worker-wide error type. Serialises to a `{"code":"C2xx","message":"..."}` +//! JSON string that handlers return via `Result<_, String>`; the engine +//! surfaces that string verbatim to callers. +//! +//! Codes mirror `shell::fs::*`'s `S2xx` scheme so consumers can pattern +//! against a stable prefix. + +use serde::Serialize; +use thiserror::Error; + +#[derive(Debug, Error, Serialize)] +#[serde(tag = "code", content = "message")] +pub enum CoderError { + /// Malformed input: bad payload, illegal line numbers, overlapping ops, … + #[error("C210: {0}")] + #[serde(rename = "C210")] + BadInput(String), + + /// Path not found OR matched a `non_accessible_globs` entry. Both are + /// folded into the same code so callers can't probe for the existence + /// of a denied file by toggling the glob. + #[error("C211: {0}")] + #[serde(rename = "C211")] + NotFoundOrDenied(String), + + /// File exceeds `max_read_bytes` or `max_write_bytes`. + #[error("C213: {0}")] + #[serde(rename = "C213")] + TooLarge(String), + + /// Path escapes `base_path` lexically or through a symlink. + #[error("C215: {0}")] + #[serde(rename = "C215")] + OutsideBase(String), + + /// Underlying I/O error. + #[error("C216: {0}")] + #[serde(rename = "C216")] + Io(String), + + /// `create-file` saw an existing file and `overwrite=false`. + #[error("C217: {0}")] + #[serde(rename = "C217")] + AlreadyExists(String), +} + +impl CoderError { + /// Render as a JSON object string suitable for handler return values. + pub fn to_wire_string(&self) -> String { + // serde_json on the enum produces `{"code":"C2xx","message":"..."}` + // thanks to the `tag/content` attributes above. + serde_json::to_string(self) + .unwrap_or_else(|_| format!("{{\"code\":\"C216\",\"message\":\"{self}\"}}")) + } + + pub fn code(&self) -> &'static str { + match self { + CoderError::BadInput(_) => "C210", + CoderError::NotFoundOrDenied(_) => "C211", + CoderError::TooLarge(_) => "C213", + CoderError::OutsideBase(_) => "C215", + CoderError::Io(_) => "C216", + CoderError::AlreadyExists(_) => "C217", + } + } +} + +impl From for CoderError { + fn from(e: std::io::Error) -> Self { + match e.kind() { + std::io::ErrorKind::NotFound => CoderError::NotFoundOrDenied(e.to_string()), + std::io::ErrorKind::AlreadyExists => CoderError::AlreadyExists(e.to_string()), + _ => CoderError::Io(e.to_string()), + } + } +} + +pub fn err_to_string(e: CoderError) -> String { + e.to_wire_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bad_input_serializes_with_code_and_message() { + let s = CoderError::BadInput("nope".into()).to_wire_string(); + let v: serde_json::Value = serde_json::from_str(&s).expect("valid json"); + assert_eq!(v["code"], "C210"); + assert_eq!(v["message"], "nope"); + } + + #[test] + fn io_not_found_maps_to_c211() { + let e: CoderError = std::io::Error::new(std::io::ErrorKind::NotFound, "x").into(); + assert_eq!(e.code(), "C211"); + } + + #[test] + fn io_already_exists_maps_to_c217() { + let e: CoderError = std::io::Error::new(std::io::ErrorKind::AlreadyExists, "x").into(); + assert_eq!(e.code(), "C217"); + } + + #[test] + fn each_variant_has_distinct_code() { + use std::collections::HashSet; + let codes: HashSet<&str> = [ + CoderError::BadInput("a".into()).code(), + CoderError::NotFoundOrDenied("a".into()).code(), + CoderError::TooLarge("a".into()).code(), + CoderError::OutsideBase("a".into()).code(), + CoderError::Io("a".into()).code(), + CoderError::AlreadyExists("a".into()).code(), + ] + .into_iter() + .collect(); + assert_eq!(codes.len(), 6); + } +} diff --git a/coder/src/functions/create_file.rs b/coder/src/functions/create_file.rs new file mode 100644 index 000000000..d31a26929 --- /dev/null +++ b/coder/src/functions/create_file.rs @@ -0,0 +1,341 @@ +//! `coder::create-file` — write one or more new files. Each entry is +//! treated independently so a single bad input never aborts the rest. +//! Non-accessible paths and oversized payloads are rejected. + +use std::path::Path; +use std::sync::Arc; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::config::CoderConfig; +use crate::error::{err_to_string, CoderError}; +use crate::path::PathResolver; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateFileInput { + pub files: Vec, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateFileSpec { + pub path: String, + pub content: String, + /// Octal permission bits as a string, e.g. "0644". Defaults to "0644". + #[serde(default = "default_mode")] + pub mode: String, + /// Create missing parent directories. Defaults to true so a single + /// `coder::create-file` call can scaffold a fresh subtree. + #[serde(default = "default_true")] + pub parents: bool, + /// When false (the default), refuse to write if `path` already exists. + #[serde(default)] + pub overwrite: bool, +} + +fn default_mode() -> String { + "0644".to_string() +} +fn default_true() -> bool { + true +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct CreateFileOutput { + pub results: Vec, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct CreateFileResult { + pub path: String, + pub success: bool, + pub bytes_written: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +pub async fn handle( + resolver: Arc, + cfg: Arc, + req: CreateFileInput, +) -> Result { + if req.files.is_empty() { + return Err(err_to_string(CoderError::BadInput( + "`files` must not be empty".into(), + ))); + } + let mut results = Vec::with_capacity(req.files.len()); + for spec in req.files { + results.push(create_one(&resolver, &cfg, spec)); + } + Ok(CreateFileOutput { results }) +} + +fn create_one( + resolver: &PathResolver, + cfg: &CoderConfig, + spec: CreateFileSpec, +) -> CreateFileResult { + let path = spec.path.clone(); + match try_create_one(resolver, cfg, spec) { + Ok(bytes) => CreateFileResult { + path, + success: true, + bytes_written: bytes, + error: None, + }, + Err(e) => CreateFileResult { + path, + success: false, + bytes_written: 0, + error: Some(e.to_wire_string()), + }, + } +} + +fn try_create_one( + resolver: &PathResolver, + cfg: &CoderConfig, + spec: CreateFileSpec, +) -> Result { + let abs = resolver.require_writable(&spec.path)?; + let bytes = spec.content.as_bytes(); + if (bytes.len() as u64) > cfg.max_write_bytes { + return Err(CoderError::TooLarge(format!( + "{} bytes exceeds max_write_bytes {}", + bytes.len(), + cfg.max_write_bytes + ))); + } + if abs.exists() && !spec.overwrite { + return Err(CoderError::AlreadyExists(format!( + "{} already exists; pass overwrite=true to replace", + spec.path + ))); + } + if spec.parents { + if let Some(parent) = abs.parent() { + std::fs::create_dir_all(parent).map_err(CoderError::from)?; + } + } + std::fs::write(&abs, bytes).map_err(CoderError::from)?; + apply_mode(&abs, &spec.mode)?; + Ok(bytes.len() as u64) +} + +#[cfg(unix)] +fn apply_mode(path: &Path, mode_str: &str) -> Result<(), CoderError> { + use std::os::unix::fs::PermissionsExt; + let mode = u32::from_str_radix(mode_str.trim_start_matches('0'), 8) + .map_err(|e| CoderError::BadInput(format!("bad mode {mode_str:?}: {e}")))?; + let perms = std::fs::Permissions::from_mode(mode & 0o777); + std::fs::set_permissions(path, perms).map_err(CoderError::from) +} + +#[cfg(not(unix))] +fn apply_mode(_path: &Path, _mode_str: &str) -> Result<(), CoderError> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn setup() -> (tempfile::TempDir, Arc, Arc) { + let tmp = tempdir().unwrap(); + let cfg = Arc::new(CoderConfig { + base_path: tmp.path().to_path_buf(), + non_accessible_globs: vec!["**/.env".to_string()], + max_read_bytes: 1024 * 1024, + max_write_bytes: 1024 * 1024, + ..CoderConfig::default() + }); + let resolver = Arc::new(PathResolver::new(&cfg).unwrap()); + (tmp, resolver, cfg) + } + + #[tokio::test] + async fn creates_simple_file() { + let (tmp, r, c) = setup(); + let out = handle( + r, + c, + CreateFileInput { + files: vec![CreateFileSpec { + path: "a.txt".into(), + content: "hello".into(), + mode: "0644".into(), + parents: true, + overwrite: false, + }], + }, + ) + .await + .unwrap(); + assert!(out.results[0].success); + assert_eq!(out.results[0].bytes_written, 5); + assert_eq!( + std::fs::read_to_string(tmp.path().join("a.txt")).unwrap(), + "hello" + ); + } + + #[tokio::test] + async fn creates_with_parents() { + let (tmp, r, c) = setup(); + let out = handle( + r, + c, + CreateFileInput { + files: vec![CreateFileSpec { + path: "a/b/c.txt".into(), + content: "hi".into(), + mode: "0644".into(), + parents: true, + overwrite: false, + }], + }, + ) + .await + .unwrap(); + assert!(out.results[0].success, "{:?}", out.results[0].error); + assert!(tmp.path().join("a/b/c.txt").exists()); + } + + #[tokio::test] + async fn rejects_existing_without_overwrite() { + let (tmp, r, c) = setup(); + std::fs::write(tmp.path().join("a.txt"), "old").unwrap(); + let out = handle( + r, + c, + CreateFileInput { + files: vec![CreateFileSpec { + path: "a.txt".into(), + content: "new".into(), + mode: "0644".into(), + parents: true, + overwrite: false, + }], + }, + ) + .await + .unwrap(); + assert!(!out.results[0].success); + assert!(out.results[0].error.as_deref().unwrap().contains("C217")); + assert_eq!( + std::fs::read_to_string(tmp.path().join("a.txt")).unwrap(), + "old" + ); + } + + #[tokio::test] + async fn overwrite_replaces_existing() { + let (tmp, r, c) = setup(); + std::fs::write(tmp.path().join("a.txt"), "old").unwrap(); + let out = handle( + r, + c, + CreateFileInput { + files: vec![CreateFileSpec { + path: "a.txt".into(), + content: "new".into(), + mode: "0644".into(), + parents: true, + overwrite: true, + }], + }, + ) + .await + .unwrap(); + assert!(out.results[0].success); + assert_eq!( + std::fs::read_to_string(tmp.path().join("a.txt")).unwrap(), + "new" + ); + } + + #[tokio::test] + async fn refuses_non_accessible() { + let (_tmp, r, c) = setup(); + let out = handle( + r, + c, + CreateFileInput { + files: vec![CreateFileSpec { + path: ".env".into(), + content: "secret".into(), + mode: "0644".into(), + parents: true, + overwrite: true, + }], + }, + ) + .await + .unwrap(); + assert!(!out.results[0].success); + assert!(out.results[0].error.as_deref().unwrap().contains("C211")); + } + + #[tokio::test] + async fn refuses_oversize() { + let (_tmp, r, _c) = setup(); + let small_cfg = Arc::new(CoderConfig { + base_path: _tmp.path().to_path_buf(), + non_accessible_globs: vec![], + max_write_bytes: 4, + ..CoderConfig::default() + }); + let out = handle( + r, + small_cfg, + CreateFileInput { + files: vec![CreateFileSpec { + path: "big.txt".into(), + content: "abcdefg".into(), + mode: "0644".into(), + parents: true, + overwrite: false, + }], + }, + ) + .await + .unwrap(); + assert!(!out.results[0].success); + assert!(out.results[0].error.as_deref().unwrap().contains("C213")); + } + + #[tokio::test] + async fn multi_file_partial_success() { + let (tmp, r, c) = setup(); + let out = handle( + r, + c, + CreateFileInput { + files: vec![ + CreateFileSpec { + path: ".env".into(), + content: "x".into(), + mode: "0644".into(), + parents: true, + overwrite: false, + }, + CreateFileSpec { + path: "ok.txt".into(), + content: "y".into(), + mode: "0644".into(), + parents: true, + overwrite: false, + }, + ], + }, + ) + .await + .unwrap(); + assert_eq!(out.results.len(), 2); + assert!(!out.results[0].success); + assert!(out.results[1].success); + assert!(tmp.path().join("ok.txt").exists()); + } +} diff --git a/coder/src/functions/delete_file.rs b/coder/src/functions/delete_file.rs new file mode 100644 index 000000000..c13b8a1ed --- /dev/null +++ b/coder/src/functions/delete_file.rs @@ -0,0 +1,255 @@ +//! `coder::delete-file` — remove one or more paths. Per-path errors are +//! reported in the result array rather than failing the whole batch. +//! Directories require `recursive: true`. Non-accessible paths return +//! `C211`. Trying to delete `base_path` itself is rejected. + +use std::path::Path; +use std::sync::Arc; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::error::{err_to_string, CoderError}; +use crate::path::PathResolver; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeleteFileInput { + pub paths: Vec, + /// Required for non-empty directories. Files and empty dirs ignore it. + #[serde(default)] + pub recursive: bool, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct DeleteFileOutput { + pub results: Vec, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct DeleteFileResult { + pub path: String, + pub success: bool, + pub removed: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +pub async fn handle( + resolver: Arc, + req: DeleteFileInput, +) -> Result { + if req.paths.is_empty() { + return Err(err_to_string(CoderError::BadInput( + "`paths` must not be empty".into(), + ))); + } + let mut results = Vec::with_capacity(req.paths.len()); + for p in req.paths { + results.push(delete_one(&resolver, &p, req.recursive)); + } + Ok(DeleteFileOutput { results }) +} + +fn delete_one(resolver: &PathResolver, rel: &str, recursive: bool) -> DeleteFileResult { + match try_delete_one(resolver, rel, recursive) { + Ok(removed) => DeleteFileResult { + path: rel.to_string(), + success: true, + removed, + error: None, + }, + Err(e) => DeleteFileResult { + path: rel.to_string(), + success: false, + removed: false, + error: Some(e.to_wire_string()), + }, + } +} + +fn try_delete_one(resolver: &PathResolver, rel: &str, recursive: bool) -> Result { + let abs = resolver.require_writable(rel)?; + if abs == resolver.base_root() { + return Err(CoderError::BadInput( + "refusing to delete base_path itself".into(), + )); + } + let md = match std::fs::symlink_metadata(&abs) { + Ok(m) => m, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // Idempotent: missing target counts as "not removed, no error". + return Ok(false); + } + Err(e) => return Err(CoderError::from(e)), + }; + if md.file_type().is_dir() { + if recursive { + remove_dir_all_safe(&abs, resolver)?; + } else { + std::fs::remove_dir(&abs).map_err(CoderError::from)?; + } + } else { + std::fs::remove_file(&abs).map_err(CoderError::from)?; + } + Ok(true) +} + +/// `std::fs::remove_dir_all` plus a guard rail: refuse to descend through +/// non-accessible entries. The resolver canonicalised `abs` already so +/// it's known to be inside `base_root`. +fn remove_dir_all_safe(abs: &Path, resolver: &PathResolver) -> Result<(), CoderError> { + for entry in walkdir::WalkDir::new(abs) + .min_depth(1) + .follow_links(false) + .into_iter() + .filter_map(|e| e.ok()) + { + if resolver.is_non_accessible(entry.path()) { + return Err(CoderError::NotFoundOrDenied(format!( + "recursive delete blocked: {} contains non-accessible {}", + abs.display(), + entry.path().display() + ))); + } + } + std::fs::remove_dir_all(abs).map_err(CoderError::from) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn setup() -> (tempfile::TempDir, Arc) { + let tmp = tempdir().unwrap(); + let cfg = Arc::new(crate::config::CoderConfig { + base_path: tmp.path().to_path_buf(), + non_accessible_globs: vec!["**/.env".to_string()], + ..crate::config::CoderConfig::default() + }); + let resolver = Arc::new(PathResolver::new(&cfg).unwrap()); + (tmp, resolver) + } + + #[tokio::test] + async fn deletes_file() { + let (tmp, r) = setup(); + std::fs::write(tmp.path().join("a.txt"), "x").unwrap(); + let out = handle( + r, + DeleteFileInput { + paths: vec!["a.txt".into()], + recursive: false, + }, + ) + .await + .unwrap(); + assert!(out.results[0].success); + assert!(out.results[0].removed); + assert!(!tmp.path().join("a.txt").exists()); + } + + #[tokio::test] + async fn missing_path_is_idempotent_success() { + let (_tmp, r) = setup(); + let out = handle( + r, + DeleteFileInput { + paths: vec!["nope.txt".into()], + recursive: false, + }, + ) + .await + .unwrap(); + assert!(out.results[0].success); + assert!(!out.results[0].removed); + } + + #[tokio::test] + async fn refuses_non_accessible() { + let (tmp, r) = setup(); + std::fs::write(tmp.path().join(".env"), "x").unwrap(); + let out = handle( + r, + DeleteFileInput { + paths: vec![".env".into()], + recursive: false, + }, + ) + .await + .unwrap(); + assert!(!out.results[0].success); + assert!(out.results[0].error.as_deref().unwrap().contains("C211")); + assert!(tmp.path().join(".env").exists()); + } + + #[tokio::test] + async fn directory_without_recursive_rejected() { + let (tmp, r) = setup(); + std::fs::create_dir(tmp.path().join("d")).unwrap(); + std::fs::write(tmp.path().join("d/x"), "x").unwrap(); + let out = handle( + r, + DeleteFileInput { + paths: vec!["d".into()], + recursive: false, + }, + ) + .await + .unwrap(); + assert!(!out.results[0].success); + } + + #[tokio::test] + async fn directory_with_recursive_succeeds() { + let (tmp, r) = setup(); + std::fs::create_dir(tmp.path().join("d")).unwrap(); + std::fs::write(tmp.path().join("d/x"), "x").unwrap(); + let out = handle( + r, + DeleteFileInput { + paths: vec!["d".into()], + recursive: true, + }, + ) + .await + .unwrap(); + assert!(out.results[0].success); + assert!(!tmp.path().join("d").exists()); + } + + #[tokio::test] + async fn recursive_refuses_when_subtree_has_non_accessible() { + let (tmp, r) = setup(); + std::fs::create_dir(tmp.path().join("d")).unwrap(); + std::fs::write(tmp.path().join("d/.env"), "secret").unwrap(); + let out = handle( + r, + DeleteFileInput { + paths: vec!["d".into()], + recursive: true, + }, + ) + .await + .unwrap(); + assert!(!out.results[0].success); + assert!(out.results[0].error.as_deref().unwrap().contains("C211")); + assert!(tmp.path().join("d/.env").exists()); + } + + #[tokio::test] + async fn refuses_to_delete_base_root() { + let (_tmp, r) = setup(); + let out = handle( + r, + DeleteFileInput { + paths: vec![".".into()], + recursive: true, + }, + ) + .await + .unwrap(); + assert!(!out.results[0].success); + assert!(out.results[0].error.as_deref().unwrap().contains("C210")); + } +} diff --git a/coder/src/functions/list_folder.rs b/coder/src/functions/list_folder.rs new file mode 100644 index 000000000..251051e2d --- /dev/null +++ b/coder/src/functions/list_folder.rs @@ -0,0 +1,309 @@ +//! `coder::list-folder` — paginated single-folder listing. Sorted by +//! name (directories and files mixed). Non-accessible entries are still +//! returned, flagged `non_accessible: true`, so callers can see they +//! exist even though they can't be read or written. + +use std::sync::Arc; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::config::CoderConfig; +use crate::error::{err_to_string, CoderError}; +use crate::path::PathResolver; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListFolderInput { + /// Folder, relative to `base_path`. Defaults to `.` (the base itself). + #[serde(default = "default_path")] + pub path: String, + #[serde(default = "default_page")] + pub page: u32, + /// Capped by `config.list_max_page_size`; falls back to + /// `config.list_default_page_size` when omitted. + #[serde(default)] + pub page_size: Option, +} + +fn default_path() -> String { + ".".to_string() +} +fn default_page() -> u32 { + 1 +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct ListFolderOutput { + pub path: String, + pub entries: Vec, + pub total: u64, + pub page: u32, + pub page_size: u32, + pub has_more: bool, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct DirEntry { + pub name: String, + pub kind: EntryKind, + pub size: u64, + pub mtime: i64, + /// True if this entry matches `non_accessible_globs` — caller cannot + /// read/write/delete it via `coder::*` even though it shows up here. + pub non_accessible: bool, +} + +#[derive(Debug, Serialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum EntryKind { + File, + Dir, + Symlink, + Other, +} + +pub async fn handle( + resolver: Arc, + cfg: Arc, + req: ListFolderInput, +) -> Result { + inner(&resolver, &cfg, req).map_err(err_to_string) +} + +fn inner( + resolver: &PathResolver, + cfg: &CoderConfig, + req: ListFolderInput, +) -> Result { + // list-folder uses `resolve`, not `require_writable`, so callers can + // list a directory that contains non-accessible *children* even if the + // directory itself happened to match a glob. + let abs = resolver.resolve(&req.path)?; + let md = std::fs::metadata(&abs)?; + if !md.is_dir() { + return Err(CoderError::BadInput(format!( + "not a directory: {}", + req.path + ))); + } + + let page_size = req + .page_size + .unwrap_or(cfg.list_default_page_size) + .min(cfg.list_max_page_size) + .max(1); + let page = req.page.max(1); + + let mut all: Vec = Vec::new(); + for entry in std::fs::read_dir(&abs)? { + let e = entry?; + let name = e.file_name().to_string_lossy().into_owned(); + let entry_md = match e.metadata() { + Ok(m) => m, + Err(_) => continue, + }; + let abs_entry = e.path(); + all.push(DirEntry { + name, + kind: classify(&entry_md), + size: entry_md.len(), + mtime: unix_mtime(&entry_md), + non_accessible: resolver.is_non_accessible(&abs_entry), + }); + } + all.sort_by(|a, b| a.name.cmp(&b.name)); + + let total = all.len() as u64; + let start = ((page.saturating_sub(1)) as usize).saturating_mul(page_size as usize); + let end = start.saturating_add(page_size as usize).min(all.len()); + let entries = if start < all.len() { + all[start..end].to_vec() + } else { + Vec::new() + }; + let has_more = (end as u64) < total; + + Ok(ListFolderOutput { + path: req.path, + entries, + total, + page, + page_size, + has_more, + }) +} + +fn classify(md: &std::fs::Metadata) -> EntryKind { + let ft = md.file_type(); + if ft.is_symlink() { + EntryKind::Symlink + } else if ft.is_dir() { + EntryKind::Dir + } else if ft.is_file() { + EntryKind::File + } else { + EntryKind::Other + } +} + +fn unix_mtime(md: &std::fs::Metadata) -> i64 { + md.modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +impl Clone for DirEntry { + fn clone(&self) -> Self { + DirEntry { + name: self.name.clone(), + kind: match self.kind { + EntryKind::File => EntryKind::File, + EntryKind::Dir => EntryKind::Dir, + EntryKind::Symlink => EntryKind::Symlink, + EntryKind::Other => EntryKind::Other, + }, + size: self.size, + mtime: self.mtime, + non_accessible: self.non_accessible, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn setup() -> (tempfile::TempDir, Arc, Arc) { + let tmp = tempdir().unwrap(); + let cfg = Arc::new(CoderConfig { + base_path: tmp.path().to_path_buf(), + non_accessible_globs: vec!["**/.env".to_string()], + list_default_page_size: 100, + list_max_page_size: 1000, + ..CoderConfig::default() + }); + let resolver = Arc::new(PathResolver::new(&cfg).unwrap()); + (tmp, resolver, cfg) + } + + #[tokio::test] + async fn lists_mixed_entries_sorted() { + let (tmp, r, c) = setup(); + std::fs::write(tmp.path().join("b.txt"), "x").unwrap(); + std::fs::write(tmp.path().join("a.txt"), "x").unwrap(); + std::fs::create_dir(tmp.path().join("dir")).unwrap(); + let out = handle( + r, + c, + ListFolderInput { + path: ".".into(), + page: 1, + page_size: None, + }, + ) + .await + .unwrap(); + let names: Vec<_> = out.entries.iter().map(|e| e.name.as_str()).collect(); + assert_eq!(names, vec!["a.txt", "b.txt", "dir"]); + assert_eq!(out.total, 3); + assert!(!out.has_more); + } + + #[tokio::test] + async fn pagination_works() { + let (tmp, r, c) = setup(); + for i in 0..5 { + std::fs::write(tmp.path().join(format!("f{i}.txt")), "x").unwrap(); + } + let out = handle( + r, + c, + ListFolderInput { + path: ".".into(), + page: 2, + page_size: Some(2), + }, + ) + .await + .unwrap(); + let names: Vec<_> = out.entries.iter().map(|e| e.name.as_str()).collect(); + assert_eq!(names, vec!["f2.txt", "f3.txt"]); + assert_eq!(out.total, 5); + assert!(out.has_more); + } + + #[tokio::test] + async fn page_size_caps_at_max() { + let (tmp, r, _c) = setup(); + std::fs::write(tmp.path().join("x.txt"), "x").unwrap(); + let cfg = Arc::new(CoderConfig { + base_path: tmp.path().to_path_buf(), + list_default_page_size: 50, + list_max_page_size: 5, + ..CoderConfig::default() + }); + let out = handle( + r, + cfg, + ListFolderInput { + path: ".".into(), + page: 1, + page_size: Some(9999), + }, + ) + .await + .unwrap(); + assert_eq!(out.page_size, 5); + } + + #[tokio::test] + async fn non_accessible_entries_still_listed_with_flag() { + let (tmp, r, c) = setup(); + std::fs::write(tmp.path().join(".env"), "x").unwrap(); + std::fs::write(tmp.path().join("a.txt"), "x").unwrap(); + let out = handle( + r, + c, + ListFolderInput { + path: ".".into(), + page: 1, + page_size: None, + }, + ) + .await + .unwrap(); + let dot = out + .entries + .iter() + .find(|e| e.name == ".env") + .expect("listed"); + assert!(dot.non_accessible); + let a = out + .entries + .iter() + .find(|e| e.name == "a.txt") + .expect("listed"); + assert!(!a.non_accessible); + } + + #[tokio::test] + async fn rejects_path_that_is_not_a_directory() { + let (tmp, r, c) = setup(); + std::fs::write(tmp.path().join("a.txt"), "x").unwrap(); + let err = handle( + r, + c, + ListFolderInput { + path: "a.txt".into(), + page: 1, + page_size: None, + }, + ) + .await + .unwrap_err(); + assert!(err.contains("C210")); + } +} diff --git a/coder/src/functions/mod.rs b/coder/src/functions/mod.rs new file mode 100644 index 000000000..9e936c873 --- /dev/null +++ b/coder/src/functions/mod.rs @@ -0,0 +1,152 @@ +//! Per-verb function modules and the single `register_all` entry point. +//! `main.rs` calls `register_all` after `register_worker`; each +//! `register_` below uses `RegisterFunction::new_async` with typed +//! `JsonSchema` request/response structs so the SDK can emit schemas for +//! tools and docs (binary-worker.md §7). + +pub mod create_file; +pub mod delete_file; +pub mod list_folder; +pub mod read_file; +pub mod search; +pub mod tree; +pub mod update_file; + +use std::sync::Arc; + +use iii_sdk::{RegisterFunction, III}; + +use crate::config::CoderConfig; +use crate::path::PathResolver; + +pub fn register_all(iii: &III, resolver: Arc, cfg: Arc) { + register_read_file(iii, resolver.clone(), cfg.clone()); + register_search(iii, resolver.clone(), cfg.clone()); + register_update_file(iii, resolver.clone(), cfg.clone()); + register_create_file(iii, resolver.clone(), cfg.clone()); + register_delete_file(iii, resolver.clone()); + register_list_folder(iii, resolver.clone(), cfg.clone()); + register_tree(iii, resolver, cfg); + tracing::info!("coder registered 7 functions"); +} + +fn register_read_file(iii: &III, resolver: Arc, cfg: Arc) { + iii.register_function( + RegisterFunction::new_async("coder::read-file", move |req: read_file::ReadFileInput| { + let resolver = resolver.clone(); + let cfg = cfg.clone(); + async move { read_file::handle(resolver, cfg, req).await } + }) + .description( + "Read a file relative to base_path. Returns content plus \ + size/mtime/mode. Capped by max_read_bytes; non-accessible \ + paths return C211.", + ), + ); +} + +fn register_search(iii: &III, resolver: Arc, cfg: Arc) { + iii.register_function( + RegisterFunction::new_async("coder::search", move |req: search::SearchInput| { + let resolver = resolver.clone(); + let cfg = cfg.clone(); + async move { search::handle(resolver, cfg, req).await } + }) + .description( + "Search file contents and/or paths under base_path. Supports \ + literal or regex queries with include/exclude globs; \ + non-accessible files are excluded from both content and path \ + results.", + ), + ); +} + +fn register_update_file(iii: &III, resolver: Arc, cfg: Arc) { + iii.register_function( + RegisterFunction::new_async( + "coder::update-file", + move |req: update_file::UpdateFileInput| { + let resolver = resolver.clone(); + let cfg = cfg.clone(); + async move { update_file::handle(resolver, cfg, req).await } + }, + ) + .description( + "Apply batched line-oriented and regex edits across one or more \ + files. Line ops: { op: 'insert', at_line, content } | \ + { op: 'remove', from_line, to_line } | \ + { op: 'update_lines', from_line, to_line, content } — 1-based, \ + inclusive, applied bottom-up. Regex op: { op: 'replace', pattern, \ + replacement, ignore_case? } runs on the file body after line \ + ops. Each file commits atomically via temp + rename.", + ), + ); +} + +fn register_create_file(iii: &III, resolver: Arc, cfg: Arc) { + iii.register_function( + RegisterFunction::new_async( + "coder::create-file", + move |req: create_file::CreateFileInput| { + let resolver = resolver.clone(); + let cfg = cfg.clone(); + async move { create_file::handle(resolver, cfg, req).await } + }, + ) + .description( + "Create one or more files. Per-file `overwrite` and `parents` \ + flags; non-accessible paths return C211.", + ), + ); +} + +fn register_delete_file(iii: &III, resolver: Arc) { + iii.register_function( + RegisterFunction::new_async( + "coder::delete-file", + move |req: delete_file::DeleteFileInput| { + let resolver = resolver.clone(); + async move { delete_file::handle(resolver, req).await } + }, + ) + .description( + "Remove one or more paths. Directories need `recursive: true`; \ + missing paths are idempotent successes; recursive removal \ + refuses to descend through non-accessible entries.", + ), + ); +} + +fn register_list_folder(iii: &III, resolver: Arc, cfg: Arc) { + iii.register_function( + RegisterFunction::new_async( + "coder::list-folder", + move |req: list_folder::ListFolderInput| { + let resolver = resolver.clone(); + let cfg = cfg.clone(); + async move { list_folder::handle(resolver, cfg, req).await } + }, + ) + .description( + "Paginated single-folder listing, sorted by name. \ + Non-accessible entries are still listed with a \ + `non_accessible: true` flag.", + ), + ); +} + +fn register_tree(iii: &III, resolver: Arc, cfg: Arc) { + iii.register_function( + RegisterFunction::new_async("coder::tree", move |req: tree::TreeInput| { + let resolver = resolver.clone(); + let cfg = cfg.clone(); + async move { tree::handle(resolver, cfg, req).await } + }) + .description( + "Recursive directory snapshot bounded by `max_depth` and a \ + `per_folder_limit`. Folders that hit the limit are flagged \ + `truncated` and the caller is pointed at coder::list-folder \ + for pagination.", + ), + ); +} diff --git a/coder/src/functions/read_file.rs b/coder/src/functions/read_file.rs new file mode 100644 index 000000000..aea4e2691 --- /dev/null +++ b/coder/src/functions/read_file.rs @@ -0,0 +1,194 @@ +//! `coder::read-file` — return the file's content + metadata. Capped by +//! `max_read_bytes`; non-accessible paths return `C211`. The path is always +//! relative to `base_path`. + +use std::sync::Arc; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::config::CoderConfig; +use crate::error::{err_to_string, CoderError}; +use crate::path::PathResolver; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ReadFileInput { + /// File to read, relative to `base_path`. + pub path: String, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct ReadFileOutput { + /// The original `path` argument echoed back for caller correlation. + pub path: String, + /// File content as a UTF-8 string. Binary files are returned with + /// invalid bytes replaced by U+FFFD; use a future binary-aware + /// function if exact bytes matter. + pub content: String, + /// Whether `content` lost bytes to UTF-8 sanitisation. + pub is_utf8: bool, + pub size: u64, + /// Unix permission bits (lower 9 bits of `st_mode`), e.g. 0o644. + pub mode: u32, + /// Last-modified time as a Unix epoch in seconds. + pub mtime: i64, +} + +pub async fn handle( + resolver: Arc, + cfg: Arc, + req: ReadFileInput, +) -> Result { + inner(&resolver, &cfg, req).map_err(err_to_string) +} + +fn inner( + resolver: &PathResolver, + cfg: &CoderConfig, + req: ReadFileInput, +) -> Result { + let abs = resolver.require_writable(&req.path)?; + let md = std::fs::metadata(&abs)?; + if !md.is_file() { + return Err(CoderError::BadInput(format!( + "not a regular file: {}", + req.path + ))); + } + if md.len() > cfg.max_read_bytes { + return Err(CoderError::TooLarge(format!( + "{} is {} bytes; max_read_bytes is {}", + req.path, + md.len(), + cfg.max_read_bytes + ))); + } + let bytes = std::fs::read(&abs)?; + let (content, is_utf8) = match String::from_utf8(bytes.clone()) { + Ok(s) => (s, true), + Err(_) => (String::from_utf8_lossy(&bytes).into_owned(), false), + }; + let mode = unix_mode(&md); + let mtime = unix_mtime(&md); + Ok(ReadFileOutput { + path: req.path, + content, + is_utf8, + size: md.len(), + mode, + mtime, + }) +} + +#[cfg(unix)] +fn unix_mode(md: &std::fs::Metadata) -> u32 { + use std::os::unix::fs::PermissionsExt; + md.permissions().mode() & 0o777 +} + +#[cfg(not(unix))] +fn unix_mode(_md: &std::fs::Metadata) -> u32 { + 0o644 +} + +fn unix_mtime(md: &std::fs::Metadata) -> i64 { + md.modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn setup() -> (tempfile::TempDir, Arc, Arc) { + let tmp = tempdir().unwrap(); + let cfg = CoderConfig { + base_path: tmp.path().to_path_buf(), + non_accessible_globs: vec!["**/.env".to_string()], + max_read_bytes: 1024, + ..CoderConfig::default() + }; + let cfg = Arc::new(cfg); + let resolver = Arc::new(PathResolver::new(&cfg).unwrap()); + (tmp, resolver, cfg) + } + + #[tokio::test] + async fn reads_existing_file() { + let (tmp, r, c) = setup(); + std::fs::write(tmp.path().join("hi.txt"), b"hello").unwrap(); + let out = handle( + r, + c, + ReadFileInput { + path: "hi.txt".into(), + }, + ) + .await + .unwrap(); + assert_eq!(out.content, "hello"); + assert_eq!(out.size, 5); + assert!(out.is_utf8); + } + + #[tokio::test] + async fn refuses_non_accessible() { + let (tmp, r, c) = setup(); + std::fs::write(tmp.path().join(".env"), b"secret").unwrap(); + let err = handle( + r, + c, + ReadFileInput { + path: ".env".into(), + }, + ) + .await + .unwrap_err(); + assert!(err.contains("C211"), "got: {err}"); + } + + #[tokio::test] + async fn refuses_file_above_max_read_bytes() { + let (tmp, r, c) = setup(); + std::fs::write(tmp.path().join("big.bin"), vec![0u8; 2048]).unwrap(); + let err = handle( + r, + c, + ReadFileInput { + path: "big.bin".into(), + }, + ) + .await + .unwrap_err(); + assert!(err.contains("C213"), "got: {err}"); + } + + #[tokio::test] + async fn rejects_directory_with_bad_input() { + let (tmp, r, c) = setup(); + std::fs::create_dir(tmp.path().join("d")).unwrap(); + let err = handle(r, c, ReadFileInput { path: "d".into() }) + .await + .unwrap_err(); + assert!(err.contains("C210"), "got: {err}"); + } + + #[tokio::test] + async fn missing_file_returns_c211() { + let (_tmp, r, c) = setup(); + let err = handle( + r, + c, + ReadFileInput { + path: "nope.txt".into(), + }, + ) + .await + .unwrap_err(); + assert!(err.contains("C211"), "got: {err}"); + } +} diff --git a/coder/src/functions/search.rs b/coder/src/functions/search.rs new file mode 100644 index 000000000..371ed2d8b --- /dev/null +++ b/coder/src/functions/search.rs @@ -0,0 +1,606 @@ +//! `coder::search` — combined path + content search. +//! +//! Walks `base_path` with `walkdir`, filtering by include/exclude globs +//! and skipping non-accessible files entirely so the search can't reveal +//! their content. Path matches and content matches are reported in +//! separate arrays of one response. + +use std::sync::Arc; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::config::CoderConfig; +use crate::error::{err_to_string, CoderError}; +use crate::path::PathResolver; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SearchInput { + /// Pattern to search for. Treated as a regex when `regex: true`, + /// otherwise as a literal substring. + pub query: String, + /// Folder, relative to `base_path`, scoping the walk. Defaults to `.` + /// (the base itself). Globs and result `path`s remain anchored at + /// `base_path` regardless of this value. + #[serde(default = "default_path")] + pub path: String, + #[serde(default)] + pub regex: bool, + #[serde(default)] + pub ignore_case: bool, + /// Glob patterns (relative to `base_path`) that paths must match + /// to be considered. Empty = include everything. + #[serde(default)] + pub include_globs: Vec, + /// Glob patterns (relative to `base_path`) that exclude matching paths. + #[serde(default)] + pub exclude_globs: Vec, + /// Optional explicit cap. Falls back to config when unset. + #[serde(default)] + pub max_matches: Option, + /// Bytes per line to consider when scanning content; longer lines are + /// truncated for the match snippet. + #[serde(default)] + pub max_line_bytes: Option, + /// Search file contents (default true). + #[serde(default = "default_true")] + pub search_content: bool, + /// Search file paths (default true). + #[serde(default = "default_true")] + pub search_paths: bool, +} + +fn default_true() -> bool { + true +} + +fn default_path() -> String { + ".".to_string() +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct ContentMatch { + pub path: String, + pub line: u32, + pub column: u32, + /// Matched line; truncated to `max_line_bytes` and never spans newlines. + pub text: String, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct PathMatch { + pub path: String, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct SearchOutput { + pub content_matches: Vec, + pub path_matches: Vec, + /// True if either match list was cut off at the configured cap. + pub truncated: bool, +} + +pub async fn handle( + resolver: Arc, + cfg: Arc, + req: SearchInput, +) -> Result { + inner(&resolver, &cfg, req).map_err(err_to_string) +} + +fn inner( + resolver: &PathResolver, + cfg: &CoderConfig, + req: SearchInput, +) -> Result { + if req.query.is_empty() { + return Err(CoderError::BadInput("query must not be empty".into())); + } + if !req.search_content && !req.search_paths { + return Err(CoderError::BadInput( + "at least one of search_content / search_paths must be true".into(), + )); + } + let max_matches = req.max_matches.unwrap_or(cfg.search_default_max_matches) as usize; + let max_line_bytes = req + .max_line_bytes + .unwrap_or(cfg.search_default_max_line_bytes) as usize; + + // Use `resolve` rather than `require_writable` so a search rooted at + // a folder that *contains* non-accessible children still works; the + // per-file `is_non_accessible` filter below still guards their bytes. + let walk_root = resolver.resolve(&req.path)?; + let md = std::fs::metadata(&walk_root)?; + if !md.is_dir() { + return Err(CoderError::BadInput(format!( + "not a directory: {}", + req.path + ))); + } + + let include = build_globset(&req.include_globs)?; + let exclude = build_globset(&req.exclude_globs)?; + + let content_matcher = if req.search_content { + Some(build_matcher(&req.query, req.regex, req.ignore_case)?) + } else { + None + }; + let path_matcher = if req.search_paths { + Some(build_matcher(&req.query, req.regex, req.ignore_case)?) + } else { + None + }; + + let mut content_matches: Vec = Vec::new(); + let mut path_matches: Vec = Vec::new(); + let mut truncated = false; + + let walker = walkdir::WalkDir::new(&walk_root).follow_links(false); + for entry in walker.into_iter().filter_map(|e| e.ok()) { + if !entry.file_type().is_file() { + continue; + } + let abs = entry.path(); + let Some(rel) = resolver.relative(abs) else { + continue; + }; + if rel.is_empty() { + continue; + } + if resolver.is_non_accessible(abs) { + continue; + } + if let Some(set) = &include { + if !set.is_match(&rel) { + continue; + } + } + if let Some(set) = &exclude { + if set.is_match(&rel) { + continue; + } + } + + if let Some(matcher) = &path_matcher { + if matcher.is_match(&rel) { + if path_matches.len() >= max_matches { + truncated = true; + } else { + path_matches.push(PathMatch { path: rel.clone() }); + } + } + } + + if let Some(matcher) = &content_matcher { + // Skip files larger than max_read_bytes during a search — we + // don't want to load multi-GB blobs into memory by accident. + if let Ok(md) = std::fs::metadata(abs) { + if md.len() > cfg.max_read_bytes { + continue; + } + } + let bytes = match std::fs::read(abs) { + Ok(b) => b, + Err(_) => continue, + }; + // Cheap binary heuristic: presence of any NUL byte. Skip + // binary files so the response stays human-readable. + if bytes.contains(&0) { + continue; + } + let text = String::from_utf8_lossy(&bytes); + for (line_idx, line) in text.lines().enumerate() { + let truncated_line = if line.len() > max_line_bytes { + &line[..max_line_bytes] + } else { + line + }; + if let Some(m) = matcher.find(truncated_line) { + if content_matches.len() >= max_matches { + truncated = true; + break; + } + content_matches.push(ContentMatch { + path: rel.clone(), + line: (line_idx as u32) + 1, + column: (m.start as u32) + 1, + text: truncated_line.to_string(), + }); + } + } + if truncated { + break; + } + } + } + + Ok(SearchOutput { + content_matches, + path_matches, + truncated, + }) +} + +fn build_globset(patterns: &[String]) -> Result, CoderError> { + if patterns.is_empty() { + return Ok(None); + } + let mut b = globset::GlobSetBuilder::new(); + for p in patterns { + let g = globset::Glob::new(p) + .map_err(|e| CoderError::BadInput(format!("bad glob {p:?}: {e}")))?; + b.add(g); + } + let set = b + .build() + .map_err(|e| CoderError::BadInput(format!("globset build failed: {e}")))?; + Ok(Some(set)) +} + +/// Lightweight matcher abstraction — wraps either a `Regex` or a literal +/// substring search. Both report a 0-based column for the first match +/// position so the wire `column` can be 1-based. +enum Matcher { + Regex(regex::Regex), + Literal { needle: String, ignore_case: bool }, +} + +struct Match { + start: usize, +} + +impl Matcher { + fn is_match(&self, hay: &str) -> bool { + self.find(hay).is_some() + } + fn find(&self, hay: &str) -> Option { + match self { + Matcher::Regex(re) => re.find(hay).map(|m| Match { start: m.start() }), + Matcher::Literal { + needle, + ignore_case, + } => { + if *ignore_case { + let hay_l = hay.to_lowercase(); + let needle_l = needle.to_lowercase(); + hay_l.find(&needle_l).map(|s| Match { start: s }) + } else { + hay.find(needle.as_str()).map(|s| Match { start: s }) + } + } + } + } +} + +fn build_matcher(query: &str, regex: bool, ignore_case: bool) -> Result { + if regex { + let mut builder = regex::RegexBuilder::new(query); + builder.case_insensitive(ignore_case); + let re = builder + .build() + .map_err(|e| CoderError::BadInput(format!("bad regex {query:?}: {e}")))?; + Ok(Matcher::Regex(re)) + } else { + Ok(Matcher::Literal { + needle: query.to_string(), + ignore_case, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn setup() -> (tempfile::TempDir, Arc, Arc) { + let tmp = tempdir().unwrap(); + let cfg = CoderConfig { + base_path: tmp.path().to_path_buf(), + non_accessible_globs: vec!["**/.env".to_string()], + max_read_bytes: 1024 * 1024, + search_default_max_matches: 1000, + search_default_max_line_bytes: 4096, + ..CoderConfig::default() + }; + let cfg = Arc::new(cfg); + let resolver = Arc::new(PathResolver::new(&cfg).unwrap()); + (tmp, resolver, cfg) + } + + fn write(tmp: &tempfile::TempDir, rel: &str, body: &str) { + let p = tmp.path().join(rel); + if let Some(parent) = p.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(p, body).unwrap(); + } + + #[tokio::test] + async fn literal_content_match_returns_line_column() { + let (tmp, r, c) = setup(); + write(&tmp, "a.txt", "alpha\nbeta needle here\ngamma\n"); + let out = handle( + r, + c, + SearchInput { + query: "needle".into(), + path: ".".into(), + regex: false, + ignore_case: false, + include_globs: vec![], + exclude_globs: vec![], + max_matches: None, + max_line_bytes: None, + search_content: true, + search_paths: false, + }, + ) + .await + .unwrap(); + assert_eq!(out.content_matches.len(), 1); + let m = &out.content_matches[0]; + assert_eq!(m.path, "a.txt"); + assert_eq!(m.line, 2); + assert_eq!(m.column, 6); + } + + #[tokio::test] + async fn regex_content_match() { + let (tmp, r, c) = setup(); + write(&tmp, "a.txt", "fn foo() {}\nfn Bar() {}\n"); + let out = handle( + r, + c, + SearchInput { + query: "^fn [A-Z]".into(), + path: ".".into(), + regex: true, + ignore_case: false, + include_globs: vec![], + exclude_globs: vec![], + max_matches: None, + max_line_bytes: None, + search_content: true, + search_paths: false, + }, + ) + .await + .unwrap(); + assert_eq!(out.content_matches.len(), 1); + assert_eq!(out.content_matches[0].line, 2); + } + + #[tokio::test] + async fn path_match_returns_paths() { + let (tmp, r, c) = setup(); + write(&tmp, "src/foo.rs", "x"); + write(&tmp, "src/bar.ts", "x"); + let out = handle( + r, + c, + SearchInput { + query: "foo".into(), + path: ".".into(), + regex: false, + ignore_case: false, + include_globs: vec![], + exclude_globs: vec![], + max_matches: None, + max_line_bytes: None, + search_content: false, + search_paths: true, + }, + ) + .await + .unwrap(); + let paths: Vec<_> = out.path_matches.iter().map(|p| p.path.as_str()).collect(); + assert!(paths.contains(&"src/foo.rs")); + assert!(!paths.contains(&"src/bar.ts")); + } + + #[tokio::test] + async fn non_accessible_files_skipped() { + let (tmp, r, c) = setup(); + write(&tmp, ".env", "API_KEY=needle"); + write(&tmp, "a.txt", "needle here"); + let out = handle( + r, + c, + SearchInput { + query: "needle".into(), + path: ".".into(), + regex: false, + ignore_case: false, + include_globs: vec![], + exclude_globs: vec![], + max_matches: None, + max_line_bytes: None, + search_content: true, + search_paths: true, + }, + ) + .await + .unwrap(); + // The .env file must not appear in either match list. + for m in &out.content_matches { + assert_ne!(m.path, ".env", "non-accessible file leaked content"); + } + for m in &out.path_matches { + assert_ne!(m.path, ".env", "non-accessible file leaked path"); + } + } + + #[tokio::test] + async fn include_and_exclude_globs() { + let (tmp, r, c) = setup(); + write(&tmp, "src/a.rs", "needle"); + write(&tmp, "src/b.ts", "needle"); + write(&tmp, "build/c.rs", "needle"); + let out = handle( + r, + c, + SearchInput { + query: "needle".into(), + path: ".".into(), + regex: false, + ignore_case: false, + include_globs: vec!["**/*.rs".into()], + exclude_globs: vec!["build/**".into()], + max_matches: None, + max_line_bytes: None, + search_content: true, + search_paths: false, + }, + ) + .await + .unwrap(); + let paths: Vec<_> = out + .content_matches + .iter() + .map(|m| m.path.as_str()) + .collect(); + assert_eq!(paths, vec!["src/a.rs"]); + } + + #[tokio::test] + async fn truncation_flag_when_cap_hit() { + let (tmp, r, _c) = setup(); + for i in 0..5 { + write(&tmp, &format!("f{i}.txt"), "needle\n"); + } + let cfg = Arc::new(CoderConfig { + base_path: tmp.path().to_path_buf(), + non_accessible_globs: vec![], + search_default_max_matches: 1000, + max_read_bytes: 1024 * 1024, + ..CoderConfig::default() + }); + let out = handle( + r, + cfg, + SearchInput { + query: "needle".into(), + path: ".".into(), + regex: false, + ignore_case: false, + include_globs: vec![], + exclude_globs: vec![], + max_matches: Some(2), + max_line_bytes: None, + search_content: true, + search_paths: false, + }, + ) + .await + .unwrap(); + assert_eq!(out.content_matches.len(), 2); + assert!(out.truncated); + } + + #[tokio::test] + async fn empty_query_rejected() { + let (_tmp, r, c) = setup(); + let err = handle( + r, + c, + SearchInput { + query: "".into(), + path: ".".into(), + regex: false, + ignore_case: false, + include_globs: vec![], + exclude_globs: vec![], + max_matches: None, + max_line_bytes: None, + search_content: true, + search_paths: true, + }, + ) + .await + .unwrap_err(); + assert!(err.contains("C210"), "got: {err}"); + } + + #[tokio::test] + async fn ignore_case_literal() { + let (tmp, r, c) = setup(); + write(&tmp, "a.txt", "NEEDLE here"); + let out = handle( + r, + c, + SearchInput { + query: "needle".into(), + path: ".".into(), + regex: false, + ignore_case: true, + include_globs: vec![], + exclude_globs: vec![], + max_matches: None, + max_line_bytes: None, + search_content: true, + search_paths: false, + }, + ) + .await + .unwrap(); + assert_eq!(out.content_matches.len(), 1); + } + + #[tokio::test] + async fn skips_binary_files() { + let (tmp, r, c) = setup(); + std::fs::write(tmp.path().join("blob.bin"), [0u8, 1, 2, 3, b'n', b'e']).unwrap(); + let out = handle( + r, + c, + SearchInput { + query: "ne".into(), + path: ".".into(), + regex: false, + ignore_case: false, + include_globs: vec![], + exclude_globs: vec![], + max_matches: None, + max_line_bytes: None, + search_content: true, + search_paths: false, + }, + ) + .await + .unwrap(); + assert!(out.content_matches.is_empty()); + } + + #[tokio::test] + async fn path_scopes_walk_to_subdir() { + let (tmp, r, c) = setup(); + write(&tmp, "a/x.txt", "needle here"); + write(&tmp, "b/y.txt", "needle here"); + let out = handle( + r, + c, + SearchInput { + query: "needle".into(), + path: "a".into(), + regex: false, + ignore_case: false, + include_globs: vec![], + exclude_globs: vec![], + max_matches: None, + max_line_bytes: None, + search_content: true, + search_paths: false, + }, + ) + .await + .unwrap(); + let paths: Vec<_> = out + .content_matches + .iter() + .map(|m| m.path.as_str()) + .collect(); + assert_eq!(paths, vec!["a/x.txt"]); + } +} diff --git a/coder/src/functions/tree.rs b/coder/src/functions/tree.rs new file mode 100644 index 000000000..533b51182 --- /dev/null +++ b/coder/src/functions/tree.rs @@ -0,0 +1,372 @@ +//! `coder::tree` — recursive directory snapshot, bounded by `max_depth` +//! and a `per_folder_limit`. Folders that hit the limit are tagged with +//! a `truncated` block pointing the caller at `coder::list-folder` for +//! pagination — matching the user's "if folder contains thousands of +//! files, it should show an indication it loaded only 50" requirement. + +use std::path::Path; +use std::sync::Arc; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::config::CoderConfig; +use crate::error::{err_to_string, CoderError}; +use crate::path::PathResolver; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct TreeInput { + /// Base folder relative to `base_path`. Defaults to `.`. + #[serde(default = "default_path")] + pub path: String, + /// Maximum depth to descend; the root node is depth 0. + #[serde(default)] + pub max_depth: Option, + /// Maximum children listed per folder. When more exist, the folder is + /// flagged `truncated` and callers should switch to `coder::list-folder`. + #[serde(default)] + pub per_folder_limit: Option, +} + +fn default_path() -> String { + ".".to_string() +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct TreeOutput { + pub root: TreeNode, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct TreeNode { + pub name: String, + pub path: String, + pub kind: NodeKind, + pub size: u64, + pub mtime: i64, + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub non_accessible: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub children: Option>, + /// Set on directories whose `children` was capped at `per_folder_limit` + /// or whose subtree was cut off by `max_depth`. + #[serde(skip_serializing_if = "Option::is_none")] + pub truncated: Option, +} + +#[derive(Debug, Serialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum NodeKind { + File, + Dir, + Symlink, + Other, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct TruncationInfo { + /// Reason this folder was truncated: hit `per_folder_limit` or + /// `max_depth`. + pub reason: String, + /// Number of children actually returned. + pub shown: u32, + /// Total number of children in the folder (only populated when + /// `reason == "per_folder_limit"`; for depth truncation we don't + /// peek into the folder). + #[serde(skip_serializing_if = "Option::is_none")] + pub total: Option, + pub hint: String, +} + +pub async fn handle( + resolver: Arc, + cfg: Arc, + req: TreeInput, +) -> Result { + inner(&resolver, &cfg, req).map_err(err_to_string) +} + +fn inner( + resolver: &PathResolver, + cfg: &CoderConfig, + req: TreeInput, +) -> Result { + let abs = resolver.resolve(&req.path)?; + let md = std::fs::metadata(&abs)?; + if !md.is_dir() { + return Err(CoderError::BadInput(format!( + "not a directory: {}", + req.path + ))); + } + let max_depth = req.max_depth.unwrap_or(cfg.tree_default_depth); + let per_folder_limit = req + .per_folder_limit + .unwrap_or(cfg.tree_per_folder_limit) + .max(1); + + let root_rel = resolver.relative(&abs).unwrap_or_default(); + let root = walk_dir(resolver, &abs, root_rel, 0, max_depth, per_folder_limit)?; + Ok(TreeOutput { root }) +} + +fn walk_dir( + resolver: &PathResolver, + abs: &Path, + rel: String, + depth: u32, + max_depth: u32, + per_folder_limit: u32, +) -> Result { + let md = std::fs::metadata(abs)?; + let name = abs + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| ".".to_string()); + + let mut node = TreeNode { + name, + path: rel.clone(), + kind: NodeKind::Dir, + size: md.len(), + mtime: unix_mtime(&md), + non_accessible: resolver.is_non_accessible(abs), + children: None, + truncated: None, + }; + + if depth >= max_depth { + node.truncated = Some(TruncationInfo { + reason: "max_depth".to_string(), + shown: 0, + total: None, + hint: "raise max_depth or call coder::tree with this path as the new root".into(), + }); + return Ok(node); + } + + let mut entries: Vec = match std::fs::read_dir(abs) { + Ok(it) => it.filter_map(|e| e.ok()).collect(), + Err(e) => { + // Surface as a node with no children rather than failing the whole tree. + node.truncated = Some(TruncationInfo { + reason: "io_error".to_string(), + shown: 0, + total: None, + hint: format!("read_dir failed: {e}"), + }); + return Ok(node); + } + }; + entries.sort_by_key(|a| a.file_name()); + + let total = entries.len() as u32; + let cap = per_folder_limit as usize; + let truncated_here = total as usize > cap; + let visible = if truncated_here { + &entries[..cap] + } else { + &entries[..] + }; + + let mut children = Vec::with_capacity(visible.len()); + for e in visible { + let child_abs = e.path(); + let child_rel = if rel.is_empty() { + e.file_name().to_string_lossy().into_owned() + } else { + format!("{}/{}", rel, e.file_name().to_string_lossy()) + }; + let ft = e.file_type().ok(); + if ft.as_ref().is_some_and(|t| t.is_dir()) { + let sub = walk_dir( + resolver, + &child_abs, + child_rel, + depth + 1, + max_depth, + per_folder_limit, + )?; + children.push(sub); + } else { + let cmd = match e.metadata() { + Ok(m) => m, + Err(_) => continue, + }; + children.push(TreeNode { + name: e.file_name().to_string_lossy().into_owned(), + path: child_rel, + kind: classify(&cmd), + size: cmd.len(), + mtime: unix_mtime(&cmd), + non_accessible: resolver.is_non_accessible(&child_abs), + children: None, + truncated: None, + }); + } + } + node.children = Some(children); + if truncated_here { + node.truncated = Some(TruncationInfo { + reason: "per_folder_limit".to_string(), + shown: cap as u32, + total: Some(total), + hint: "use coder::list-folder for paginated access to all entries".into(), + }); + } + Ok(node) +} + +fn classify(md: &std::fs::Metadata) -> NodeKind { + let ft = md.file_type(); + if ft.is_symlink() { + NodeKind::Symlink + } else if ft.is_dir() { + NodeKind::Dir + } else if ft.is_file() { + NodeKind::File + } else { + NodeKind::Other + } +} + +fn unix_mtime(md: &std::fs::Metadata) -> i64 { + md.modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn setup() -> (tempfile::TempDir, Arc, Arc) { + let tmp = tempdir().unwrap(); + let cfg = Arc::new(CoderConfig { + base_path: tmp.path().to_path_buf(), + non_accessible_globs: vec!["**/.env".to_string()], + tree_default_depth: 4, + tree_per_folder_limit: 50, + ..CoderConfig::default() + }); + let resolver = Arc::new(PathResolver::new(&cfg).unwrap()); + (tmp, resolver, cfg) + } + + #[tokio::test] + async fn tree_with_nested_dirs() { + let (tmp, r, c) = setup(); + std::fs::create_dir_all(tmp.path().join("a/b")).unwrap(); + std::fs::write(tmp.path().join("a/b/c.txt"), "hi").unwrap(); + std::fs::write(tmp.path().join("z.txt"), "x").unwrap(); + + let out = handle( + r, + c, + TreeInput { + path: ".".into(), + max_depth: None, + per_folder_limit: None, + }, + ) + .await + .unwrap(); + let root = out.root; + assert!(matches!(root.kind, NodeKind::Dir)); + let children = root.children.unwrap(); + let names: Vec<_> = children.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, vec!["a", "z.txt"]); + let a = &children[0]; + let a_children = a.children.as_ref().unwrap(); + assert_eq!(a_children[0].name, "b"); + let b_children = a_children[0].children.as_ref().unwrap(); + assert_eq!(b_children[0].name, "c.txt"); + } + + #[tokio::test] + async fn max_depth_truncates_subtree() { + let (tmp, r, _c) = setup(); + std::fs::create_dir_all(tmp.path().join("a/b/c")).unwrap(); + std::fs::write(tmp.path().join("a/b/c/x.txt"), "x").unwrap(); + let cfg = Arc::new(CoderConfig { + base_path: tmp.path().to_path_buf(), + tree_default_depth: 1, + tree_per_folder_limit: 50, + ..CoderConfig::default() + }); + let out = handle( + r, + cfg, + TreeInput { + path: ".".into(), + max_depth: None, + per_folder_limit: None, + }, + ) + .await + .unwrap(); + let a = &out.root.children.unwrap()[0]; + // a is depth 1, which equals max_depth → should be truncated, no children loaded. + assert!(a.children.is_none()); + let trunc = a.truncated.as_ref().unwrap(); + assert_eq!(trunc.reason, "max_depth"); + } + + #[tokio::test] + async fn per_folder_limit_truncates_with_total() { + let (tmp, r, _c) = setup(); + for i in 0..10 { + std::fs::write(tmp.path().join(format!("f{i:02}.txt")), "x").unwrap(); + } + let cfg = Arc::new(CoderConfig { + base_path: tmp.path().to_path_buf(), + tree_default_depth: 4, + tree_per_folder_limit: 3, + ..CoderConfig::default() + }); + let out = handle( + r, + cfg, + TreeInput { + path: ".".into(), + max_depth: None, + per_folder_limit: None, + }, + ) + .await + .unwrap(); + let kids = out.root.children.as_ref().unwrap(); + assert_eq!(kids.len(), 3); + let trunc = out.root.truncated.as_ref().unwrap(); + assert_eq!(trunc.reason, "per_folder_limit"); + assert_eq!(trunc.shown, 3); + assert_eq!(trunc.total, Some(10)); + assert!(trunc.hint.contains("coder::list-folder")); + } + + #[tokio::test] + async fn non_accessible_flag_set_on_matching_entries() { + let (tmp, r, c) = setup(); + std::fs::write(tmp.path().join(".env"), "x").unwrap(); + std::fs::write(tmp.path().join("a.txt"), "x").unwrap(); + let out = handle( + r, + c, + TreeInput { + path: ".".into(), + max_depth: None, + per_folder_limit: None, + }, + ) + .await + .unwrap(); + let kids = out.root.children.unwrap(); + let env = kids.iter().find(|k| k.name == ".env").unwrap(); + assert!(env.non_accessible); + let a = kids.iter().find(|k| k.name == "a.txt").unwrap(); + assert!(!a.non_accessible); + } +} diff --git a/coder/src/functions/update_file.rs b/coder/src/functions/update_file.rs new file mode 100644 index 000000000..627d0920c --- /dev/null +++ b/coder/src/functions/update_file.rs @@ -0,0 +1,937 @@ +//! `coder::update-file` — batched line-oriented and regex edits across one +//! or more files. Line ops are applied **bottom-up** so earlier ops see the +//! original line numbers; regex replaces run afterward on the serialized +//! body. Each file commits atomically via sibling temp + `rename`. +//! +//! Line ops (1-based, inclusive): +//! +//! - `{ op: "insert", at_line: N, content: "..." }` — insert before line N. +//! `N = lines + 1` appends at the end. +//! - `{ op: "remove", from_line: A, to_line: B }` — delete lines A..=B. +//! - `{ op: "update_lines", from_line: A, to_line: B, content: "..." }` — +//! overwrite lines A..=B with `content` (split by `\n`). +//! +//! Content op: +//! +//! - `{ op: "replace", pattern: "...", replacement: "...", ignore_case?: bool }` +//! — regex substitution on the full file body after line ops. + +use std::path::Path; +use std::sync::Arc; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::config::CoderConfig; +use crate::error::{err_to_string, CoderError}; +use crate::path::PathResolver; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateFileInput { + pub files: Vec, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateFileSpec { + pub path: String, + pub ops: Vec, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +#[serde(tag = "op", rename_all = "lowercase")] +pub enum UpdateOp { + /// Insert `content` before line `at_line` (1-based). `at_line = lines+1` + /// appends to end of file. + Insert { at_line: u32, content: String }, + /// Delete lines `from_line..=to_line` (1-based, inclusive). + Remove { from_line: u32, to_line: u32 }, + /// Overwrite lines `from_line..=to_line` with `content`. + #[serde(rename = "update_lines")] + UpdateLines { + from_line: u32, + to_line: u32, + content: String, + }, + /// Replace all regex matches in the file body (after line ops). + Replace { + pattern: String, + replacement: String, + #[serde(default)] + ignore_case: bool, + }, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct UpdateFileOutput { + pub results: Vec, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct UpdateFileResult { + pub path: String, + pub success: bool, + /// Number of operations applied (only meaningful when `success`). + pub applied: u32, + /// Final line count after applying (only meaningful when `success`). + pub new_line_count: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +pub async fn handle( + resolver: Arc, + cfg: Arc, + req: UpdateFileInput, +) -> Result { + if req.files.is_empty() { + return Err(err_to_string(CoderError::BadInput( + "`files` must not be empty".into(), + ))); + } + let mut results = Vec::with_capacity(req.files.len()); + for spec in req.files { + results.push(update_one(&resolver, &cfg, spec)); + } + Ok(UpdateFileOutput { results }) +} + +fn update_one( + resolver: &PathResolver, + cfg: &CoderConfig, + spec: UpdateFileSpec, +) -> UpdateFileResult { + let path = spec.path.clone(); + match try_update_one(resolver, cfg, spec) { + Ok((applied, new_line_count)) => UpdateFileResult { + path, + success: true, + applied, + new_line_count, + error: None, + }, + Err(e) => UpdateFileResult { + path, + success: false, + applied: 0, + new_line_count: 0, + error: Some(e.to_wire_string()), + }, + } +} + +fn try_update_one( + resolver: &PathResolver, + cfg: &CoderConfig, + spec: UpdateFileSpec, +) -> Result<(u32, u64), CoderError> { + let abs = resolver.require_writable(&spec.path)?; + let md = std::fs::metadata(&abs)?; + if !md.is_file() { + return Err(CoderError::BadInput(format!( + "not a regular file: {}", + spec.path + ))); + } + if md.len() > cfg.max_write_bytes { + return Err(CoderError::TooLarge(format!( + "current file size {} exceeds max_write_bytes {}", + md.len(), + cfg.max_write_bytes + ))); + } + if spec.ops.is_empty() { + return Err(CoderError::BadInput("ops must not be empty".into())); + } + + let bytes = std::fs::read(&abs)?; + let (mut lines, ending, has_trailing) = split_file(&bytes); + let original_len = lines.len(); + + let (line_ops, replace_ops): (Vec<&UpdateOp>, Vec<&UpdateOp>) = + spec.ops.iter().partition(|op| is_line_op(op)); + + validate_line_ops(&line_ops, original_len)?; + apply_line_ops(&mut lines, &line_ops)?; + + let mut new_bytes = join_lines(&lines, ending, has_trailing); + new_bytes = apply_regex_replaces(new_bytes, &replace_ops)?; + + let (final_lines, _, _) = split_file(&new_bytes); + if (new_bytes.len() as u64) > cfg.max_write_bytes { + return Err(CoderError::TooLarge(format!( + "new file size {} exceeds max_write_bytes {}", + new_bytes.len(), + cfg.max_write_bytes + ))); + } + atomic_write(&abs, &new_bytes)?; + Ok((spec.ops.len() as u32, final_lines.len() as u64)) +} + +fn is_line_op(op: &UpdateOp) -> bool { + matches!( + op, + UpdateOp::Insert { .. } | UpdateOp::Remove { .. } | UpdateOp::UpdateLines { .. } + ) +} + +/// Apply line ops to `lines` in place. Ops are sorted by their first-affected +/// original line, descending, then applied so each op sees the original +/// line numbers from the caller's perspective. +fn apply_line_ops(lines: &mut Vec, ops: &[&UpdateOp]) -> Result<(), CoderError> { + if ops.is_empty() { + return Ok(()); + } + + let mut order: Vec = (0..ops.len()).collect(); + order.sort_by(|&i, &j| anchor(ops[j]).cmp(&anchor(ops[i]))); + + for &i in &order { + match ops[i] { + UpdateOp::Insert { at_line, content } => { + let pos = (*at_line as usize) - 1; + let new_lines = split_content(content); + lines.splice(pos..pos, new_lines); + } + UpdateOp::Remove { from_line, to_line } => { + let a = (*from_line as usize) - 1; + let b = *to_line as usize; + lines.splice(a..b, std::iter::empty::()); + } + UpdateOp::UpdateLines { + from_line, + to_line, + content, + } => { + let a = (*from_line as usize) - 1; + let b = *to_line as usize; + let new_lines = split_content(content); + lines.splice(a..b, new_lines); + } + UpdateOp::Replace { .. } => unreachable!("line ops only"), + } + } + Ok(()) +} + +fn apply_regex_replaces(mut bytes: Vec, ops: &[&UpdateOp]) -> Result, CoderError> { + for op in ops { + let UpdateOp::Replace { + pattern, + replacement, + ignore_case, + } = op + else { + continue; + }; + if pattern.is_empty() { + return Err(CoderError::BadInput( + "replace.pattern must not be empty".into(), + )); + } + let mut builder = regex::RegexBuilder::new(pattern); + builder.case_insensitive(*ignore_case); + let re = builder + .build() + .map_err(|e| CoderError::BadInput(format!("bad regex {pattern:?}: {e}")))?; + let s = String::from_utf8_lossy(&bytes); + let out = re.replace_all(&s, replacement.as_str()); + bytes = out.into_owned().into_bytes(); + } + Ok(bytes) +} + +/// Apply line ops only — used by unit tests that exercise line semantics +/// without the full file pipeline. +pub fn apply_ops( + lines: &mut Vec, + ops: &[UpdateOp], + original_len: usize, +) -> Result { + if ops.is_empty() { + return Err(CoderError::BadInput("ops must not be empty".into())); + } + if ops.iter().any(|op| matches!(op, UpdateOp::Replace { .. })) { + return Err(CoderError::BadInput( + "apply_ops does not support regex replace ops".into(), + )); + } + let refs: Vec<&UpdateOp> = ops.iter().collect(); + validate_line_ops(&refs, original_len)?; + apply_line_ops(lines, &refs)?; + Ok(ops.len() as u32) +} + +fn anchor(op: &UpdateOp) -> u32 { + match op { + UpdateOp::Insert { at_line, .. } => *at_line, + UpdateOp::Remove { from_line, .. } => *from_line, + UpdateOp::UpdateLines { from_line, .. } => *from_line, + UpdateOp::Replace { .. } => 0, + } +} + +fn validate_line_ops(ops: &[&UpdateOp], original_len: usize) -> Result<(), CoderError> { + let len = original_len as u32; + for op in ops { + match op { + UpdateOp::Insert { at_line, .. } => { + if *at_line == 0 || *at_line > len + 1 { + return Err(CoderError::BadInput(format!( + "insert.at_line {at_line} out of range (1..={})", + len + 1 + ))); + } + } + UpdateOp::Remove { from_line, to_line } + | UpdateOp::UpdateLines { + from_line, to_line, .. + } => { + if *from_line == 0 || *from_line > *to_line || *to_line > len { + return Err(CoderError::BadInput(format!( + "range {from_line}-{to_line} invalid for file with {len} lines" + ))); + } + } + UpdateOp::Replace { .. } => unreachable!("line ops only"), + } + } + for i in 0..ops.len() { + for j in (i + 1)..ops.len() { + if covers_overlap(ops[i], ops[j]) { + return Err(CoderError::BadInput(format!( + "ops #{} and #{} overlap in original-line space", + i + 1, + j + 1 + ))); + } + } + } + Ok(()) +} + +fn covers_overlap(a: &UpdateOp, b: &UpdateOp) -> bool { + let (a_lo, a_hi) = cover(a); + let (b_lo, b_hi) = cover(b); + a_lo.max(b_lo) <= a_hi.min(b_hi) +} + +/// Inclusive range of original lines an op touches. Inserts cover a single +/// point at their `at_line`. +fn cover(op: &UpdateOp) -> (u32, u32) { + match op { + UpdateOp::Insert { at_line, .. } => (*at_line, *at_line), + UpdateOp::Remove { from_line, to_line } => (*from_line, *to_line), + UpdateOp::UpdateLines { + from_line, to_line, .. + } => (*from_line, *to_line), + UpdateOp::Replace { .. } => (0, 0), + } +} + +/// Split `content` into lines, dropping the trailing empty line a +/// final `\n` would create. `\r` from CRLF endings is trimmed. +fn split_content(content: &str) -> Vec { + content + .lines() + .map(|l| l.trim_end_matches('\r').to_string()) + .collect() +} + +#[derive(Debug, Clone, Copy)] +enum LineEnding { + Lf, + CrLf, +} + +fn split_file(bytes: &[u8]) -> (Vec, LineEnding, bool) { + let s = String::from_utf8_lossy(bytes); + let ending = if s.contains("\r\n") { + LineEnding::CrLf + } else { + LineEnding::Lf + }; + let has_trailing = s.ends_with('\n'); + let lines: Vec = s + .lines() + .map(|l| l.trim_end_matches('\r').to_string()) + .collect(); + (lines, ending, has_trailing) +} + +fn join_lines(lines: &[String], ending: LineEnding, trailing: bool) -> Vec { + let sep = match ending { + LineEnding::Lf => "\n", + LineEnding::CrLf => "\r\n", + }; + let mut out = lines.join(sep); + if trailing && !out.is_empty() { + out.push_str(sep); + } else if trailing && lines.is_empty() { + // Preserve a file that was just "\n" or empty as empty. + } + out.into_bytes() +} + +/// Write atomically via sibling temp file + rename. +fn atomic_write(target: &Path, bytes: &[u8]) -> Result<(), CoderError> { + let parent = target + .parent() + .ok_or_else(|| CoderError::Io(format!("no parent for {}", target.display())))?; + let mut tmp = std::ffi::OsString::from(target.file_name().unwrap_or_default()); + tmp.push(".coder-tmp-"); + tmp.push(format!("{}", std::process::id())); + tmp.push("-"); + tmp.push(format!("{}", rand_suffix())); + let tmp_path = parent.join(tmp); + std::fs::write(&tmp_path, bytes).map_err(|e| { + let _ = std::fs::remove_file(&tmp_path); + CoderError::Io(format!("tmp write: {e}")) + })?; + std::fs::rename(&tmp_path, target).map_err(|e| { + let _ = std::fs::remove_file(&tmp_path); + CoderError::Io(format!("rename: {e}")) + })?; + Ok(()) +} + +/// Cheap unique suffix so concurrent updates on the same target don't +/// collide on the temp path. Uses nanos-mod-1M; collision probability +/// is acceptable for the rename-or-fail path below. +fn rand_suffix() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.subsec_nanos() as u64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lines_of(s: &str) -> Vec { + s.lines().map(|l| l.to_string()).collect() + } + + #[test] + fn insert_at_1_prepends() { + let mut lines = lines_of("a\nb\nc"); + apply_ops( + &mut lines, + &[UpdateOp::Insert { + at_line: 1, + content: "X".into(), + }], + 3, + ) + .unwrap(); + assert_eq!(lines, vec!["X", "a", "b", "c"]); + } + + #[test] + fn insert_at_end_plus_one_appends() { + let mut lines = lines_of("a\nb\nc"); + apply_ops( + &mut lines, + &[UpdateOp::Insert { + at_line: 4, + content: "X".into(), + }], + 3, + ) + .unwrap(); + assert_eq!(lines, vec!["a", "b", "c", "X"]); + } + + #[test] + fn insert_multiline() { + let mut lines = lines_of("a\nb"); + apply_ops( + &mut lines, + &[UpdateOp::Insert { + at_line: 2, + content: "X\nY".into(), + }], + 2, + ) + .unwrap(); + assert_eq!(lines, vec!["a", "X", "Y", "b"]); + } + + #[test] + fn remove_range() { + let mut lines = lines_of("a\nb\nc\nd\ne"); + apply_ops( + &mut lines, + &[UpdateOp::Remove { + from_line: 2, + to_line: 4, + }], + 5, + ) + .unwrap(); + assert_eq!(lines, vec!["a", "e"]); + } + + #[test] + fn update_lines_range_multiline() { + let mut lines = lines_of("a\nb\nc\nd"); + apply_ops( + &mut lines, + &[UpdateOp::UpdateLines { + from_line: 2, + to_line: 3, + content: "X\nY\nZ".into(), + }], + 4, + ) + .unwrap(); + assert_eq!(lines, vec!["a", "X", "Y", "Z", "d"]); + } + + #[test] + fn bottom_up_apply_preserves_original_line_numbers() { + let mut lines = lines_of("a\nb\nc\nd\ne"); + let ops = vec![ + UpdateOp::Insert { + at_line: 1, + content: "X".into(), + }, + UpdateOp::Remove { + from_line: 2, + to_line: 4, + }, + UpdateOp::UpdateLines { + from_line: 5, + to_line: 5, + content: "E".into(), + }, + ]; + apply_ops(&mut lines, &ops, 5).unwrap(); + assert_eq!(lines, vec!["X", "a", "E"]); + } + + #[test] + fn overlapping_remove_and_update_lines_rejected() { + let mut lines = lines_of("a\nb\nc\nd"); + let err = apply_ops( + &mut lines, + &[ + UpdateOp::Remove { + from_line: 1, + to_line: 3, + }, + UpdateOp::UpdateLines { + from_line: 3, + to_line: 4, + content: "X".into(), + }, + ], + 4, + ) + .unwrap_err(); + assert_eq!(err.code(), "C210"); + } + + #[test] + fn insert_inside_remove_range_rejected() { + let mut lines = lines_of("a\nb\nc\nd"); + let err = apply_ops( + &mut lines, + &[ + UpdateOp::Remove { + from_line: 1, + to_line: 3, + }, + UpdateOp::Insert { + at_line: 2, + content: "X".into(), + }, + ], + 4, + ) + .unwrap_err(); + assert_eq!(err.code(), "C210"); + } + + #[test] + fn duplicate_insert_at_same_line_rejected() { + let mut lines = lines_of("a\nb"); + let err = apply_ops( + &mut lines, + &[ + UpdateOp::Insert { + at_line: 1, + content: "X".into(), + }, + UpdateOp::Insert { + at_line: 1, + content: "Y".into(), + }, + ], + 2, + ) + .unwrap_err(); + assert_eq!(err.code(), "C210"); + } + + #[test] + fn out_of_range_remove_rejected() { + let mut lines = lines_of("a\nb"); + let err = apply_ops( + &mut lines, + &[UpdateOp::Remove { + from_line: 1, + to_line: 9, + }], + 2, + ) + .unwrap_err(); + assert_eq!(err.code(), "C210"); + } + + #[test] + fn zero_line_rejected() { + let mut lines = lines_of("a"); + let err = apply_ops( + &mut lines, + &[UpdateOp::Insert { + at_line: 0, + content: "X".into(), + }], + 1, + ) + .unwrap_err(); + assert_eq!(err.code(), "C210"); + } + + #[test] + fn empty_ops_rejected() { + let mut lines = lines_of("a"); + let err = apply_ops(&mut lines, &[], 1).unwrap_err(); + assert_eq!(err.code(), "C210"); + } + + #[test] + fn split_file_detects_crlf() { + let bytes = b"a\r\nb\r\nc\r\n"; + let (lines, ending, trailing) = split_file(bytes); + assert_eq!(lines, vec!["a", "b", "c"]); + assert!(matches!(ending, LineEnding::CrLf)); + assert!(trailing); + let out = join_lines(&lines, ending, trailing); + assert_eq!(out, b"a\r\nb\r\nc\r\n"); + } + + #[test] + fn split_file_no_trailing_newline_preserved() { + let bytes = b"a\nb"; + let (lines, ending, trailing) = split_file(bytes); + assert_eq!(lines, vec!["a", "b"]); + assert!(matches!(ending, LineEnding::Lf)); + assert!(!trailing); + let out = join_lines(&lines, ending, trailing); + assert_eq!(out, b"a\nb"); + } + + #[test] + fn regex_replace_all_matches() { + let out = apply_regex_replaces( + b"foo bar foo".to_vec(), + &[&UpdateOp::Replace { + pattern: "foo".into(), + replacement: "baz".into(), + ignore_case: false, + }], + ) + .unwrap(); + assert_eq!(out, b"baz bar baz"); + } + + #[test] + fn regex_replace_capture_groups() { + let out = apply_regex_replaces( + b"HOST=8080".to_vec(), + &[&UpdateOp::Replace { + pattern: r"(\w+)=(\d+)".into(), + replacement: "$1: $2".into(), + ignore_case: false, + }], + ) + .unwrap(); + assert_eq!(out, b"HOST: 8080"); + } + + #[test] + fn regex_replace_ignore_case() { + let out = apply_regex_replaces( + b"Foo FOO".to_vec(), + &[&UpdateOp::Replace { + pattern: "foo".into(), + replacement: "bar".into(), + ignore_case: true, + }], + ) + .unwrap(); + assert_eq!(out, b"bar bar"); + } + + #[test] + fn regex_replace_invalid_pattern_rejected() { + let err = apply_regex_replaces( + b"x".to_vec(), + &[&UpdateOp::Replace { + pattern: "[unclosed".into(), + replacement: "y".into(), + ignore_case: false, + }], + ) + .unwrap_err(); + assert_eq!(err.code(), "C210"); + } + + #[test] + fn regex_replace_empty_pattern_rejected() { + let err = apply_regex_replaces( + b"x".to_vec(), + &[&UpdateOp::Replace { + pattern: String::new(), + replacement: "y".into(), + ignore_case: false, + }], + ) + .unwrap_err(); + assert_eq!(err.code(), "C210"); + } + + #[test] + fn regex_replace_no_match_is_noop() { + let out = apply_regex_replaces( + b"hello".to_vec(), + &[&UpdateOp::Replace { + pattern: "missing".into(), + replacement: "x".into(), + ignore_case: false, + }], + ) + .unwrap(); + assert_eq!(out, b"hello"); + } +} + +#[cfg(test)] +mod handler_tests { + use super::*; + use tempfile::tempdir; + + fn setup() -> (tempfile::TempDir, Arc, Arc) { + let tmp = tempdir().unwrap(); + let cfg = Arc::new(CoderConfig { + base_path: tmp.path().to_path_buf(), + non_accessible_globs: vec!["**/.env".to_string()], + max_read_bytes: 1024 * 1024, + max_write_bytes: 1024 * 1024, + ..CoderConfig::default() + }); + let resolver = Arc::new(PathResolver::new(&cfg).unwrap()); + (tmp, resolver, cfg) + } + + #[tokio::test] + async fn end_to_end_single_file_update_lines_writes_atomically() { + let (tmp, r, c) = setup(); + std::fs::write(tmp.path().join("a.txt"), "1\n2\n3\n").unwrap(); + let out = handle( + r, + c, + UpdateFileInput { + files: vec![UpdateFileSpec { + path: "a.txt".into(), + ops: vec![UpdateOp::UpdateLines { + from_line: 2, + to_line: 2, + content: "TWO".into(), + }], + }], + }, + ) + .await + .unwrap(); + assert_eq!(out.results.len(), 1); + let r0 = &out.results[0]; + assert!(r0.success, "got: {:?}", r0.error); + assert_eq!(r0.applied, 1); + assert_eq!( + std::fs::read_to_string(tmp.path().join("a.txt")).unwrap(), + "1\nTWO\n3\n" + ); + } + + #[tokio::test] + async fn end_to_end_regex_replace() { + let (tmp, r, c) = setup(); + std::fs::write(tmp.path().join("a.txt"), "foo bar foo\n").unwrap(); + let out = handle( + r, + c, + UpdateFileInput { + files: vec![UpdateFileSpec { + path: "a.txt".into(), + ops: vec![UpdateOp::Replace { + pattern: "foo".into(), + replacement: "baz".into(), + ignore_case: false, + }], + }], + }, + ) + .await + .unwrap(); + assert!(out.results[0].success); + assert_eq!( + std::fs::read_to_string(tmp.path().join("a.txt")).unwrap(), + "baz bar baz\n" + ); + } + + #[tokio::test] + async fn mixed_update_lines_then_regex_replace() { + let (tmp, r, c) = setup(); + std::fs::write(tmp.path().join("a.txt"), "OLD\nkeep\nOLD\n").unwrap(); + let out = handle( + r, + c, + UpdateFileInput { + files: vec![UpdateFileSpec { + path: "a.txt".into(), + ops: vec![ + UpdateOp::Remove { + from_line: 2, + to_line: 2, + }, + UpdateOp::Replace { + pattern: "OLD".into(), + replacement: "NEW".into(), + ignore_case: false, + }, + ], + }], + }, + ) + .await + .unwrap(); + assert!(out.results[0].success); + assert_eq!(out.results[0].applied, 2); + assert_eq!( + std::fs::read_to_string(tmp.path().join("a.txt")).unwrap(), + "NEW\nNEW\n" + ); + } + + #[tokio::test] + async fn regex_replace_introducing_newline_updates_line_count() { + let (tmp, r, c) = setup(); + std::fs::write(tmp.path().join("a.txt"), "a=b\n").unwrap(); + let out = handle( + r, + c, + UpdateFileInput { + files: vec![UpdateFileSpec { + path: "a.txt".into(), + ops: vec![UpdateOp::Replace { + pattern: "=".into(), + replacement: "=\n".into(), + ignore_case: false, + }], + }], + }, + ) + .await + .unwrap(); + assert!(out.results[0].success); + assert_eq!(out.results[0].new_line_count, 2); + } + + #[tokio::test] + async fn non_accessible_path_yields_per_file_error_without_failing_batch() { + let (tmp, r, c) = setup(); + std::fs::write(tmp.path().join(".env"), "API=1\n").unwrap(); + std::fs::write(tmp.path().join("a.txt"), "1\n").unwrap(); + let out = handle( + r, + c, + UpdateFileInput { + files: vec![ + UpdateFileSpec { + path: ".env".into(), + ops: vec![UpdateOp::Remove { + from_line: 1, + to_line: 1, + }], + }, + UpdateFileSpec { + path: "a.txt".into(), + ops: vec![UpdateOp::Insert { + at_line: 1, + content: "X".into(), + }], + }, + ], + }, + ) + .await + .unwrap(); + assert_eq!(out.results.len(), 2); + assert!(!out.results[0].success); + assert!(out.results[0].error.as_deref().unwrap().contains("C211")); + assert!(out.results[1].success); + assert_eq!( + std::fs::read_to_string(tmp.path().join("a.txt")).unwrap(), + "X\n1\n" + ); + } + + #[tokio::test] + async fn original_file_untouched_when_ops_invalid() { + let (tmp, r, c) = setup(); + let original = "1\n2\n3\n"; + std::fs::write(tmp.path().join("a.txt"), original).unwrap(); + let out = handle( + r, + c, + UpdateFileInput { + files: vec![UpdateFileSpec { + path: "a.txt".into(), + ops: vec![ + UpdateOp::Remove { + from_line: 1, + to_line: 2, + }, + UpdateOp::UpdateLines { + from_line: 2, + to_line: 3, + content: "X".into(), + }, + ], + }], + }, + ) + .await + .unwrap(); + assert!(!out.results[0].success); + assert_eq!( + std::fs::read_to_string(tmp.path().join("a.txt")).unwrap(), + original + ); + } + + #[tokio::test] + async fn empty_files_array_rejected() { + let (_tmp, r, c) = setup(); + let err = handle(r, c, UpdateFileInput { files: vec![] }) + .await + .unwrap_err(); + assert!(err.contains("C210")); + } +} diff --git a/coder/src/lib.rs b/coder/src/lib.rs new file mode 100644 index 000000000..575c4a47b --- /dev/null +++ b/coder/src/lib.rs @@ -0,0 +1,9 @@ +//! 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 error; +pub mod functions; +pub mod manifest; +pub mod path; diff --git a/coder/src/main.rs b/coder/src/main.rs new file mode 100644 index 000000000..0d8e8282b --- /dev/null +++ b/coder/src/main.rs @@ -0,0 +1,107 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Parser; +use iii_sdk::{register_worker, InitOptions, OtelConfig, WorkerMetadata}; + +use coder::config; +use coder::functions; +use coder::manifest; +use coder::path::PathResolver; + +#[derive(Parser, Debug)] +#[command(name = "coder", about = "Path-jailed code worker for iii agents")] +struct Cli { + #[arg(long, default_value = "./config.yaml")] + config: String, + + #[arg(long, env = "III_URL", 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).expect("manifest serializes") + ); + return Ok(()); + } + + let cfg = match config::load_config(&cli.config) { + Ok(c) => { + tracing::info!( + base_path = %c.base_path.display(), + non_accessible_globs = c.non_accessible_globs.len(), + "loaded config from {}", + cli.config + ); + c + } + Err(e) => { + tracing::warn!( + error = %e, + path = %cli.config, + "failed to load config, using defaults" + ); + config::CoderConfig::default() + } + }; + let cfg = Arc::new(cfg); + + let resolver = match PathResolver::new(&cfg) { + Ok(r) => Arc::new(r), + Err(e) => { + // base_path canonicalization failure is operator config — + // refuse to start instead of degrading silently. + anyhow::bail!( + "failed to initialise PathResolver: {} (check `base_path` and `non_accessible_globs`)", + e.code() + ); + } + }; + tracing::info!( + base_root = %resolver.base_root().display(), + "path resolver ready" + ); + + tracing::info!(url = %cli.url, "connecting to III engine"); + let iii = register_worker( + &cli.url, + InitOptions { + otel: Some(OtelConfig::default()), + metadata: Some(WorkerMetadata { + runtime: "rust".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + name: "coder".to_string(), + os: std::env::consts::OS.to_string(), + pid: Some(std::process::id()), + telemetry: None, + ..WorkerMetadata::default() + }), + ..InitOptions::default() + }, + ); + + functions::register_all(&iii, resolver.clone(), cfg.clone()); + + tracing::info!("coder ready, waiting for invocations"); + tokio::signal::ctrl_c().await?; + tracing::info!("coder shutting down"); + iii.shutdown_async().await; + Ok(()) +} diff --git a/coder/src/manifest.rs b/coder/src/manifest.rs new file mode 100644 index 000000000..a3932c027 --- /dev/null +++ b/coder/src/manifest.rs @@ -0,0 +1,69 @@ +//! Manifest emitted by `coder --manifest`. The registry publish pipeline +//! runs the binary with `--manifest`, parses the JSON on stdout, and +//! writes it to the registry. The five required fields (`name`, +//! `version`, `description`, `default_config` object, non-empty +//! `supported_targets`) are validated by `POST /publish`. + +use serde::Serialize; + +const DESCRIPTION: &str = "Path-jailed code worker — read/search/update/create/delete files plus paginated list-folder and tree, with non-accessible glob protection."; + +#[derive(Serialize)] +pub struct ModuleManifest { + pub name: String, + pub version: String, + pub description: String, + pub default_config: serde_json::Value, + pub supported_targets: Vec, +} + +pub fn build_manifest() -> ModuleManifest { + ModuleManifest { + name: env!("CARGO_PKG_NAME").to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + description: DESCRIPTION.to_string(), + default_config: serde_json::json!({ + "base_path": "./", + "non_accessible_globs": [ + "**/.env", + "**/.env.*", + "**/*.pem", + "**/*.key", + "**/secrets/**" + ], + "max_read_bytes": 10_485_760u64, + "max_write_bytes": 10_485_760u64, + "tree_default_depth": 4, + "tree_per_folder_limit": 50, + "list_default_page_size": 100, + "list_max_page_size": 1_000, + "search_default_max_matches": 1_000, + "search_default_max_line_bytes": 4_096, + }), + supported_targets: vec![env!("TARGET").to_string()], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_roundtrip_has_required_fields() { + let m = build_manifest(); + let json = serde_json::to_string_pretty(&m).expect("serialize manifest"); + let parsed: serde_json::Value = + serde_json::from_str(&json).expect("manifest is valid JSON"); + assert_eq!(parsed["name"], env!("CARGO_PKG_NAME")); + assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION")); + assert!(parsed["description"] + .as_str() + .map(|s| !s.is_empty()) + .unwrap_or(false)); + assert!(parsed["default_config"].is_object()); + assert!(!parsed["supported_targets"] + .as_array() + .expect("array") + .is_empty()); + } +} diff --git a/coder/src/path/mod.rs b/coder/src/path/mod.rs new file mode 100644 index 000000000..dbdcca6e7 --- /dev/null +++ b/coder/src/path/mod.rs @@ -0,0 +1,295 @@ +//! Path resolution and access control. +//! +//! All wire-facing paths are *relative to `base_path`*. `PathResolver` +//! canonicalises them (symlink-aware) and verifies they remain inside +//! `base_root` so `..` and crafted symlinks cannot escape. A `GlobSet` +//! built from `non_accessible_globs` further blocks read/write/delete on +//! sensitive entries (`.env`, `*.pem`, …) while still allowing them to +//! appear in `list-folder`/`tree` listings. +//! +//! The symlink-safe canonicalisation walks to the longest existing +//! ancestor, canonicalises that, then lexically collapses the tail — +//! mirroring [`shell/src/fs/host.rs`](../../../shell/src/fs/host.rs) +//! `canonicalize_with_fallback`. + +use std::path::{Component, Path, PathBuf}; + +use globset::{Glob, GlobSet, GlobSetBuilder}; + +use crate::config::CoderConfig; +use crate::error::CoderError; + +#[derive(Debug)] +pub struct PathResolver { + base_root_canon: PathBuf, + non_accessible: GlobSet, +} + +impl PathResolver { + pub fn new(cfg: &CoderConfig) -> Result { + let base_root_canon = std::fs::canonicalize(&cfg.base_path).map_err(|e| { + CoderError::Io(format!( + "base_path {} cannot be canonicalized: {e}", + cfg.base_path.display() + )) + })?; + let mut builder = GlobSetBuilder::new(); + for pat in &cfg.non_accessible_globs { + let g = Glob::new(pat).map_err(|e| { + CoderError::BadInput(format!("invalid non_accessible_glob {pat:?}: {e}")) + })?; + builder.add(g); + } + let non_accessible = builder + .build() + .map_err(|e| CoderError::BadInput(format!("globset build failed: {e}")))?; + Ok(Self { + base_root_canon, + non_accessible, + }) + } + + pub fn base_root(&self) -> &Path { + &self.base_root_canon + } + + /// Resolve `rel` to a canonical absolute path under `base_root`. The + /// path need not exist; we walk to the longest existing ancestor, + /// canonicalise that, then lexically collapse the tail. Rejects: + /// + /// - absolute inputs → `C210` (the wire API is base-relative) + /// - results outside `base_root` → `C215` + /// - dangling symlinks in the tail → `C215` + pub fn resolve(&self, rel: &str) -> Result { + let rel_path = Path::new(rel); + if rel_path.is_absolute() { + return Err(CoderError::BadInput(format!( + "path must be relative to base_path: {rel}" + ))); + } + let joined = self.base_root_canon.join(rel_path); + let canon = canonicalize_with_fallback(&joined).map_err(|e| { + let msg = e.to_string(); + if msg.contains("dangling symlink in path") { + CoderError::OutsideBase(format!("{rel}: {msg}")) + } else if e.kind() == std::io::ErrorKind::InvalidInput + || e.kind() == std::io::ErrorKind::NotFound + { + CoderError::NotFoundOrDenied(format!("{rel}: {msg}")) + } else { + CoderError::Io(format!("canonicalize {rel}: {e}")) + } + })?; + if !canon.starts_with(&self.base_root_canon) { + return Err(CoderError::OutsideBase(format!( + "path escapes base_path: {rel}" + ))); + } + Ok(canon) + } + + /// Path's location relative to `base_root` as a forward-slash string, + /// suitable for glob matching and wire responses. Returns `None` if + /// `abs` isn't under `base_root` (should never happen for paths that + /// came out of `resolve`). + pub fn relative(&self, abs: &Path) -> Option { + abs.strip_prefix(&self.base_root_canon) + .ok() + .map(|p| p.to_string_lossy().replace('\\', "/")) + } + + /// True if `abs`'s base-relative form matches any non-accessible glob. + /// `abs` is expected to be a path previously returned by `resolve`. + pub fn is_non_accessible(&self, abs: &Path) -> bool { + let Some(rel) = self.relative(abs) else { + return false; + }; + if rel.is_empty() { + return false; + } + self.non_accessible.is_match(&rel) + } + + /// Resolve and reject if the result is on the non-accessible list. + /// Used by every mutating operation and by `read-file` so the same + /// glob hides both reads and writes. + pub fn require_writable(&self, rel: &str) -> Result { + let abs = self.resolve(rel)?; + if self.is_non_accessible(&abs) { + return Err(CoderError::NotFoundOrDenied(format!( + "path is non-accessible per config: {rel}" + ))); + } + Ok(abs) + } +} + +fn canonicalize_with_fallback(p: &Path) -> std::io::Result { + if let Ok(c) = std::fs::canonicalize(p) { + return Ok(c); + } + 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 { + Component::ParentDir => { + out.pop(); + } + Component::CurDir => {} + other => out.push(other), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn cfg_with(base: PathBuf, globs: Vec<&str>) -> CoderConfig { + CoderConfig { + base_path: base, + non_accessible_globs: globs.into_iter().map(String::from).collect(), + ..CoderConfig::default() + } + } + + #[test] + fn resolve_dot_returns_base_root() { + let tmp = tempdir().unwrap(); + let r = PathResolver::new(&cfg_with(tmp.path().to_path_buf(), vec![])).unwrap(); + let got = r.resolve(".").unwrap(); + assert_eq!(got, std::fs::canonicalize(tmp.path()).unwrap()); + } + + #[test] + fn resolve_existing_subpath() { + let tmp = tempdir().unwrap(); + std::fs::create_dir(tmp.path().join("sub")).unwrap(); + std::fs::write(tmp.path().join("sub/a.txt"), b"hi").unwrap(); + let r = PathResolver::new(&cfg_with(tmp.path().to_path_buf(), vec![])).unwrap(); + let got = r.resolve("sub/a.txt").unwrap(); + assert!(got.ends_with("sub/a.txt")); + assert!(got.starts_with(r.base_root())); + } + + #[test] + fn resolve_nonexistent_inside_base_succeeds_via_fallback() { + let tmp = tempdir().unwrap(); + let r = PathResolver::new(&cfg_with(tmp.path().to_path_buf(), vec![])).unwrap(); + let got = r.resolve("does/not/exist.txt").unwrap(); + assert!(got.starts_with(r.base_root())); + } + + #[test] + fn resolve_absolute_input_rejected_as_bad_input() { + let tmp = tempdir().unwrap(); + let r = PathResolver::new(&cfg_with(tmp.path().to_path_buf(), vec![])).unwrap(); + let err = r.resolve("/etc/passwd").unwrap_err(); + assert_eq!(err.code(), "C210"); + } + + #[test] + fn resolve_dotdot_escape_rejected_as_outside_base() { + let tmp = tempdir().unwrap(); + let r = PathResolver::new(&cfg_with(tmp.path().to_path_buf(), vec![])).unwrap(); + let err = r.resolve("../etc/passwd").unwrap_err(); + assert_eq!(err.code(), "C215"); + } + + #[test] + fn resolve_through_symlink_escape_rejected() { + let tmp = tempdir().unwrap(); + let outside = tempdir().unwrap(); + std::os::unix::fs::symlink(outside.path(), tmp.path().join("escape")).unwrap(); + let r = PathResolver::new(&cfg_with(tmp.path().to_path_buf(), vec![])).unwrap(); + let err = r.resolve("escape").unwrap_err(); + assert_eq!(err.code(), "C215"); + } + + #[test] + fn is_non_accessible_matches_root_dotenv() { + let tmp = tempdir().unwrap(); + std::fs::write(tmp.path().join(".env"), b"x").unwrap(); + let r = PathResolver::new(&cfg_with( + tmp.path().to_path_buf(), + vec!["**/.env", "**/.env.*"], + )) + .unwrap(); + let abs = r.resolve(".env").unwrap(); + assert!(r.is_non_accessible(&abs)); + } + + #[test] + fn is_non_accessible_matches_nested_dotenv() { + let tmp = tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join("a/b")).unwrap(); + std::fs::write(tmp.path().join("a/b/.env"), b"x").unwrap(); + let r = PathResolver::new(&cfg_with(tmp.path().to_path_buf(), vec!["**/.env"])).unwrap(); + let abs = r.resolve("a/b/.env").unwrap(); + assert!(r.is_non_accessible(&abs)); + } + + #[test] + fn is_non_accessible_false_for_unrelated_file() { + let tmp = tempdir().unwrap(); + std::fs::write(tmp.path().join("hello.txt"), b"x").unwrap(); + let r = PathResolver::new(&cfg_with(tmp.path().to_path_buf(), vec!["**/.env"])).unwrap(); + let abs = r.resolve("hello.txt").unwrap(); + assert!(!r.is_non_accessible(&abs)); + } + + #[test] + fn require_writable_rejects_non_accessible_with_c211() { + let tmp = tempdir().unwrap(); + std::fs::write(tmp.path().join(".env"), b"x").unwrap(); + let r = PathResolver::new(&cfg_with(tmp.path().to_path_buf(), vec!["**/.env"])).unwrap(); + let err = r.require_writable(".env").unwrap_err(); + assert_eq!(err.code(), "C211"); + } + + #[test] + fn new_with_invalid_glob_returns_bad_input() { + let tmp = tempdir().unwrap(); + let err = PathResolver::new(&cfg_with(tmp.path().to_path_buf(), vec!["["])).unwrap_err(); + assert_eq!(err.code(), "C210"); + } + + #[test] + fn new_with_missing_base_path_returns_io_error() { + let cfg = CoderConfig { + base_path: PathBuf::from("/this/does/not/exist/probably/xyz123"), + ..CoderConfig::default() + }; + let err = PathResolver::new(&cfg).unwrap_err(); + assert_eq!(err.code(), "C216"); + } +} diff --git a/coder/tests/bdd.rs b/coder/tests/bdd.rs new file mode 100644 index 000000000..6025f2db9 --- /dev/null +++ b/coder/tests/bdd.rs @@ -0,0 +1,49 @@ +//! BDD entry point. Runs every `.feature` file under `tests/features/`. +//! +//! Filter by tag to isolate slices: +//! +//! ```text +//! cargo test --test bdd # everything +//! cargo test --test bdd -- --tags @search # one tag +//! cargo test --test bdd -- --tags @security +//! cargo test --test bdd -- --tags "@engine and not @unix" +//! ``` +//! +//! All `@engine`-tagged scenarios soft-skip when no iii engine is +//! reachable, so CI hosts without `iii` on PATH still pass. + +mod common; +mod steps; + +use cucumber::World; + +use crate::common::world::CoderWorld; + +#[tokio::main] +async fn main() { + // Bring up the shared engine connection + in-process registrations + // exactly once. On a host without an engine this returns None and + // every `@engine` scenario short-circuits into a soft skip. + let _ = common::engine::get_or_init().await; + + CoderWorld::cucumber() + // Scenarios share a single engine connection + one base_path + // tempdir; running them concurrently means two scenarios + // trampling each other's fixture writes. + .max_concurrent_scenarios(1) + .before(|_feature, _rule, _scenario, world| { + Box::pin(async move { + if let Some(iii) = common::engine::get_or_init().await { + world.iii = Some(iii.clone()); + if let Some(shared) = common::workers::shared() { + world.cfg = shared.cfg.clone(); + world.base_path = Some(shared.base_path.clone()); + common::workers::reset_fs(&shared.base_path); + } + } + world.stash.clear(); + }) + }) + .run_and_exit("tests/features") + .await; +} diff --git a/coder/tests/common/engine.rs b/coder/tests/common/engine.rs new file mode 100644 index 000000000..7300b5e30 --- /dev/null +++ b/coder/tests/common/engine.rs @@ -0,0 +1,64 @@ +//! Connect-or-skip wrapper around the iii SDK. +//! +//! One engine connection per test binary process via `OnceCell` — the +//! WebSocket handshake cost dwarfs cucumber's per-scenario overhead, so +//! every scenario shares the same client. When the engine is unreachable +//! `get_or_init` returns `None` and every `@engine` step soft-skips. + +use std::sync::Arc; +use std::time::Duration; + +use iii_sdk::{register_worker, IIIError, InitOptions, TriggerRequest, III}; +use serde_json::json; +use tokio::sync::OnceCell; + +const DEFAULT_WS_URL: &str = "ws://127.0.0.1:49134"; + +static ENGINE: OnceCell>> = OnceCell::const_new(); + +pub fn ws_url() -> String { + std::env::var("III_ENGINE_WS_URL").unwrap_or_else(|_| DEFAULT_WS_URL.to_string()) +} + +pub async fn try_connect_raw() -> Option> { + let url = ws_url(); + let iii = Arc::new(register_worker(&url, InitOptions::default())); + + for _ in 0..20 { + tokio::time::sleep(Duration::from_millis(250)).await; + let probe = iii + .trigger(TriggerRequest { + function_id: "engine::workers::list".to_string(), + payload: json!({}), + action: None, + timeout_ms: Some(800), + }) + .await; + match probe { + Ok(_) => return Some(iii), + Err(IIIError::NotConnected) => continue, + Err(_) => continue, + } + } + + eprintln!( + "[skip] iii engine not reachable at {url}; \ + set III_ENGINE_WS_URL or start `iii` to enable engine-bound BDD scenarios" + ); + iii.shutdown_async().await; + None +} + +/// Get-or-init the shared engine handle. Registers coder's own function +/// surface in-process so BDD scenarios drive the same code paths the +/// production binary would. +pub async fn get_or_init() -> Option> { + ENGINE + .get_or_init(|| async { + let iii = try_connect_raw().await?; + crate::common::workers::register_all(&iii).await.ok()?; + Some(iii) + }) + .await + .clone() +} diff --git a/coder/tests/common/helpers.rs b/coder/tests/common/helpers.rs new file mode 100644 index 000000000..e0c273583 --- /dev/null +++ b/coder/tests/common/helpers.rs @@ -0,0 +1,110 @@ +//! Shared step-def utilities: trigger the coder surface, write fixtures +//! into the per-scenario `base_path`, and pull stashed outcomes back +//! out for `Then` assertions. + +use cucumber::gherkin::Step; +use iii_sdk::TriggerRequest; +use serde_json::Value; + +use crate::common::world::CoderWorld; + +pub const LAST_OK: &str = "coder_last_ok"; +pub const LAST_ERR: &str = "coder_last_err"; + +/// Strip Cucumber's leading docstring newline. Cucumber inserts a `\n` +/// right after the opening `"""`; tests that care about exact file +/// contents (e.g. fixture reads) need that newline gone. +pub fn docstring_body(step: &Step) -> String { + let raw = step.docstring.as_deref().unwrap_or(""); + raw.strip_prefix('\n').unwrap_or(raw).to_string() +} + +/// Parse a docstring as a JSON payload. Panics with the offending text +/// when invalid so failures point at the feature line. +pub fn parse_payload(step: &Step) -> Value { + let raw = step.docstring.as_deref().unwrap_or("{}"); + serde_json::from_str(raw) + .unwrap_or_else(|e| panic!("payload docstring is not valid JSON: {raw:?} \u{2014} {e}")) +} + +/// Trigger a coder function over the shared SDK handle, then stash +/// either the success `Value` (`LAST_OK`) or the error string +/// (`LAST_ERR`). No-op when no engine is reachable (soft skip). +pub async fn call_coder(world: &mut CoderWorld, function_id: &str, payload: Value) { + world.stash.remove(LAST_OK); + world.stash.remove(LAST_ERR); + let Some(iii) = world.iii.clone() else { + return; + }; + match iii + .trigger(TriggerRequest { + function_id: function_id.to_string(), + payload, + action: None, + timeout_ms: Some(10_000), + }) + .await + { + Ok(v) => { + world.stash.insert(LAST_OK.into(), v); + } + Err(e) => { + world + .stash + .insert(LAST_ERR.into(), Value::String(e.to_string())); + } + } +} + +/// Write a fixture file under the per-scenario `base_path`. Creates +/// parent directories as needed. No-op when no engine is reachable. +pub fn write_fixture(world: &CoderWorld, rel: &str, body: &str) { + let Some(base) = world.base_path.as_ref() else { + return; + }; + let path = base.join(rel); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("create_dir_all"); + } + std::fs::write(&path, body).expect("write fixture"); +} + +/// Create a directory under the per-scenario `base_path`. No-op when +/// no engine is reachable. +pub fn mkdir(world: &CoderWorld, rel: &str) { + let Some(base) = world.base_path.as_ref() else { + return; + }; + let path = base.join(rel); + std::fs::create_dir_all(&path).expect("create_dir_all"); +} + +/// Stashed success result; panics if missing so the failure message +/// surfaces the last error (rather than a vague "missing key"). +pub fn last_ok(world: &CoderWorld) -> &Value { + world.stash.get(LAST_OK).unwrap_or_else(|| { + panic!( + "no successful call recorded; last error: {:?}", + world.stash.get(LAST_ERR) + ) + }) +} + +/// Stashed error string; empty when no error was recorded. +pub fn last_err(world: &CoderWorld) -> String { + world + .stash + .get(LAST_ERR) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string() +} + +/// Locate a batch result entry by `path` in `results[]`. Returns `None` +/// when missing. Shared by create-file / update-file / delete-file +/// assertions. +pub fn batch_result<'a>(world: &'a CoderWorld, path: &str) -> Option<&'a Value> { + let v = world.stash.get(LAST_OK)?; + let arr = v.get("results")?.as_array()?; + arr.iter().find(|e| e["path"].as_str() == Some(path)) +} diff --git a/coder/tests/common/mod.rs b/coder/tests/common/mod.rs new file mode 100644 index 000000000..a122f1116 --- /dev/null +++ b/coder/tests/common/mod.rs @@ -0,0 +1,7 @@ +//! Shared BDD harness — engine connect-or-skip, in-process worker +//! registration, per-scenario World, and reusable step helpers. + +pub mod engine; +pub mod helpers; +pub mod workers; +pub mod world; diff --git a/coder/tests/common/workers.rs b/coder/tests/common/workers.rs new file mode 100644 index 000000000..80e50479c --- /dev/null +++ b/coder/tests/common/workers.rs @@ -0,0 +1,103 @@ +//! In-process registration of the `coder` function surface against the +//! shared SDK handle. Re-uses the production entry point +//! `coder::functions::register_all` so scenarios exercise identical +//! code paths. Registration is idempotent (`OnceCell`); we wipe the +//! per-test `base_path` between scenarios so leftover files from one +//! scenario don't pollute the next. + +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::Result; +use iii_sdk::III; +use tokio::sync::OnceCell; + +use coder::config::CoderConfig; +use coder::functions; +use coder::path::PathResolver; + +/// Caps used by the shared in-process worker. Small enough that a few +/// kilobytes of fixture content triggers `C213` for oversize scenarios, +/// large enough that ordinary scenario fixtures (line-oriented text +/// files) fit comfortably. +const TEST_MAX_READ_BYTES: u64 = 4_096; +const TEST_MAX_WRITE_BYTES: u64 = 4_096; +/// Small enough that pagination scenarios can fill a page without +/// writing hundreds of fixtures. +const TEST_LIST_DEFAULT_PAGE_SIZE: u32 = 5; +const TEST_LIST_MAX_PAGE_SIZE: u32 = 100; + +pub struct Shared { + pub cfg: Arc, + /// Canonicalised `base_path` the worker reads + writes against. + /// Fixture writes from step defs go here. + pub base_path: PathBuf, +} + +static SHARED: OnceCell> = OnceCell::const_new(); + +/// Idempotent: the first caller registers; subsequent callers reuse. +pub async fn register_all(iii: &Arc) -> Result> { + if let Some(s) = SHARED.get() { + return Ok(s.clone()); + } + + // Leak the tempdir so it lives for the lifetime of the test binary. + let tmp = tempfile::tempdir()?; + let base_path = tmp.keep(); + std::fs::create_dir_all(&base_path)?; + // Canonicalise so the recorded path matches what `PathResolver` + // sees after its own canonicalisation. On macOS `/var/folders/...` + // and `/private/var/folders/...` are aliases; using the canonical + // form makes fixture writes from steps line up with handler reads. + let base_path = std::fs::canonicalize(&base_path)?; + + let cfg = Arc::new(CoderConfig { + base_path: base_path.clone(), + non_accessible_globs: vec!["**/.env".to_string(), "**/*.pem".to_string()], + max_read_bytes: TEST_MAX_READ_BYTES, + max_write_bytes: TEST_MAX_WRITE_BYTES, + list_default_page_size: TEST_LIST_DEFAULT_PAGE_SIZE, + list_max_page_size: TEST_LIST_MAX_PAGE_SIZE, + ..CoderConfig::default() + }); + let resolver = Arc::new(PathResolver::new(&cfg)?); + + functions::register_all(iii, resolver, cfg.clone()); + + // Give the SDK a beat to publish the function registrations before + // scenarios start triggering them. + tokio::time::sleep(std::time::Duration::from_millis(150)).await; + + let shared = Arc::new(Shared { cfg, base_path }); + let _ = SHARED.set(shared.clone()); + Ok(shared) +} + +pub fn shared() -> Option> { + SHARED.get().cloned() +} + +/// Wipe the per-test `base_path` between scenarios so leftover fixtures +/// don't leak. We clear the directory's contents rather than removing +/// the directory itself — `PathResolver` cached its canonical path at +/// startup and we want subsequent fixture writes to land at the same +/// inode. +pub fn reset_fs(base_path: &Path) { + let Ok(entries) = std::fs::read_dir(base_path) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let md = match std::fs::symlink_metadata(&path) { + Ok(m) => m, + Err(_) => continue, + }; + let _ = if md.file_type().is_dir() { + std::fs::remove_dir_all(&path) + } else { + std::fs::remove_file(&path) + }; + } +} diff --git a/coder/tests/common/world.rs b/coder/tests/common/world.rs new file mode 100644 index 000000000..97eb5bdc0 --- /dev/null +++ b/coder/tests/common/world.rs @@ -0,0 +1,50 @@ +//! Per-scenario World. +//! +//! Holds: +//! +//! - `iii` — `None` when the engine is unreachable so `@engine` steps +//! can soft-skip without failing the run. +//! - `cfg` — the `CoderConfig` the in-process registration used. +//! - `base_path` — canonical path to the shared tempdir scenarios write +//! fixtures into. Mirrors `cfg.base_path`. +//! - `stash` — JSON outcome slots a `Then` step reads after its +//! matching `When` step wrote (see `common::helpers`). + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use cucumber::World; +use iii_sdk::III; +use serde_json::Value; + +use coder::config::CoderConfig; + +#[derive(World)] +#[world(init = Self::new)] +pub struct CoderWorld { + pub iii: Option>, + pub cfg: Arc, + pub stash: HashMap, + pub base_path: Option, +} + +impl std::fmt::Debug for CoderWorld { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CoderWorld") + .field("engine_connected", &self.iii.is_some()) + .field("base_path", &self.base_path) + .finish() + } +} + +impl CoderWorld { + pub fn new() -> Self { + Self { + iii: None, + cfg: Arc::new(CoderConfig::default()), + stash: HashMap::new(), + base_path: None, + } + } +} diff --git a/coder/tests/features/create_file.feature b/coder/tests/features/create_file.feature new file mode 100644 index 000000000..9d1e2bb47 --- /dev/null +++ b/coder/tests/features/create_file.feature @@ -0,0 +1,95 @@ +@engine @create +Feature: coder::create-file + Per-file results are reported in a `results[]` array so a single bad + input never aborts the batch. Non-accessible paths return `C211`, + existing files without `overwrite` return `C217`, and an empty + `files` array is a top-level `C210`. + + Background: + Given the iii engine is reachable + + Scenario: create a single file with parent directories + When I call coder::create-file with payload: + """ + { + "files": [ + {"path": "notes/intro.md", "content": "# hello\n", "mode": "0644", "parents": true, "overwrite": false} + ] + } + """ + Then the call succeeded + And the result for "notes/intro.md" succeeded + And the result for "notes/intro.md" wrote 8 bytes + And the file "notes/intro.md" exists on disk + And the file "notes/intro.md" on disk contains "hello" + + Scenario: batch create two files reports per-file success + When I call coder::create-file with payload: + """ + {"files": [ + {"path": "a.txt", "content": "a", "mode": "0644", "parents": true, "overwrite": false}, + {"path": "b.txt", "content": "bb", "mode": "0644", "parents": true, "overwrite": false} + ]} + """ + Then the result for "a.txt" succeeded + And the result for "a.txt" wrote 1 bytes + And the result for "b.txt" succeeded + And the result for "b.txt" wrote 2 bytes + + Scenario: create without overwrite on an existing file fails with C217 + Given a file at "exists.txt" with content: + """ + old + """ + When I call coder::create-file with payload: + """ + {"files": [ + {"path": "exists.txt", "content": "new", "mode": "0644", "parents": true, "overwrite": false}, + {"path": "fresh.txt", "content": "fresh", "mode": "0644", "parents": true, "overwrite": false} + ]} + """ + Then the call succeeded + And the result for "exists.txt" failed with code "C217" + And the result for "fresh.txt" succeeded + And the file "exists.txt" on disk contains "old" + + Scenario: overwrite replaces existing content + Given a file at "exists.txt" with content: + """ + original + """ + When I call coder::create-file with payload: + """ + {"files": [ + {"path": "exists.txt", "content": "replaced", "mode": "0644", "parents": true, "overwrite": true} + ]} + """ + Then the result for "exists.txt" succeeded + And the file "exists.txt" on disk contains "replaced" + + Scenario: creating a non-accessible file fails with C211 + When I call coder::create-file with payload: + """ + {"files": [ + {"path": ".env", "content": "API_KEY=secret", "mode": "0644", "parents": true, "overwrite": true} + ]} + """ + Then the call succeeded + And the result for ".env" failed with code "C211" + And the file ".env" does not exist on disk + + Scenario: creating a non-accessible file under a subdirectory also fails with C211 + When I call coder::create-file with payload: + """ + {"files": [ + {"path": "secrets/key.pem", "content": "x", "mode": "0644", "parents": true, "overwrite": false} + ]} + """ + Then the result for "secrets/key.pem" failed with code "C211" + + Scenario: an empty files array is a top-level C210 + When I call coder::create-file with payload: + """ + {"files": []} + """ + Then the call failed with code "C210" diff --git a/coder/tests/features/delete_file.feature b/coder/tests/features/delete_file.feature new file mode 100644 index 000000000..3cd3227db --- /dev/null +++ b/coder/tests/features/delete_file.feature @@ -0,0 +1,99 @@ +@engine @delete +Feature: coder::delete-file + Per-path results in `results[]`. Missing paths are idempotent + successes with `removed: false`. Directories need `recursive: true`; + recursive deletes refuse to descend through non-accessible entries + and the worker refuses to delete `base_path` itself. + + Background: + Given the iii engine is reachable + + Scenario: delete an existing file + Given a file at "scratch.txt" with content: + """ + bye + """ + When I call coder::delete-file with payload: + """ + {"paths": ["scratch.txt"]} + """ + Then the result for "scratch.txt" succeeded + And the result for "scratch.txt" was removed + And the file "scratch.txt" does not exist on disk + + Scenario: deleting a missing path is an idempotent success + When I call coder::delete-file with payload: + """ + {"paths": ["never_existed.txt"]} + """ + Then the result for "never_existed.txt" succeeded + And the result for "never_existed.txt" was not removed + + Scenario: deleting a non-empty directory without recursive fails with C216 + Given a file at "subdir/inner.txt" with content: + """ + x + """ + When I call coder::delete-file with payload: + """ + {"paths": ["subdir"], "recursive": false} + """ + Then the result for "subdir" failed with code "C216" + + Scenario: recursive directory delete removes the whole tree + Given a file at "tree/a.txt" with content: + """ + a + """ + And a file at "tree/inner/b.txt" with content: + """ + b + """ + When I call coder::delete-file with payload: + """ + {"paths": ["tree"], "recursive": true} + """ + Then the result for "tree" succeeded + And the result for "tree" was removed + And the file "tree/a.txt" does not exist on disk + And the file "tree/inner/b.txt" does not exist on disk + + Scenario: recursive delete blocked by a non-accessible child fails with C211 + Given a file at "mixed/ok.txt" with content: + """ + ok + """ + And a file at "mixed/.env" with content: + """ + secret + """ + When I call coder::delete-file with payload: + """ + {"paths": ["mixed"], "recursive": true} + """ + Then the result for "mixed" failed with code "C211" + + Scenario: deleting a non-accessible file directly fails with C211 + Given a file at ".env" with content: + """ + secret + """ + When I call coder::delete-file with payload: + """ + {"paths": [".env"]} + """ + Then the result for ".env" failed with code "C211" + + Scenario: deleting base_path itself fails with C210 + When I call coder::delete-file with payload: + """ + {"paths": ["."]} + """ + Then the result for "." failed with code "C210" + + Scenario: an empty paths array is a top-level C210 + When I call coder::delete-file with payload: + """ + {"paths": []} + """ + Then the call failed with code "C210" diff --git a/coder/tests/features/lifecycle.feature b/coder/tests/features/lifecycle.feature new file mode 100644 index 000000000..f741f8ac7 --- /dev/null +++ b/coder/tests/features/lifecycle.feature @@ -0,0 +1,95 @@ +@engine @lifecycle +Feature: end-to-end coder lifecycle + Decomposes the existing `tests/integration.rs` subprocess scenario + into readable Gherkin: create -> read -> update -> read -> list -> + tree -> search -> non-accessible blocked -> delete. Uses the + in-process registration so the engine drives the same handlers a + production binary would. + + Background: + Given the iii engine is reachable + + Scenario: full create-update-search-delete journey for one file + When I call coder::create-file with payload: + """ + {"files": [ + {"path": "hello.txt", "content": "hello world\nsecond line\n", "mode": "0644", "parents": true, "overwrite": false} + ]} + """ + Then the result for "hello.txt" succeeded + And the result for "hello.txt" wrote 24 bytes + + When I call coder::read-file with payload: + """ + {"path": "hello.txt"} + """ + Then the read size equals 24 + And the read content equals: + """ + hello world + second line + """ + + When I call coder::update-file with payload: + """ + {"files": [ + {"path": "hello.txt", "ops": [ + {"op": "update_lines", "from_line": 2, "to_line": 2, "content": "REPLACED"} + ]} + ]} + """ + Then the result for "hello.txt" succeeded + + When I call coder::read-file with payload: + """ + {"path": "hello.txt"} + """ + Then the read content equals: + """ + hello world + REPLACED + """ + + When I call coder::list-folder with payload: + """ + {"path": "."} + """ + Then the listing has an entry named "hello.txt" + And the listing entry "hello.txt" has kind "file" + + When I call coder::tree with payload: + """ + {"path": "."} + """ + Then the tree has a node at "hello.txt" + And the tree node at "hello.txt" has kind "file" + + When I call coder::search with payload: + """ + {"query": "REPLACED"} + """ + Then the search has a content match for "hello.txt" at line 2 + + Given a file at ".env" with content: + """ + API_KEY=secret + """ + When I call coder::list-folder with payload: + """ + {"path": "."} + """ + Then the listing has an entry named ".env" + And the listing entry ".env" is non_accessible + When I call coder::read-file with payload: + """ + {"path": ".env"} + """ + Then the call failed with code "C211" + + When I call coder::delete-file with payload: + """ + {"paths": ["hello.txt"]} + """ + Then the result for "hello.txt" succeeded + And the result for "hello.txt" was removed + And the file "hello.txt" does not exist on disk diff --git a/coder/tests/features/list_folder.feature b/coder/tests/features/list_folder.feature new file mode 100644 index 000000000..64374c12b --- /dev/null +++ b/coder/tests/features/list_folder.feature @@ -0,0 +1,137 @@ +@engine @list +Feature: coder::list-folder + Paginated single-folder listing sorted by name. Non-accessible + entries are still reported with `non_accessible: true` so callers + can see they exist even though they cannot be read or written. + Test config sets `list_default_page_size` to 5 and + `list_max_page_size` to 100. + + Background: + Given the iii engine is reachable + + Scenario: list a flat folder sorted by name + Given a file at "alpha.txt" with content: + """ + a + """ + And a file at "bravo.txt" with content: + """ + b + """ + And a directory at "charlie" + When I call coder::list-folder with payload: + """ + {"path": "."} + """ + Then the call succeeded + And the listing has an entry named "alpha.txt" + And the listing entry "alpha.txt" has kind "file" + And the listing has an entry named "bravo.txt" + And the listing entry "bravo.txt" has kind "file" + And the listing has an entry named "charlie" + And the listing entry "charlie" has kind "dir" + And the listing total equals 3 + And the listing has_more is false + + Scenario: pagination splits entries across pages with has_more + Given a file at "f1.txt" with content: + """ + 1 + """ + And a file at "f2.txt" with content: + """ + 2 + """ + And a file at "f3.txt" with content: + """ + 3 + """ + And a file at "f4.txt" with content: + """ + 4 + """ + And a file at "f5.txt" with content: + """ + 5 + """ + And a file at "f6.txt" with content: + """ + 6 + """ + And a file at "f7.txt" with content: + """ + 7 + """ + When I call coder::list-folder with payload: + """ + {"path": ".", "page": 1} + """ + Then the listing total equals 7 + And the listing has 5 entries + And the listing page equals 1 + And the listing page_size equals 5 + And the listing has_more is true + When I call coder::list-folder with payload: + """ + {"path": ".", "page": 2} + """ + Then the listing total equals 7 + And the listing has 2 entries + And the listing page equals 2 + And the listing has_more is false + + Scenario: explicit page_size overrides the default + Given a file at "a.txt" with content: + """ + x + """ + And a file at "b.txt" with content: + """ + x + """ + And a file at "c.txt" with content: + """ + x + """ + When I call coder::list-folder with payload: + """ + {"path": ".", "page": 1, "page_size": 2} + """ + Then the listing has 2 entries + And the listing page_size equals 2 + And the listing has_more is true + + Scenario: page_size is capped at list_max_page_size + Given a file at "only.txt" with content: + """ + x + """ + When I call coder::list-folder with payload: + """ + {"path": ".", "page": 1, "page_size": 100000} + """ + Then the listing page_size equals 100 + + Scenario: non-accessible entries are listed with the flag + Given a file at ".env" with content: + """ + secret + """ + And a file at "public.txt" with content: + """ + ok + """ + When I call coder::list-folder with payload: + """ + {"path": "."} + """ + Then the listing has an entry named ".env" + And the listing entry ".env" is non_accessible + And the listing entry "public.txt" is accessible + + Scenario: listing a missing folder fails with C211 + When I call coder::list-folder with payload: + """ + {"path": "no_such_dir"} + """ + Then the call failed with code "C211" diff --git a/coder/tests/features/path_security.feature b/coder/tests/features/path_security.feature new file mode 100644 index 000000000..38e2e6f56 --- /dev/null +++ b/coder/tests/features/path_security.feature @@ -0,0 +1,51 @@ +@engine @security +Feature: path-jail invariants + All wire paths are interpreted as relative to `base_path`. Absolute + paths are bad input (`C210`); `..` escapes and crafted symlinks + cannot leave the jail (`C215`); non-accessible globs block reads + even though the entry is still listed (`C211`). + + Background: + Given the iii engine is reachable + + Scenario: a dot-dot escape on read fails with C215 + When I call coder::read-file with payload: + """ + {"path": "../../etc/passwd"} + """ + Then the call failed with code "C215" + + Scenario: a dot-dot escape on create is reported per item as C215 + When I call coder::create-file with payload: + """ + {"files": [ + {"path": "../escape.txt", "content": "x", "mode": "0644", "parents": false, "overwrite": false} + ]} + """ + Then the call succeeded + And the result for "../escape.txt" failed with code "C215" + + Scenario: an absolute path is rejected as C210 on read + When I call coder::read-file with payload: + """ + {"path": "/etc/passwd"} + """ + Then the call failed with code "C210" + + Scenario: an absolute path is rejected per item on create + When I call coder::create-file with payload: + """ + {"files": [ + {"path": "/tmp/abs.txt", "content": "x", "mode": "0644", "parents": false, "overwrite": false} + ]} + """ + Then the result for "/tmp/abs.txt" failed with code "C210" + + @unix + Scenario: a symlink whose target escapes base_path is rejected with C215 + Given a symlink at "escape_link" pointing to a path outside base + When I call coder::read-file with payload: + """ + {"path": "escape_link"} + """ + Then the call failed with code "C215" diff --git a/coder/tests/features/read_file.feature b/coder/tests/features/read_file.feature new file mode 100644 index 000000000..00332f43d --- /dev/null +++ b/coder/tests/features/read_file.feature @@ -0,0 +1,66 @@ +@engine @read +Feature: coder::read-file + Returns the file contents plus `size`, `mode`, `mtime`, and an + `is_utf8` flag. The path is echoed back for caller correlation. + Capped by `max_read_bytes`; non-accessible paths return `C211`. + + Background: + Given the iii engine is reachable + + Scenario: read a UTF-8 text file + Given a file at "hello.txt" with content: + """ + hello world + """ + When I call coder::read-file with payload: + """ + {"path": "hello.txt"} + """ + Then the call succeeded + And the read path equals "hello.txt" + And the read content equals: + """ + hello world + """ + And the read size equals 12 + And the read is_utf8 is true + + Scenario: reading a missing file fails with C211 + When I call coder::read-file with payload: + """ + {"path": "nope.txt"} + """ + Then the call failed with code "C211" + + Scenario: reading a directory fails with C210 + Given a directory at "subdir" + When I call coder::read-file with payload: + """ + {"path": "subdir"} + """ + Then the call failed with code "C210" + + Scenario: reading a non-accessible file fails with C211 even though list-folder shows it + Given a file at ".env" with content: + """ + API_KEY=secret + """ + When I call coder::list-folder with payload: + """ + {"path": "."} + """ + Then the listing has an entry named ".env" + And the listing entry ".env" is non_accessible + When I call coder::read-file with payload: + """ + {"path": ".env"} + """ + Then the call failed with code "C211" + + Scenario: reading an oversize file fails with C213 + Given a file at "big.bin" with 5000 bytes of content + When I call coder::read-file with payload: + """ + {"path": "big.bin"} + """ + Then the call failed with code "C213" diff --git a/coder/tests/features/search.feature b/coder/tests/features/search.feature new file mode 100644 index 000000000..2083241b9 --- /dev/null +++ b/coder/tests/features/search.feature @@ -0,0 +1,169 @@ +@engine @search +Feature: coder::search + Combined path + content search under `base_path`. Supports literal + and regex queries with include / exclude globs, returns content and + path matches in separate arrays, and refuses to read non-accessible + files. Binary files (NUL byte heuristic) and oversize files are + skipped silently. + + Background: + Given the iii engine is reachable + + Scenario: literal content match reports path, line, and column + Given a file at "notes.md" with content: + """ + hello world + goodbye world + """ + When I call coder::search with payload: + """ + {"query": "goodbye"} + """ + Then the call succeeded + And the search has a content match for "notes.md" at line 2 + And the search truncated is false + + Scenario: regex content match + Given a file at "phones.txt" with content: + """ + call 555-1234 or 555-9876 + """ + When I call coder::search with payload: + """ + {"query": "\\d{3}-\\d{4}", "regex": true} + """ + Then the search has a content match for "phones.txt" + + Scenario: ignore_case literal matches mixed-case occurrences + Given a file at "doc.md" with content: + """ + Hello world + """ + When I call coder::search with payload: + """ + {"query": "hello", "ignore_case": true} + """ + Then the search has a content match for "doc.md" + + Scenario: search by path only (search_content false) + Given a file at "notes.md" with content: + """ + irrelevant body + """ + When I call coder::search with payload: + """ + {"query": "notes", "search_content": false, "search_paths": true} + """ + Then the search has a path match for "notes.md" + And the search has no content matches + + Scenario: include_globs limits which files are scanned + Given a file at "src/main.rs" with content: + """ + fn main() {} + """ + And a file at "src/lib.md" with content: + """ + fn main() {} + """ + When I call coder::search with payload: + """ + {"query": "main", "include_globs": ["**/*.rs"], "search_paths": false} + """ + Then the search has a content match for "src/main.rs" + And the search has no content match for "src/lib.md" + + Scenario: exclude_globs removes matching files from results + Given a file at "keep.txt" with content: + """ + banana + """ + And a file at "skip.txt" with content: + """ + banana + """ + When I call coder::search with payload: + """ + {"query": "banana", "exclude_globs": ["skip.txt"]} + """ + Then the search has a content match for "keep.txt" + And the search has no content match for "skip.txt" + + Scenario: path scopes the search to a subdirectory + Given a file at "a/hit.txt" with content: + """ + banana + """ + And a file at "b/hit.txt" with content: + """ + banana + """ + When I call coder::search with payload: + """ + {"query": "banana", "path": "a"} + """ + Then the search has a content match for "a/hit.txt" + And the search has no content match for "b/hit.txt" + + Scenario: empty query is rejected with C210 + When I call coder::search with payload: + """ + {"query": ""} + """ + Then the call failed with code "C210" + + Scenario: non-accessible files are excluded from content and path results + Given a file at ".env" with content: + """ + banana + """ + And a file at "ok.txt" with content: + """ + banana + """ + When I call coder::search with payload: + """ + {"query": "banana"} + """ + Then the search has a content match for "ok.txt" + And the search has no content match for ".env" + And the search has no path match for ".env" + + Scenario: max_matches truncates the result set + Given a file at "a.txt" with content: + """ + hit + """ + And a file at "b.txt" with content: + """ + hit + """ + And a file at "c.txt" with content: + """ + hit + """ + When I call coder::search with payload: + """ + {"query": "hit", "max_matches": 2} + """ + Then the search truncated is true + + Scenario: binary files are skipped silently + Given a binary file at "blob.bin" containing "needle" + And a file at "readme.txt" with content: + """ + needle here + """ + When I call coder::search with payload: + """ + {"query": "needle", "search_paths": false} + """ + Then the search has a content match for "readme.txt" + And the search has no content match for "blob.bin" + + Scenario: search_content and search_paths both false is rejected with C210 + When I call coder::search with payload: + """ + {"query": "x", "search_content": false, "search_paths": false} + """ + Then the call failed with code "C210" diff --git a/coder/tests/features/tree.feature b/coder/tests/features/tree.feature new file mode 100644 index 000000000..ddf73d450 --- /dev/null +++ b/coder/tests/features/tree.feature @@ -0,0 +1,101 @@ +@engine @tree +Feature: coder::tree + Recursive directory snapshot bounded by `max_depth` and a + `per_folder_limit`. Folders that hit the limit are tagged with a + `truncated` block; deep subtrees cut off by `max_depth` carry the + same metadata so callers can switch to `coder::list-folder` for + pagination. + + Background: + Given the iii engine is reachable + + Scenario: nested directory snapshot returns a child hierarchy + Given a file at "top.txt" with content: + """ + t + """ + And a file at "sub/inner.txt" with content: + """ + i + """ + When I call coder::tree with payload: + """ + {"path": "."} + """ + Then the call succeeded + And the tree has a node at "top.txt" + And the tree node at "top.txt" has kind "file" + And the tree has a node at "sub" + And the tree node at "sub" has kind "dir" + And the tree has a node at "sub/inner.txt" + And the tree node at "sub/inner.txt" has kind "file" + + Scenario: max_depth truncates the subtree with a list-folder hint + Given a file at "a/b/c/d.txt" with content: + """ + deep + """ + When I call coder::tree with payload: + """ + {"path": ".", "max_depth": 2} + """ + Then the tree has a node at "a/b" + And the tree node at "a/b" is truncated with reason "max_depth" + And the tree node at "a/b" hint mentions "max_depth" + And the tree has no node at "a/b/c" + + Scenario: per_folder_limit truncates wide folders + Given a file at "one.txt" with content: + """ + 1 + """ + And a file at "two.txt" with content: + """ + 2 + """ + And a file at "three.txt" with content: + """ + 3 + """ + When I call coder::tree with payload: + """ + {"path": ".", "per_folder_limit": 2} + """ + Then the tree node at "." has 2 children + And the tree node at "." is truncated with reason "per_folder_limit" + And the tree node at "." hint mentions "list-folder" + + Scenario: non-accessible entries appear with the flag + Given a file at ".env" with content: + """ + secret + """ + When I call coder::tree with payload: + """ + {"path": "."} + """ + Then the tree has a node at ".env" + And the tree node at ".env" is non_accessible + + Scenario: rooting the tree at a subpath returns only that subtree + Given a file at "outside.txt" with content: + """ + o + """ + And a file at "sub/inside.txt" with content: + """ + i + """ + When I call coder::tree with payload: + """ + {"path": "sub"} + """ + Then the tree has a node at "inside.txt" + And the tree has no node at "outside.txt" + + Scenario: tree on a missing folder fails with C211 + When I call coder::tree with payload: + """ + {"path": "missing"} + """ + Then the call failed with code "C211" diff --git a/coder/tests/features/update_file.feature b/coder/tests/features/update_file.feature new file mode 100644 index 000000000..b3e5baf87 --- /dev/null +++ b/coder/tests/features/update_file.feature @@ -0,0 +1,251 @@ +@engine @update +Feature: coder::update-file + Batched line-oriented and regex edits (insert / remove / update_lines / + replace) applied bottom-up for line ops so earlier ops still reference + the original line numbers. Regex replace runs after line ops. Each file + commits atomically; overlapping line ops in one file reject with `C210` + and leave the file unchanged. + + Background: + Given the iii engine is reachable + + Scenario: insert a line at a given position + Given a file at "doc.txt" with content: + """ + one + two + """ + When I call coder::update-file with payload: + """ + {"files": [ + {"path": "doc.txt", "ops": [ + {"op": "insert", "at_line": 2, "content": "inserted"} + ]} + ]} + """ + Then the result for "doc.txt" succeeded + And the result for "doc.txt" applied 1 ops + And the result for "doc.txt" has line count 3 + + Scenario: remove a single line + Given a file at "doc.txt" with content: + """ + one + two + three + """ + When I call coder::update-file with payload: + """ + {"files": [ + {"path": "doc.txt", "ops": [ + {"op": "remove", "from_line": 2, "to_line": 2} + ]} + ]} + """ + Then the result for "doc.txt" succeeded + And the result for "doc.txt" has line count 2 + + Scenario: update_lines a single line and read it back + Given a file at "doc.txt" with content: + """ + one + two + three + """ + When I call coder::update-file with payload: + """ + {"files": [ + {"path": "doc.txt", "ops": [ + {"op": "update_lines", "from_line": 2, "to_line": 2, "content": "REPLACED"} + ]} + ]} + """ + Then the result for "doc.txt" succeeded + When I call coder::read-file with payload: + """ + {"path": "doc.txt"} + """ + Then the read content equals: + """ + one + REPLACED + three + """ + + Scenario: multiple ops applied bottom-up keep earlier line numbers stable + Given a file at "doc.txt" with content: + """ + A + B + C + D + """ + When I call coder::update-file with payload: + """ + {"files": [ + {"path": "doc.txt", "ops": [ + {"op": "remove", "from_line": 4, "to_line": 4}, + {"op": "update_lines", "from_line": 2, "to_line": 2, "content": "BB"} + ]} + ]} + """ + Then the result for "doc.txt" succeeded + And the result for "doc.txt" applied 2 ops + When I call coder::read-file with payload: + """ + {"path": "doc.txt"} + """ + Then the read content equals: + """ + A + BB + C + """ + + Scenario: overlapping ops are rejected per file with C210 + Given a file at "doc.txt" with content: + """ + A + B + C + """ + When I call coder::update-file with payload: + """ + {"files": [ + {"path": "doc.txt", "ops": [ + {"op": "remove", "from_line": 1, "to_line": 2}, + {"op": "update_lines", "from_line": 2, "to_line": 3, "content": "X"} + ]} + ]} + """ + Then the result for "doc.txt" failed with code "C210" + + Scenario: batch partial success - one file ok, one missing + Given a file at "ok.txt" with content: + """ + hello + """ + When I call coder::update-file with payload: + """ + {"files": [ + {"path": "ok.txt", "ops": [ + {"op": "update_lines", "from_line": 1, "to_line": 1, "content": "HI"} + ]}, + {"path": "missing.txt", "ops": [ + {"op": "insert", "at_line": 1, "content": "x"} + ]} + ]} + """ + Then the result for "ok.txt" succeeded + And the result for "missing.txt" failed with code "C211" + + Scenario: updating a non-accessible file fails with C211 + Given a file at ".env" with content: + """ + KEY=v + """ + When I call coder::update-file with payload: + """ + {"files": [ + {"path": ".env", "ops": [ + {"op": "update_lines", "from_line": 1, "to_line": 1, "content": "OTHER=z"} + ]} + ]} + """ + Then the result for ".env" failed with code "C211" + + Scenario: an empty files array is a top-level C210 + When I call coder::update-file with payload: + """ + {"files": []} + """ + Then the call failed with code "C210" + + Scenario: regex replace all occurrences + Given a file at "doc.txt" with content: + """ + foo bar foo + """ + When I call coder::update-file with payload: + """ + {"files": [ + {"path": "doc.txt", "ops": [ + {"op": "replace", "pattern": "foo", "replacement": "baz"} + ]} + ]} + """ + Then the result for "doc.txt" succeeded + When I call coder::read-file with payload: + """ + {"path": "doc.txt"} + """ + Then the read content equals: + """ + baz bar baz + """ + + Scenario: regex replace with capture groups + Given a file at "doc.txt" with content: + """ + HOST=8080 + """ + When I call coder::update-file with payload: + """ + {"files": [ + {"path": "doc.txt", "ops": [ + {"op": "replace", "pattern": "(\\w+)=(\\d+)", "replacement": "$1: $2"} + ]} + ]} + """ + Then the result for "doc.txt" succeeded + When I call coder::read-file with payload: + """ + {"path": "doc.txt"} + """ + Then the read content equals: + """ + HOST: 8080 + """ + + Scenario: invalid regex is rejected with C210 + Given a file at "doc.txt" with content: + """ + hello + """ + When I call coder::update-file with payload: + """ + {"files": [ + {"path": "doc.txt", "ops": [ + {"op": "replace", "pattern": "[unclosed", "replacement": "x"} + ]} + ]} + """ + Then the result for "doc.txt" failed with code "C210" + + Scenario: mixed update_lines then regex replace + Given a file at "doc.txt" with content: + """ + OLD + keep + OLD + """ + When I call coder::update-file with payload: + """ + {"files": [ + {"path": "doc.txt", "ops": [ + {"op": "remove", "from_line": 2, "to_line": 2}, + {"op": "replace", "pattern": "OLD", "replacement": "NEW"} + ]} + ]} + """ + Then the result for "doc.txt" succeeded + And the result for "doc.txt" applied 2 ops + When I call coder::read-file with payload: + """ + {"path": "doc.txt"} + """ + Then the read content equals: + """ + NEW + NEW + """ diff --git a/coder/tests/integration.rs b/coder/tests/integration.rs new file mode 100644 index 000000000..8c9b87a50 --- /dev/null +++ b/coder/tests/integration.rs @@ -0,0 +1,283 @@ +//! End-to-end test (binary-worker.md §9 pattern A): spawn the `iii` engine +//! and the `coder` worker as subprocesses, drive the worker through +//! `iii-sdk` as a client, then tear both down. **Self-skips** when `iii` +//! is not on `PATH` so CI hosts without the engine still pass. +//! +//! A single test runs both scenarios sequentially because the engine +//! binds a fixed loopback port and parallel test cases would race for it. + +use std::process::{Child, Command, Stdio}; +use std::time::Duration; + +use iii_sdk::{register_worker, InitOptions, TriggerRequest}; +use serde_json::json; +use tempfile::TempDir; +use tokio::time::{sleep, timeout}; + +const ENGINE_WS: &str = "ws://127.0.0.1:49134"; + +struct Harness { + iii: Child, + worker: Child, + /// Kept alive so the temp base persists for the worker's lifetime. + base: TempDir, +} + +impl Drop for Harness { + fn drop(&mut self) { + let _ = self.worker.kill(); + let _ = self.worker.wait(); + let _ = self.iii.kill(); + let _ = self.iii.wait(); + } +} + +async fn boot() -> Option { + let iii_bin = which::which("iii").ok()?; + + let mut iii = Command::new(&iii_bin) + .arg("--use-default-config") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + + sleep(Duration::from_millis(800)).await; + + let base = tempfile::tempdir().ok()?; + let cfg_path = base.path().join("coder-config.yaml"); + let yaml = format!( + "base_path: {}\nnon_accessible_globs:\n - \"**/.env\"\n", + base.path().display() + ); + std::fs::write(&cfg_path, yaml).ok()?; + + let worker_bin = env!("CARGO_BIN_EXE_coder"); + let worker = match Command::new(worker_bin) + .args([ + "--url", + ENGINE_WS, + "--config", + cfg_path.to_str().expect("utf-8 cfg path"), + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + Ok(w) => w, + Err(_) => { + let _ = iii.kill(); + let _ = iii.wait(); + return None; + } + }; + + Some(Harness { iii, worker, base }) +} + +/// Poll `function_id` with `payload` until the worker has registered, or +/// give up. Returns the response (or panics with the last error). +async fn call_when_ready( + client: &iii_sdk::III, + function_id: &str, + payload: serde_json::Value, +) -> serde_json::Value { + let deadline = std::time::Instant::now() + Duration::from_secs(30); + let mut last_err: Option = None; + while std::time::Instant::now() < deadline { + match timeout( + Duration::from_secs(5), + client.trigger(TriggerRequest { + function_id: function_id.into(), + payload: payload.clone(), + action: None, + timeout_ms: Some(4_000), + }), + ) + .await + { + Ok(Ok(v)) => return v, + Ok(Err(e)) => { + let msg = format!("{e:?}"); + if !msg.contains("function_not_found") && !msg.contains("not found") { + panic!("{function_id} failed: {msg}"); + } + last_err = Some(msg); + } + Err(_) => last_err = Some("trigger timed out".into()), + } + sleep(Duration::from_millis(500)).await; + } + panic!( + "worker never registered {function_id} within 30s; last error: {}", + last_err.unwrap_or_else(|| "".into()) + ); +} + +#[tokio::test] +async fn full_lifecycle_via_iii_sdk() { + let Some(h) = boot().await else { + eprintln!("skipping: `iii` not on PATH"); + return; + }; + + let client = register_worker(ENGINE_WS, InitOptions::default()); + sleep(Duration::from_millis(500)).await; + + // 1. Create a file via the worker. + let create = call_when_ready( + &client, + "coder::create-file", + json!({ + "files": [{ + "path": "hello.txt", + "content": "hello world\nsecond line\n", + "mode": "0644", + "parents": true, + "overwrite": false + }] + }), + ) + .await; + let create_results = create["results"].as_array().expect("create results array"); + assert_eq!(create_results.len(), 1); + assert_eq!( + create_results[0]["success"], true, + "create failed: {:?}", + create_results[0]["error"] + ); + + // 2. Read it back. + let read = client + .trigger(TriggerRequest { + function_id: "coder::read-file".into(), + payload: json!({ "path": "hello.txt" }), + action: None, + timeout_ms: Some(5_000), + }) + .await + .expect("read-file"); + assert_eq!(read["content"], "hello world\nsecond line\n"); + assert_eq!(read["size"], 24); + + // 3. Update — replace line 2. + let updated = client + .trigger(TriggerRequest { + function_id: "coder::update-file".into(), + payload: json!({ + "files": [{ + "path": "hello.txt", + "ops": [ + { "op": "update_lines", "from_line": 2, "to_line": 2, "content": "REPLACED" } + ] + }] + }), + action: None, + timeout_ms: Some(5_000), + }) + .await + .expect("update-file"); + assert_eq!(updated["results"][0]["success"], true); + let after = client + .trigger(TriggerRequest { + function_id: "coder::read-file".into(), + payload: json!({ "path": "hello.txt" }), + action: None, + timeout_ms: Some(5_000), + }) + .await + .expect("read after update"); + assert_eq!(after["content"], "hello world\nREPLACED\n"); + + // 4. list-folder shows the file. + let list = client + .trigger(TriggerRequest { + function_id: "coder::list-folder".into(), + payload: json!({ "path": ".", "page": 1 }), + action: None, + timeout_ms: Some(5_000), + }) + .await + .expect("list-folder"); + let entries = list["entries"].as_array().expect("entries array"); + assert!(entries.iter().any(|e| e["name"] == "hello.txt")); + + // 5. tree returns the file as a child of root. + let tree = client + .trigger(TriggerRequest { + function_id: "coder::tree".into(), + payload: json!({ "path": "." }), + action: None, + timeout_ms: Some(5_000), + }) + .await + .expect("tree"); + let root = &tree["root"]; + let kids = root["children"].as_array().expect("root children"); + assert!(kids.iter().any(|k| k["name"] == "hello.txt")); + + // 6. search finds the content. + let search = client + .trigger(TriggerRequest { + function_id: "coder::search".into(), + payload: json!({ "query": "REPLACED", "regex": false }), + action: None, + timeout_ms: Some(5_000), + }) + .await + .expect("search"); + let content_matches = search["content_matches"] + .as_array() + .expect("content matches"); + assert!(content_matches + .iter() + .any(|m| m["path"] == "hello.txt" && m["line"] == 2)); + + // 7. Non-accessible glob blocks reads even though list-folder shows it. + std::fs::write(h.base.path().join(".env"), "API_KEY=secret\n").unwrap(); + let list_with_env = client + .trigger(TriggerRequest { + function_id: "coder::list-folder".into(), + payload: json!({ "path": ".", "page": 1 }), + action: None, + timeout_ms: Some(5_000), + }) + .await + .expect("list-folder after .env"); + let env_entry = list_with_env["entries"] + .as_array() + .unwrap() + .iter() + .find(|e| e["name"] == ".env") + .expect(".env should be listed"); + assert_eq!(env_entry["non_accessible"], true); + + let read_env = client + .trigger(TriggerRequest { + function_id: "coder::read-file".into(), + payload: json!({ "path": ".env" }), + action: None, + timeout_ms: Some(5_000), + }) + .await; + let err_msg = match read_env { + Ok(_) => panic!("read of .env must be rejected"), + Err(e) => format!("{e:?}"), + }; + assert!(err_msg.contains("C211"), "got: {err_msg}"); + + // 8. delete-file removes the file. + let del = client + .trigger(TriggerRequest { + function_id: "coder::delete-file".into(), + payload: json!({ "paths": ["hello.txt"] }), + action: None, + timeout_ms: Some(5_000), + }) + .await + .expect("delete-file"); + assert_eq!(del["results"][0]["success"], true); + assert_eq!(del["results"][0]["removed"], true); + + client.shutdown_async().await; +} diff --git a/coder/tests/manifest.rs b/coder/tests/manifest.rs new file mode 100644 index 000000000..a2acd180e --- /dev/null +++ b/coder/tests/manifest.rs @@ -0,0 +1,46 @@ +//! Validates the `--manifest` subcommand the registry publish pipeline +//! relies on (binary-worker.md §11 / pattern A). + +use std::process::Command; + +use serde_json::Value; + +#[test] +fn manifest_subcommand_emits_valid_json() { + let bin = env!("CARGO_BIN_EXE_coder"); + let output = Command::new(bin) + .arg("--manifest") + .output() + .expect("spawn coder --manifest"); + + assert!( + output.status.success(), + "binary exited with {:?}; stderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr), + ); + + let stdout = String::from_utf8(output.stdout).expect("manifest stdout is utf-8"); + let manifest: Value = serde_json::from_str(&stdout).expect("manifest stdout is valid JSON"); + + assert_eq!(manifest["name"], env!("CARGO_PKG_NAME")); + assert_eq!(manifest["version"], env!("CARGO_PKG_VERSION")); + assert!( + manifest["description"] + .as_str() + .map(|s| !s.is_empty()) + .unwrap_or(false), + "description must be a non-empty string" + ); + assert!( + manifest["default_config"].is_object(), + "default_config must be an object" + ); + assert!( + !manifest["supported_targets"] + .as_array() + .expect("supported_targets must be an array") + .is_empty(), + "supported_targets must not be empty" + ); +} diff --git a/coder/tests/path_jail.rs b/coder/tests/path_jail.rs new file mode 100644 index 000000000..8e0b3d52c --- /dev/null +++ b/coder/tests/path_jail.rs @@ -0,0 +1,99 @@ +//! Integration coverage for `PathResolver` security properties. The +//! per-module unit tests already exercise most branches; these scenarios +//! re-verify the cross-cutting invariants from outside the module: +//! +//! - `..` escapes and absolute paths must never resolve outside `base_root`. +//! - Symlinks to outside the base must be rejected. +//! - Non-accessible globs must block reading too (not just writing). + +use std::path::PathBuf; +use std::sync::Arc; + +use coder::config::CoderConfig; +use coder::functions::read_file::{handle as read_handle, ReadFileInput}; +use coder::path::PathResolver; +use tempfile::tempdir; + +fn make_resolver(base: PathBuf, globs: Vec<&str>) -> (Arc, Arc) { + let cfg = Arc::new(CoderConfig { + base_path: base, + non_accessible_globs: globs.into_iter().map(String::from).collect(), + ..CoderConfig::default() + }); + let resolver = Arc::new(PathResolver::new(&cfg).expect("PathResolver init")); + (resolver, cfg) +} + +#[test] +fn dotdot_in_path_cannot_escape_base_root() { + let tmp = tempdir().unwrap(); + let (r, _) = make_resolver(tmp.path().to_path_buf(), vec![]); + let err = r.resolve("../../../etc/passwd").unwrap_err(); + assert_eq!(err.code(), "C215"); +} + +#[test] +fn absolute_path_input_rejected_with_c210() { + let tmp = tempdir().unwrap(); + let (r, _) = make_resolver(tmp.path().to_path_buf(), vec![]); + let err = r.resolve("/etc/passwd").unwrap_err(); + assert_eq!(err.code(), "C210"); +} + +#[test] +fn symlink_to_outside_base_rejected() { + let tmp = tempdir().unwrap(); + let outside = tempdir().unwrap(); + std::fs::write(outside.path().join("secret.txt"), b"x").unwrap(); + std::os::unix::fs::symlink(outside.path().join("secret.txt"), tmp.path().join("link")).unwrap(); + let (r, _) = make_resolver(tmp.path().to_path_buf(), vec![]); + let err = r.resolve("link").unwrap_err(); + assert_eq!(err.code(), "C215"); +} + +#[test] +fn symlink_inside_base_is_allowed() { + let tmp = tempdir().unwrap(); + std::fs::create_dir(tmp.path().join("real")).unwrap(); + std::fs::write(tmp.path().join("real/a.txt"), b"hi").unwrap(); + std::os::unix::fs::symlink(tmp.path().join("real/a.txt"), tmp.path().join("link")).unwrap(); + let (r, _) = make_resolver(tmp.path().to_path_buf(), vec![]); + let resolved = r + .resolve("link") + .expect("symlink to inside base must succeed"); + assert!(resolved.starts_with(r.base_root())); +} + +#[test] +fn non_accessible_glob_blocks_read() { + let tmp = tempdir().unwrap(); + std::fs::write(tmp.path().join(".env"), b"API_KEY=secret").unwrap(); + let (r, c) = make_resolver(tmp.path().to_path_buf(), vec!["**/.env"]); + let err = tokio_test_block_on(read_handle( + r, + c, + ReadFileInput { + path: ".env".into(), + }, + )) + .unwrap_err(); + assert!(err.contains("C211"), "got: {err}"); +} + +#[test] +fn nested_dotenv_blocked_by_glob() { + let tmp = tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join("a/b/c")).unwrap(); + std::fs::write(tmp.path().join("a/b/c/.env"), b"x").unwrap(); + let (r, _) = make_resolver(tmp.path().to_path_buf(), vec!["**/.env"]); + let abs = r.resolve("a/b/c/.env").unwrap(); + assert!(r.is_non_accessible(&abs)); +} + +fn tokio_test_block_on(fut: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(fut) +} diff --git a/coder/tests/steps/common.rs b/coder/tests/steps/common.rs new file mode 100644 index 000000000..8fa717e41 --- /dev/null +++ b/coder/tests/steps/common.rs @@ -0,0 +1,124 @@ +//! Cross-cutting steps shared by every feature file: +//! +//! - `Given the iii engine is reachable` — Background marker. +//! - Fixture Givens for files, directories, and binary content. +//! - Generic `When I call coder:: with payload:` dispatcher. +//! - Outcome assertions — top-level success / error code, per-item +//! batch result success / error code. +//! +//! Every `Then` step short-circuits when `world.iii.is_none()` so +//! `@engine` scenarios soft-skip on hosts without an iii engine. + +use cucumber::{given, then, when}; + +use crate::common::helpers::{ + batch_result, call_coder, docstring_body, last_err, last_ok, mkdir, parse_payload, + write_fixture, LAST_OK, +}; +use crate::common::world::CoderWorld; + +#[given("the iii engine is reachable")] +async fn engine_reachable(_world: &mut CoderWorld) {} + +#[given(regex = r#"^a file at "([^"]+)" with content:$"#)] +async fn given_file_with_content( + world: &mut CoderWorld, + rel: String, + step: &cucumber::gherkin::Step, +) { + let body = docstring_body(step); + write_fixture(world, &rel, &body); +} + +#[given(regex = r#"^a file at "([^"]+)" with (\d+) bytes of content$"#)] +async fn given_file_with_bytes(world: &mut CoderWorld, rel: String, size: usize) { + let body = "a".repeat(size); + write_fixture(world, &rel, &body); +} + +#[given(regex = r#"^a directory at "([^"]+)"$"#)] +async fn given_directory(world: &mut CoderWorld, rel: String) { + mkdir(world, &rel); +} + +#[given(regex = r#"^a binary file at "([^"]+)" containing "([^"]+)"$"#)] +async fn given_binary_file(world: &mut CoderWorld, rel: String, marker: String) { + // Single NUL splits the content; the search heuristic treats the + // whole file as binary and skips it. + let body = format!("{marker}\u{0000}{marker}"); + write_fixture(world, &rel, &body); +} + +#[when(regex = r#"^I call (coder::[a-z\-]+) with payload:$"#)] +async fn call_with_payload( + world: &mut CoderWorld, + function_id: String, + step: &cucumber::gherkin::Step, +) { + let payload = parse_payload(step); + call_coder(world, &function_id, payload).await; +} + +#[then("the call succeeded")] +fn call_succeeded(world: &mut CoderWorld) { + if world.iii.is_none() { + return; + } + let _ = last_ok(world); +} + +#[then(regex = r#"^the call failed with code "([^"]+)"$"#)] +fn call_failed_with_code(world: &mut CoderWorld, code: String) { + if world.iii.is_none() { + return; + } + let err = last_err(world); + assert!( + !err.is_empty(), + "expected an error containing code {code:?}; got success: {:?}", + world.stash.get(LAST_OK) + ); + assert!( + err.contains(&code), + "expected error to contain {code:?}; got: {err:?}" + ); +} + +#[then(regex = r#"^the result for "([^"]+)" succeeded$"#)] +fn result_succeeded(world: &mut CoderWorld, path: String) { + if world.iii.is_none() { + return; + } + let entry = batch_result(world, &path).unwrap_or_else(|| { + panic!( + "no result entry for path {path:?}; got: {:?}", + last_ok(world) + ) + }); + assert_eq!( + entry["success"], true, + "expected success for {path:?}; got: {entry:?}" + ); +} + +#[then(regex = r#"^the result for "([^"]+)" failed with code "([^"]+)"$"#)] +fn result_failed_with_code(world: &mut CoderWorld, path: String, code: String) { + if world.iii.is_none() { + return; + } + let entry = batch_result(world, &path).unwrap_or_else(|| { + panic!( + "no result entry for path {path:?}; got: {:?}", + last_ok(world) + ) + }); + assert_eq!( + entry["success"], false, + "expected failure for {path:?}; got: {entry:?}" + ); + let err = entry["error"].as_str().unwrap_or(""); + assert!( + err.contains(&code), + "expected error for {path:?} to contain {code:?}; got: {err:?}" + ); +} diff --git a/coder/tests/steps/create.rs b/coder/tests/steps/create.rs new file mode 100644 index 000000000..19cccea04 --- /dev/null +++ b/coder/tests/steps/create.rs @@ -0,0 +1,70 @@ +//! `coder::create-file`-specific assertions. Most create scenarios rely +//! on the generic `result for "" succeeded / failed with code "..."` +//! steps in [`common`](super::common); this module adds the response +//! fields that are unique to create-file (`bytes_written`) plus a +//! filesystem-side check that the file actually landed. + +use std::path::PathBuf; + +use cucumber::then; + +use crate::common::helpers::{batch_result, last_ok}; +use crate::common::world::CoderWorld; + +#[then(regex = r#"^the result for "([^"]+)" wrote (\d+) bytes$"#)] +fn create_wrote_bytes(world: &mut CoderWorld, path: String, expected: u64) { + if world.iii.is_none() { + return; + } + let entry = batch_result(world, &path).unwrap_or_else(|| { + panic!( + "no result entry for path {path:?}; got: {:?}", + last_ok(world) + ) + }); + let got = entry["bytes_written"].as_u64().unwrap_or_default(); + assert_eq!( + got, expected, + "expected bytes_written {expected} for {path:?}; got: {entry:?}" + ); +} + +#[then(regex = r#"^the file "([^"]+)" exists on disk$"#)] +fn file_exists_on_disk(world: &mut CoderWorld, rel: String) { + if world.iii.is_none() { + return; + } + let Some(base) = world.base_path.as_ref() else { + return; + }; + let path: PathBuf = base.join(&rel); + assert!(path.exists(), "expected {path:?} to exist on disk"); +} + +#[then(regex = r#"^the file "([^"]+)" does not exist on disk$"#)] +fn file_missing_on_disk(world: &mut CoderWorld, rel: String) { + if world.iii.is_none() { + return; + } + let Some(base) = world.base_path.as_ref() else { + return; + }; + let path: PathBuf = base.join(&rel); + assert!(!path.exists(), "expected {path:?} to be absent"); +} + +#[then(regex = r#"^the file "([^"]+)" on disk contains "([^"]+)"$"#)] +fn file_contains(world: &mut CoderWorld, rel: String, needle: String) { + if world.iii.is_none() { + return; + } + let Some(base) = world.base_path.as_ref() else { + return; + }; + let path = base.join(&rel); + let body = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path:?}: {e}")); + assert!( + body.contains(&needle), + "expected {path:?} to contain {needle:?}; got: {body:?}" + ); +} diff --git a/coder/tests/steps/delete.rs b/coder/tests/steps/delete.rs new file mode 100644 index 000000000..e37766754 --- /dev/null +++ b/coder/tests/steps/delete.rs @@ -0,0 +1,43 @@ +//! `coder::delete-file`-specific assertions. The `removed` flag +//! distinguishes idempotent "already absent" calls (`success: true`, +//! `removed: false`) from real deletions (`success: true`, `removed: +//! true`). + +use cucumber::then; + +use crate::common::helpers::{batch_result, last_ok}; +use crate::common::world::CoderWorld; + +#[then(regex = r#"^the result for "([^"]+)" was removed$"#)] +fn delete_was_removed(world: &mut CoderWorld, path: String) { + if world.iii.is_none() { + return; + } + let entry = batch_result(world, &path).unwrap_or_else(|| { + panic!( + "no result entry for path {path:?}; got: {:?}", + last_ok(world) + ) + }); + assert_eq!( + entry["removed"], true, + "expected removed: true for {path:?}; got: {entry:?}" + ); +} + +#[then(regex = r#"^the result for "([^"]+)" was not removed$"#)] +fn delete_was_not_removed(world: &mut CoderWorld, path: String) { + if world.iii.is_none() { + return; + } + let entry = batch_result(world, &path).unwrap_or_else(|| { + panic!( + "no result entry for path {path:?}; got: {:?}", + last_ok(world) + ) + }); + assert_eq!( + entry["removed"], false, + "expected removed: false for {path:?}; got: {entry:?}" + ); +} diff --git a/coder/tests/steps/lifecycle.rs b/coder/tests/steps/lifecycle.rs new file mode 100644 index 000000000..030f134f0 --- /dev/null +++ b/coder/tests/steps/lifecycle.rs @@ -0,0 +1,6 @@ +//! Placeholder for the end-to-end lifecycle scenario. The scenario in +//! [`tests/features/lifecycle.feature`](../../features/lifecycle.feature) +//! reuses the generic dispatcher and assertions from +//! [`common`](super::common), [`read`](super::read), +//! [`list`](super::list), [`tree`](super::tree), and +//! [`search`](super::search); no lifecycle-only steps are needed. diff --git a/coder/tests/steps/list.rs b/coder/tests/steps/list.rs new file mode 100644 index 000000000..374a8bfee --- /dev/null +++ b/coder/tests/steps/list.rs @@ -0,0 +1,138 @@ +//! `coder::list-folder`-specific assertions. The list response carries +//! its entries plus pagination metadata (`total`, `page`, `page_size`, +//! `has_more`) and a `non_accessible` flag per entry — each Then below +//! pokes one of those fields. + +use cucumber::then; +use serde_json::Value; + +use crate::common::helpers::last_ok; +use crate::common::world::CoderWorld; + +fn entries(v: &Value) -> &[Value] { + v["entries"].as_array().map(Vec::as_slice).unwrap_or(&[]) +} + +fn find_entry<'a>(v: &'a Value, name: &str) -> Option<&'a Value> { + entries(v).iter().find(|e| e["name"].as_str() == Some(name)) +} + +#[then(regex = r#"^the listing has an entry named "([^"]+)"$"#)] +fn listing_has_entry(world: &mut CoderWorld, name: String) { + if world.iii.is_none() { + return; + } + let v = last_ok(world); + assert!( + find_entry(v, &name).is_some(), + "expected entry named {name:?}; got: {:?}", + entries(v) + ); +} + +#[then(regex = r#"^the listing has no entry named "([^"]+)"$"#)] +fn listing_lacks_entry(world: &mut CoderWorld, name: String) { + if world.iii.is_none() { + return; + } + let v = last_ok(world); + assert!( + find_entry(v, &name).is_none(), + "entry named {name:?} unexpectedly present; got: {:?}", + entries(v) + ); +} + +#[then(regex = r#"^the listing entry "([^"]+)" has kind "([^"]+)"$"#)] +fn listing_entry_kind(world: &mut CoderWorld, name: String, kind: String) { + if world.iii.is_none() { + return; + } + let v = last_ok(world); + let entry = find_entry(v, &name) + .unwrap_or_else(|| panic!("missing entry {name:?}; got: {:?}", entries(v))); + assert_eq!( + entry["kind"].as_str().unwrap_or(""), + kind, + "kind mismatch for {name:?}: {entry:?}" + ); +} + +#[then(regex = r#"^the listing entry "([^"]+)" is non_accessible$"#)] +fn listing_entry_non_accessible(world: &mut CoderWorld, name: String) { + if world.iii.is_none() { + return; + } + let v = last_ok(world); + let entry = find_entry(v, &name) + .unwrap_or_else(|| panic!("missing entry {name:?}; got: {:?}", entries(v))); + assert_eq!( + entry["non_accessible"], true, + "expected non_accessible: true for {name:?}; got: {entry:?}" + ); +} + +#[then(regex = r#"^the listing entry "([^"]+)" is accessible$"#)] +fn listing_entry_accessible(world: &mut CoderWorld, name: String) { + if world.iii.is_none() { + return; + } + let v = last_ok(world); + let entry = find_entry(v, &name) + .unwrap_or_else(|| panic!("missing entry {name:?}; got: {:?}", entries(v))); + assert_eq!( + entry["non_accessible"], false, + "expected non_accessible: false for {name:?}; got: {entry:?}" + ); +} + +#[then(regex = r#"^the listing total equals (\d+)$"#)] +fn listing_total(world: &mut CoderWorld, expected: u64) { + if world.iii.is_none() { + return; + } + let v = last_ok(world); + let got = v["total"].as_u64().unwrap_or_default(); + assert_eq!(got, expected, "total mismatch; got: {v}"); +} + +#[then(regex = r#"^the listing page equals (\d+)$"#)] +fn listing_page(world: &mut CoderWorld, expected: u64) { + if world.iii.is_none() { + return; + } + let v = last_ok(world); + let got = v["page"].as_u64().unwrap_or_default(); + assert_eq!(got, expected, "page mismatch; got: {v}"); +} + +#[then(regex = r#"^the listing page_size equals (\d+)$"#)] +fn listing_page_size(world: &mut CoderWorld, expected: u64) { + if world.iii.is_none() { + return; + } + let v = last_ok(world); + let got = v["page_size"].as_u64().unwrap_or_default(); + assert_eq!(got, expected, "page_size mismatch; got: {v}"); +} + +#[then(regex = r#"^the listing has_more is (true|false)$"#)] +fn listing_has_more(world: &mut CoderWorld, expected: String) { + if world.iii.is_none() { + return; + } + let v = last_ok(world); + let got = v["has_more"].as_bool().unwrap_or_default(); + let expected_bool = expected == "true"; + assert_eq!(got, expected_bool, "has_more mismatch; got: {v}"); +} + +#[then(regex = r#"^the listing has (\d+) entries$"#)] +fn listing_entry_count(world: &mut CoderWorld, expected: usize) { + if world.iii.is_none() { + return; + } + let v = last_ok(world); + let got = entries(v).len(); + assert_eq!(got, expected, "entry-count mismatch; got: {v}"); +} diff --git a/coder/tests/steps/mod.rs b/coder/tests/steps/mod.rs new file mode 100644 index 000000000..0f922600e --- /dev/null +++ b/coder/tests/steps/mod.rs @@ -0,0 +1,16 @@ +//! Step-definition modules. One per feature group under +//! `tests/features/`. Most steps live in `common` (engine readiness, +//! generic dispatcher, fixture writes, shared outcome assertions); the +//! per-function modules add response-shape assertions specific to each +//! `coder::*` function. + +pub mod common; +pub mod create; +pub mod delete; +pub mod lifecycle; +pub mod list; +pub mod read; +pub mod search; +pub mod security; +pub mod tree; +pub mod update; diff --git a/coder/tests/steps/read.rs b/coder/tests/steps/read.rs new file mode 100644 index 000000000..72b7601b8 --- /dev/null +++ b/coder/tests/steps/read.rs @@ -0,0 +1,60 @@ +//! `coder::read-file`-specific assertions. Read returns a flat record so +//! every Then maps to a top-level field; we keep them parameterised so +//! one step def covers `content`, `size`, `is_utf8`, and `path` echo. + +use cucumber::then; + +use crate::common::helpers::{docstring_body, last_ok}; +use crate::common::world::CoderWorld; + +#[then(regex = r#"^the read content equals "([^"]*)"$"#)] +fn read_content_equals(world: &mut CoderWorld, expected: String) { + if world.iii.is_none() { + return; + } + let v = last_ok(world); + let got = v["content"].as_str().unwrap_or(""); + assert_eq!(got, expected, "content mismatch; got: {v}"); +} + +#[then("the read content equals:")] +fn read_content_equals_docstring(world: &mut CoderWorld, step: &cucumber::gherkin::Step) { + if world.iii.is_none() { + return; + } + let expected = docstring_body(step); + let v = last_ok(world); + let got = v["content"].as_str().unwrap_or(""); + assert_eq!(got, expected, "content mismatch; got: {v}"); +} + +#[then(regex = r#"^the read size equals (\d+)$"#)] +fn read_size_equals(world: &mut CoderWorld, expected: u64) { + if world.iii.is_none() { + return; + } + let v = last_ok(world); + let got = v["size"].as_u64().unwrap_or_default(); + assert_eq!(got, expected, "size mismatch; got: {v}"); +} + +#[then(regex = r#"^the read is_utf8 is (true|false)$"#)] +fn read_is_utf8(world: &mut CoderWorld, expected: String) { + if world.iii.is_none() { + return; + } + let v = last_ok(world); + let got = v["is_utf8"].as_bool().unwrap_or(false); + let expected_bool = expected == "true"; + assert_eq!(got, expected_bool, "is_utf8 mismatch; got: {v}"); +} + +#[then(regex = r#"^the read path equals "([^"]+)"$"#)] +fn read_path_equals(world: &mut CoderWorld, expected: String) { + if world.iii.is_none() { + return; + } + let v = last_ok(world); + let got = v["path"].as_str().unwrap_or(""); + assert_eq!(got, expected, "path echo mismatch; got: {v}"); +} diff --git a/coder/tests/steps/search.rs b/coder/tests/steps/search.rs new file mode 100644 index 000000000..15015a43b --- /dev/null +++ b/coder/tests/steps/search.rs @@ -0,0 +1,131 @@ +//! `coder::search`-specific assertions. Search returns two arrays +//! (`content_matches[]` and `path_matches[]`) plus a top-level +//! `truncated` flag. + +use cucumber::then; +use serde_json::Value; + +use crate::common::helpers::last_ok; +use crate::common::world::CoderWorld; + +fn content_matches(v: &Value) -> &[Value] { + v["content_matches"] + .as_array() + .map(Vec::as_slice) + .unwrap_or(&[]) +} + +fn path_matches(v: &Value) -> &[Value] { + v["path_matches"] + .as_array() + .map(Vec::as_slice) + .unwrap_or(&[]) +} + +#[then(regex = r#"^the search has a content match for "([^"]+)" at line (\d+)$"#)] +fn search_has_content_match(world: &mut CoderWorld, path: String, line: u64) { + if world.iii.is_none() { + return; + } + let v = last_ok(world); + let arr = content_matches(v); + let found = arr + .iter() + .any(|m| m["path"].as_str() == Some(path.as_str()) && m["line"].as_u64() == Some(line)); + assert!( + found, + "expected content match for {path:?} at line {line}; got: {arr:?}" + ); +} + +#[then(regex = r#"^the search has a content match for "([^"]+)"$"#)] +fn search_has_content_match_any_line(world: &mut CoderWorld, path: String) { + if world.iii.is_none() { + return; + } + let v = last_ok(world); + let arr = content_matches(v); + let found = arr + .iter() + .any(|m| m["path"].as_str() == Some(path.as_str())); + assert!( + found, + "expected at least one content match for {path:?}; got: {arr:?}" + ); +} + +#[then(regex = r#"^the search has no content match for "([^"]+)"$"#)] +fn search_lacks_content_match(world: &mut CoderWorld, path: String) { + if world.iii.is_none() { + return; + } + let v = last_ok(world); + let arr = content_matches(v); + let found = arr + .iter() + .any(|m| m["path"].as_str() == Some(path.as_str())); + assert!( + !found, + "unexpected content match for {path:?}; got: {arr:?}" + ); +} + +#[then(regex = r#"^the search has a path match for "([^"]+)"$"#)] +fn search_has_path_match(world: &mut CoderWorld, path: String) { + if world.iii.is_none() { + return; + } + let v = last_ok(world); + let arr = path_matches(v); + let found = arr + .iter() + .any(|m| m["path"].as_str() == Some(path.as_str())); + assert!(found, "expected path match for {path:?}; got: {arr:?}"); +} + +#[then(regex = r#"^the search has no path match for "([^"]+)"$"#)] +fn search_lacks_path_match(world: &mut CoderWorld, path: String) { + if world.iii.is_none() { + return; + } + let v = last_ok(world); + let arr = path_matches(v); + let found = arr + .iter() + .any(|m| m["path"].as_str() == Some(path.as_str())); + assert!(!found, "unexpected path match for {path:?}; got: {arr:?}"); +} + +#[then(regex = r#"^the search truncated is (true|false)$"#)] +fn search_truncated(world: &mut CoderWorld, expected: String) { + if world.iii.is_none() { + return; + } + let v = last_ok(world); + let got = v["truncated"].as_bool().unwrap_or(false); + let expected_bool = expected == "true"; + assert_eq!(got, expected_bool, "truncated mismatch; got: {v}"); +} + +#[then("the search has no content matches")] +fn search_no_content_matches(world: &mut CoderWorld) { + if world.iii.is_none() { + return; + } + let v = last_ok(world); + let arr = content_matches(v); + assert!( + arr.is_empty(), + "expected zero content matches; got: {arr:?}" + ); +} + +#[then("the search has no path matches")] +fn search_no_path_matches(world: &mut CoderWorld) { + if world.iii.is_none() { + return; + } + let v = last_ok(world); + let arr = path_matches(v); + assert!(arr.is_empty(), "expected zero path matches; got: {arr:?}"); +} diff --git a/coder/tests/steps/security.rs b/coder/tests/steps/security.rs new file mode 100644 index 000000000..10921a2d3 --- /dev/null +++ b/coder/tests/steps/security.rs @@ -0,0 +1,38 @@ +//! Security-specific fixture steps. Most of the path-jail invariants +//! (dot-dot escape, absolute paths) need no fixture — the dispatcher +//! and `the call failed with code "..."` Then in [`common`](super::common) +//! are enough. This module adds the symlink-escape fixture, which +//! requires Unix `symlink` syscalls; the matching scenario is tagged +//! `@unix` so non-Unix runs can filter it out. + +use cucumber::given; + +use crate::common::world::CoderWorld; + +#[given(regex = r#"^a symlink at "([^"]+)" pointing to a path outside base$"#)] +async fn given_symlink_escape(world: &mut CoderWorld, rel: String) { + if world.iii.is_none() { + return; + } + let Some(base) = world.base_path.as_ref() else { + return; + }; + create_escape_symlink(base, &rel); +} + +#[cfg(unix)] +fn create_escape_symlink(base: &std::path::Path, rel: &str) { + let outside = tempfile::tempdir().expect("escape tempdir").keep(); + let target = outside.join("secret.txt"); + std::fs::write(&target, b"x").expect("write escape target"); + std::os::unix::fs::symlink(&target, base.join(rel)).expect("create symlink"); +} + +#[cfg(not(unix))] +fn create_escape_symlink(_base: &std::path::Path, _rel: &str) { + panic!( + "symlink-escape scenarios are Unix-only; tag with @unix and \ + filter on non-Unix hosts (cargo test --test bdd -- --tags \ + \"not @unix\")" + ); +} diff --git a/coder/tests/steps/tree.rs b/coder/tests/steps/tree.rs new file mode 100644 index 000000000..32e614451 --- /dev/null +++ b/coder/tests/steps/tree.rs @@ -0,0 +1,130 @@ +//! `coder::tree`-specific assertions. The response is a single nested +//! `root` node; navigation through the tree uses a slash-separated path +//! ("a/b/c") starting from the root's children. Pointer ".” / empty +//! string addresses the root itself. + +use cucumber::then; +use serde_json::Value; + +use crate::common::helpers::last_ok; +use crate::common::world::CoderWorld; + +/// Walk the tree from `root` to a node whose path-from-root matches the +/// slash-separated `rel`. `"."` or `""` returns the root. Returns +/// `None` when any segment is missing. +fn find_node<'a>(root: &'a Value, rel: &str) -> Option<&'a Value> { + let trimmed = rel.trim(); + if trimmed.is_empty() || trimmed == "." { + return Some(root); + } + let mut current = root; + for segment in trimmed.split('/') { + let kids = current.get("children")?.as_array()?; + current = kids.iter().find(|c| c["name"].as_str() == Some(segment))?; + } + Some(current) +} + +fn root_of(world: &CoderWorld) -> &Value { + &last_ok(world)["root"] +} + +#[then(regex = r#"^the tree has a node at "([^"]+)"$"#)] +fn tree_has_node(world: &mut CoderWorld, rel: String) { + if world.iii.is_none() { + return; + } + let root = root_of(world); + assert!( + find_node(root, &rel).is_some(), + "missing node {rel:?}; tree: {root}" + ); +} + +#[then(regex = r#"^the tree has no node at "([^"]+)"$"#)] +fn tree_lacks_node(world: &mut CoderWorld, rel: String) { + if world.iii.is_none() { + return; + } + let root = root_of(world); + assert!( + find_node(root, &rel).is_none(), + "node {rel:?} unexpectedly present; tree: {root}" + ); +} + +#[then(regex = r#"^the tree node at "([^"]+)" has kind "([^"]+)"$"#)] +fn tree_node_kind(world: &mut CoderWorld, rel: String, kind: String) { + if world.iii.is_none() { + return; + } + let root = root_of(world); + let node = + find_node(root, &rel).unwrap_or_else(|| panic!("missing node {rel:?}; tree: {root}")); + assert_eq!( + node["kind"].as_str().unwrap_or(""), + kind, + "kind mismatch at {rel:?}: {node}" + ); +} + +#[then(regex = r#"^the tree node at "([^"]+)" is non_accessible$"#)] +fn tree_node_non_accessible(world: &mut CoderWorld, rel: String) { + if world.iii.is_none() { + return; + } + let root = root_of(world); + let node = + find_node(root, &rel).unwrap_or_else(|| panic!("missing node {rel:?}; tree: {root}")); + assert_eq!( + node["non_accessible"], true, + "expected non_accessible: true at {rel:?}; got: {node}" + ); +} + +#[then(regex = r#"^the tree node at "([^"]+)" is truncated with reason "([^"]+)"$"#)] +fn tree_node_truncated(world: &mut CoderWorld, rel: String, reason: String) { + if world.iii.is_none() { + return; + } + let root = root_of(world); + let node = + find_node(root, &rel).unwrap_or_else(|| panic!("missing node {rel:?}; tree: {root}")); + let trunc = &node["truncated"]; + assert!( + !trunc.is_null(), + "expected truncated info at {rel:?}; got: {node}" + ); + assert_eq!( + trunc["reason"].as_str().unwrap_or(""), + reason, + "reason mismatch at {rel:?}: {trunc}" + ); +} + +#[then(regex = r#"^the tree node at "([^"]+)" hint mentions "([^"]+)"$"#)] +fn tree_node_hint_mentions(world: &mut CoderWorld, rel: String, needle: String) { + if world.iii.is_none() { + return; + } + let root = root_of(world); + let node = + find_node(root, &rel).unwrap_or_else(|| panic!("missing node {rel:?}; tree: {root}")); + let hint = node["truncated"]["hint"].as_str().unwrap_or(""); + assert!( + hint.contains(&needle), + "expected hint to contain {needle:?}; got: {hint:?}" + ); +} + +#[then(regex = r#"^the tree node at "([^"]+)" has (\d+) children$"#)] +fn tree_node_children_count(world: &mut CoderWorld, rel: String, expected: usize) { + if world.iii.is_none() { + return; + } + let root = root_of(world); + let node = + find_node(root, &rel).unwrap_or_else(|| panic!("missing node {rel:?}; tree: {root}")); + let got = node["children"].as_array().map(Vec::len).unwrap_or(0); + assert_eq!(got, expected, "child-count mismatch at {rel:?}: {node}"); +} diff --git a/coder/tests/steps/update.rs b/coder/tests/steps/update.rs new file mode 100644 index 000000000..149544114 --- /dev/null +++ b/coder/tests/steps/update.rs @@ -0,0 +1,45 @@ +//! `coder::update-file`-specific assertions. The bottom-up application, +//! atomicity, and per-file partial success all surface through the +//! generic `result for ""` Thens; this module covers `applied` +//! and `new_line_count` on each per-file result. + +use cucumber::then; + +use crate::common::helpers::{batch_result, last_ok}; +use crate::common::world::CoderWorld; + +#[then(regex = r#"^the result for "([^"]+)" applied (\d+) ops$"#)] +fn update_applied(world: &mut CoderWorld, path: String, expected: u64) { + if world.iii.is_none() { + return; + } + let entry = batch_result(world, &path).unwrap_or_else(|| { + panic!( + "no result entry for path {path:?}; got: {:?}", + last_ok(world) + ) + }); + let got = entry["applied"].as_u64().unwrap_or_default(); + assert_eq!( + got, expected, + "expected applied {expected} for {path:?}; got: {entry:?}" + ); +} + +#[then(regex = r#"^the result for "([^"]+)" has line count (\d+)$"#)] +fn update_line_count(world: &mut CoderWorld, path: String, expected: u64) { + if world.iii.is_none() { + return; + } + let entry = batch_result(world, &path).unwrap_or_else(|| { + panic!( + "no result entry for path {path:?}; got: {:?}", + last_ok(world) + ) + }); + let got = entry["new_line_count"].as_u64().unwrap_or_default(); + assert_eq!( + got, expected, + "expected new_line_count {expected} for {path:?}; got: {entry:?}" + ); +} diff --git a/coder/tests/update_ops.rs b/coder/tests/update_ops.rs new file mode 100644 index 000000000..d25efd7a2 --- /dev/null +++ b/coder/tests/update_ops.rs @@ -0,0 +1,186 @@ +//! Cross-cutting integration coverage for the batched update applier. +//! The per-module unit tests cover most branches; these tests target the +//! public handler surface and the atomicity invariants spanning multiple +//! files. + +use std::path::PathBuf; +use std::sync::Arc; + +use coder::config::CoderConfig; +use coder::functions::update_file::{ + handle as update_handle, UpdateFileInput, UpdateFileSpec, UpdateOp, +}; +use coder::path::PathResolver; +use tempfile::tempdir; + +fn make(base: PathBuf, globs: Vec<&str>) -> (Arc, Arc) { + let cfg = Arc::new(CoderConfig { + base_path: base, + non_accessible_globs: globs.into_iter().map(String::from).collect(), + max_read_bytes: 1024 * 1024, + max_write_bytes: 1024 * 1024, + ..CoderConfig::default() + }); + let r = Arc::new(PathResolver::new(&cfg).unwrap()); + (r, cfg) +} + +#[tokio::test] +async fn bottom_up_application_e2e() { + let tmp = tempdir().unwrap(); + std::fs::write(tmp.path().join("a.txt"), "1\n2\n3\n4\n5\n").unwrap(); + let (r, c) = make(tmp.path().to_path_buf(), vec![]); + let out = update_handle( + r, + c, + UpdateFileInput { + files: vec![UpdateFileSpec { + path: "a.txt".into(), + ops: vec![ + UpdateOp::Insert { + at_line: 1, + content: "0".into(), + }, + UpdateOp::Remove { + from_line: 2, + to_line: 4, + }, + UpdateOp::UpdateLines { + from_line: 5, + to_line: 5, + content: "FIVE".into(), + }, + ], + }], + }, + ) + .await + .unwrap(); + assert!(out.results[0].success); + let after = std::fs::read_to_string(tmp.path().join("a.txt")).unwrap(); + assert_eq!(after, "0\n1\nFIVE\n"); +} + +#[tokio::test] +async fn batch_with_mix_of_success_and_failure_preserves_originals() { + let tmp = tempdir().unwrap(); + std::fs::write(tmp.path().join("ok.txt"), "x\ny\n").unwrap(); + let original_ok = std::fs::read_to_string(tmp.path().join("ok.txt")).unwrap(); + std::fs::write(tmp.path().join("bad.txt"), "a\nb\nc\n").unwrap(); + let original_bad = std::fs::read_to_string(tmp.path().join("bad.txt")).unwrap(); + std::fs::write(tmp.path().join(".env"), "S=1\n").unwrap(); + let original_env = std::fs::read_to_string(tmp.path().join(".env")).unwrap(); + + let (r, c) = make(tmp.path().to_path_buf(), vec!["**/.env"]); + let out = update_handle( + r, + c, + UpdateFileInput { + files: vec![ + UpdateFileSpec { + path: "ok.txt".into(), + ops: vec![UpdateOp::Insert { + at_line: 1, + content: "P".into(), + }], + }, + UpdateFileSpec { + path: "bad.txt".into(), + ops: vec![ + UpdateOp::Remove { + from_line: 1, + to_line: 2, + }, + UpdateOp::UpdateLines { + from_line: 2, + to_line: 3, + content: "Z".into(), + }, + ], + }, + UpdateFileSpec { + path: ".env".into(), + ops: vec![UpdateOp::Insert { + at_line: 1, + content: "X".into(), + }], + }, + ], + }, + ) + .await + .unwrap(); + + assert_eq!(out.results.len(), 3); + assert!(out.results[0].success, "ok.txt should succeed"); + assert!(!out.results[1].success, "bad.txt overlap must be rejected"); + assert!(out.results[1].error.as_deref().unwrap().contains("C210")); + assert!(!out.results[2].success, ".env must be denied"); + assert!(out.results[2].error.as_deref().unwrap().contains("C211")); + + assert_eq!( + std::fs::read_to_string(tmp.path().join("ok.txt")).unwrap(), + "P\n".to_string() + &original_ok + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join("bad.txt")).unwrap(), + original_bad + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join(".env")).unwrap(), + original_env + ); +} + +#[tokio::test] +async fn crlf_line_endings_preserved_after_update() { + let tmp = tempdir().unwrap(); + std::fs::write(tmp.path().join("crlf.txt"), b"a\r\nb\r\nc\r\n").unwrap(); + let (r, c) = make(tmp.path().to_path_buf(), vec![]); + update_handle( + r, + c, + UpdateFileInput { + files: vec![UpdateFileSpec { + path: "crlf.txt".into(), + ops: vec![UpdateOp::UpdateLines { + from_line: 2, + to_line: 2, + content: "B".into(), + }], + }], + }, + ) + .await + .unwrap(); + let bytes = std::fs::read(tmp.path().join("crlf.txt")).unwrap(); + assert_eq!(bytes, b"a\r\nB\r\nc\r\n"); +} + +#[tokio::test] +async fn regex_replace_e2e() { + let tmp = tempdir().unwrap(); + std::fs::write(tmp.path().join("a.txt"), "foo bar foo\n").unwrap(); + let (r, c) = make(tmp.path().to_path_buf(), vec![]); + let out = update_handle( + r, + c, + UpdateFileInput { + files: vec![UpdateFileSpec { + path: "a.txt".into(), + ops: vec![UpdateOp::Replace { + pattern: "foo".into(), + replacement: "baz".into(), + ignore_case: false, + }], + }], + }, + ) + .await + .unwrap(); + assert!(out.results[0].success); + assert_eq!( + std::fs::read_to_string(tmp.path().join("a.txt")).unwrap(), + "baz bar baz\n" + ); +} From 6ac608c0d81cbe605b2b1e5ecba267317ed0ad15 Mon Sep 17 00:00:00 2001 From: Sergio Marcelino Date: Tue, 26 May 2026 08:34:23 -0300 Subject: [PATCH 2/3] chore: adding publish tags to coder --- .github/workflows/create-tag.yml | 1 + .github/workflows/release.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml index dd80a7e9f..b37976ddc 100644 --- a/.github/workflows/create-tag.yml +++ b/.github/workflows/create-tag.yml @@ -9,6 +9,7 @@ on: type: choice options: - acp + - coder - console - database - harness diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2453ef7e1..982213ed1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,7 @@ on: push: tags: - 'acp/v*' + - 'coder/v*' - 'console/v*' - 'database/v*' - 'harness/v*' From 619550d9fd742d8e9ba2a9b76b067dac54ffdc38 Mon Sep 17 00:00:00 2001 From: Sergio Marcelino Date: Tue, 26 May 2026 11:42:23 -0300 Subject: [PATCH 3/3] feat: trigger ci --- coder/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coder/README.md b/coder/README.md index 1fdfa7218..13581a442 100644 --- a/coder/README.md +++ b/coder/README.md @@ -5,7 +5,7 @@ search, edit, create, and delete files inside a single configured `base_path` — without ever escaping it via `..`, absolute paths, or symlinks. A glob-based `non_accessible` list keeps sensitive files (`.env`, `*.pem`, anything under `secrets/`) visible to directory -listings but unreadable and unwritable. +listings but unreadable and unwritable ## Install