From 5951de0b3d8e9b299ada7e13e994b66562d2cf0c Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Tue, 3 Nov 2026 18:18:24 +0000 Subject: [PATCH 01/12] Add imcp2-deploy: an icp-project host for a bundle, treated as untrusted input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first of three changes that bring `icp deploy` to the local MCP server through the icp CLI's own project library. This one is the library's `Host` for a bundle — the `.tar.gz` that `icp project bundle` writes — and nothing yet drives it. A bundle is handed over by an agent, so the host takes it for exactly what a bundle is and no more: - `extract_bundle` unpacks it into a scratch directory under a compressed-size limit (256 MiB) and a decompressed-stream limit (1 GiB), refusing symlinks and other non-file entries, and anything under `.icp/`, the state a deploy seeds itself. - `BundleFs` is the `FileSystem` the library reads the project through: backed by the index of what extraction wrote, so a path is normalized against the bundle root (`..` clamped, absolute re-anchored) and looked up there. A path the archive did not carry is "not in the bundle", whatever exists on disk at that spelling. Writes land only in scratch directories the filesystem created. - `IcMainnet` is the network seam: the `ic` network with the pinned mainnet root key, and nothing else. - `BundledWasm` serves local wasm sources from the index, sha256 verified; `NoScripts` runs pre-built steps and refuses script ones. - `check_bundle` validates the whole bundle before a deploy would run: one pre-built step per canister naming a wasm in the archive that hashes to its checksum, every sync plugin in the archive, no scripts, an environment on `ic`. The crate is `publish = false` by construction: `icp-project` and its siblings are git dependencies, which crates.io refuses, so this cannot live in `imcp2-core`. The four icp-cli crates are pinned to one rev. `icp-sync-plugin` brings wasmtime into the workspace. The manifest carries the full dependency list so the lockfile lands once; a few deps (rmcp, base64) are used only by the tools that follow. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 1692 +++++++++++++++++++++++++++++-- Cargo.toml | 6 +- crates/imcp2-deploy/Cargo.toml | 62 ++ crates/imcp2-deploy/src/host.rs | 964 ++++++++++++++++++ crates/imcp2-deploy/src/lib.rs | 14 + 5 files changed, 2655 insertions(+), 83 deletions(-) create mode 100644 crates/imcp2-deploy/Cargo.toml create mode 100644 crates/imcp2-deploy/src/host.rs create mode 100644 crates/imcp2-deploy/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 9087355..5a9a375 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -35,6 +44,12 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -46,9 +61,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "ar_archive_writer" @@ -56,7 +71,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" dependencies = [ - "object", + "object 0.37.3", ] [[package]] @@ -137,7 +152,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -252,6 +267,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base32" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa" + [[package]] name = "base64" version = "0.13.1" @@ -276,6 +297,19 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" +[[package]] +name = "bigdecimal" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fa3f3d8cbf4dffcfe4991de61d012bef509a409ecbe9dd41049bfe32b4d4653" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "binrw" version = "0.15.2" @@ -296,7 +330,7 @@ dependencies = [ "either", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -345,6 +379,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", + "zeroize", ] [[package]] @@ -352,6 +387,9 @@ name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +dependencies = [ + "allocator-api2", +] [[package]] name = "bytemuck" @@ -384,6 +422,25 @@ dependencies = [ "web-time", ] +[[package]] +name = "camino" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" +dependencies = [ + "serde_core", +] + +[[package]] +name = "camino-tempfile" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64308c4c82a5c38679945ddf88738dc1483dcc563bbb5780755ae9f8497d2b20" +dependencies = [ + "camino", + "tempfile", +] + [[package]] name = "candid" version = "0.10.35" @@ -416,7 +473,7 @@ dependencies = [ "lazy_static", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -438,7 +495,25 @@ dependencies = [ "pretty", "serde", "thiserror 1.0.69", - "toml", + "toml 0.8.23", +] + +[[package]] +name = "cap-primitives" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b5f74729fd2f44701d1a8eb47e906cdb3ccd9ec0f02baad85a744b791940b18" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes 3.0.1", + "ipnet", + "maybe-owned", + "rustix 1.1.4", + "rustix-linux-procfs", + "windows-sys 0.61.2", + "winx", ] [[package]] @@ -497,6 +572,15 @@ dependencies = [ "cc", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "codespan-reporting" version = "0.11.1" @@ -526,6 +610,18 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console" +version = "0.16.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width 0.2.2", + "windows-sys 0.61.2", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -563,6 +659,21 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpp_demangle" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0667304c32ea56cb4cd6d2d7c0cfe9a2f8041229db8c033af7f8d69492429def" +dependencies = [ + "cfg-if", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -581,6 +692,152 @@ dependencies = [ "libc", ] +[[package]] +name = "cranelift-assembler-x64" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9c587da2696fbaa58e7a2fa2e5bfdd365d0928219f36992304ac1f15e5bc254" +dependencies = [ + "cranelift-assembler-x64-meta", +] + +[[package]] +name = "cranelift-assembler-x64-meta" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a60027b7556b680b051651291053ec381edf4ffbc01784997ae51a64269bb" +dependencies = [ + "cranelift-srcgen", +] + +[[package]] +name = "cranelift-bforest" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bfdb846acebfd591d601994ea283c98d525ac0bc9a84ca3f2c36bcf5b69ff1" +dependencies = [ + "cranelift-entity", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-bitset" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9f8cc1080739d5f72b4ee7e00aff7d589f1284beaf0a19472c07878be7f299" +dependencies = [ + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85417d493ecec833b5134546b4a252c78cc0bd5be11729a35d25dcff9e4e70b" +dependencies = [ + "bumpalo", + "cranelift-assembler-x64", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.17.1", + "libm", + "log", + "postcard", + "pulley-interpreter", + "regalloc2", + "rustc-hash", + "serde", + "serde_derive", + "sha2 0.10.9", + "smallvec", + "target-lexicon", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d01c8ed0098360bae69ad6b82952d4247516799ce8050d45ad168d6689f4eae6" +dependencies = [ + "cranelift-assembler-x64-meta", + "cranelift-codegen-shared", + "cranelift-srcgen", + "heck", + "pulley-interpreter", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f9b86d809cec9c4e88b201f1b3ed3b0347674c9ccfbb8c355228669e34be81" + +[[package]] +name = "cranelift-control" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce182038fb10b719841e65fdf5deeee258c684f63439f5be680c2be059274b58" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bca812b03a7b942796391f25bc02aa70a1a007fee3ce2756490c788429e9c39" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-frontend" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c30563d2aef95e8d1a00a920199c99117b350339599f0e9487a7a1cf5d59de" +dependencies = [ + "cranelift-codegen", + "hashbrown 0.17.1", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c54184333a275698edb1d3bdff226131a25d47133d792aa716e47e32b8b7f05" + +[[package]] +name = "cranelift-native" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb67a4c8a3180b5f1d77dffcd6317c05386ced42e5a31c7004523a9d99dc85c1" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + +[[package]] +name = "cranelift-srcgen" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b533db1679b4b728568bd490da6533cf50974c48a4d7534e78f1e0e02bc39769" + [[package]] name = "crc32fast" version = "1.5.0" @@ -599,6 +856,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -668,7 +944,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -715,7 +991,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -728,7 +1004,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -739,7 +1015,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -750,7 +1026,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -759,6 +1035,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "uuid", +] + [[package]] name = "der" version = "0.7.10" @@ -794,7 +1079,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -804,7 +1089,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn", + "syn 2.0.117", ] [[package]] @@ -837,6 +1122,17 @@ dependencies = [ "block-buffer 0.12.1", "const-oid 0.10.2", "crypto-common 0.2.2", + "zeroize", +] + +[[package]] +name = "directories-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc" +dependencies = [ + "cfg-if", + "dirs-sys-next", ] [[package]] @@ -889,7 +1185,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -986,6 +1282,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "ena" version = "0.14.4" @@ -995,6 +1303,26 @@ dependencies = [ "log", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1069,6 +1397,16 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1089,6 +1427,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -1103,6 +1442,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1112,6 +1457,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes 2.0.4", + "rustix 1.1.4", + "windows-sys 0.52.0", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -1174,7 +1530,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1206,6 +1562,20 @@ dependencies = [ "slab", ] +[[package]] +name = "fxprof-processed-profile" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25234f20a3ec0a962a61770cfe39ecf03cb529a6e474ad8cff025ed497eda557" +dependencies = [ + "bitflags", + "debugid", + "rustc-hash", + "serde", + "serde_derive", + "serde_json", +] + [[package]] name = "generic-array" version = "0.14.9" @@ -1258,6 +1628,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gimli" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" +dependencies = [ + "fnv", + "hashbrown 0.16.1", + "indexmap", + "stable_deref_trait", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "group" version = "0.13.0" @@ -1318,14 +1706,25 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", + "serde", + "serde_core", +] [[package]] name = "heck" @@ -1557,20 +1956,63 @@ dependencies = [ ] [[package]] -name = "ic-certification" -version = "3.2.0" +name = "ic-cdk" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "754e3037360152de7e5f2bd0ccf51db8864b6a03c5a2b4ffc6a65286c0e2ffa7" +checksum = "818d6d5416a8f0212e1b132703b0da51e36c55f2b96677e96f2bbe7702e1bd85" dependencies = [ - "hex", + "candid", + "ic-cdk-executor", + "ic-cdk-macros", + "ic-error-types", + "ic-management-canister-types 0.5.0", + "ic0", + "pin-project-lite", "serde", "serde_bytes", - "sha2 0.10.9", + "slotmap", + "thiserror 2.0.18", ] [[package]] -name = "ic-ed25519" -version = "0.6.0" +name = "ic-cdk-executor" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33716b730ded33690b8a704bff3533fda87d229e58046823647d28816e9bcee7" +dependencies = [ + "ic0", + "slotmap", + "smallvec", +] + +[[package]] +name = "ic-cdk-macros" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66dad91a214945cb3605bc9ef6901b87e2ac41e3624284c2cabba49d43aa4f43" +dependencies = [ + "candid", + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ic-certification" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754e3037360152de7e5f2bd0ccf51db8864b6a03c5a2b4ffc6a65286c0e2ffa7" +dependencies = [ + "hex", + "serde", + "serde_bytes", + "sha2 0.10.9", +] + +[[package]] +name = "ic-ed25519" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ad3d86f2e35cb99ae7497b3e4fa92ad751a8e8978f0680d37b86dd51ef91714" dependencies = [ @@ -1585,6 +2027,32 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ic-error-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbeeb3d91aa179d6496d7293becdacedfc413c825cac79fd54ea1906f003ee55" +dependencies = [ + "serde", + "strum 0.26.3", + "strum_macros 0.26.4", +] + +[[package]] +name = "ic-ledger-types" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60ab0da348a638e01beb5bcc6c0b92e51efbe351950ff99c125d53fff77899d5" +dependencies = [ + "candid", + "crc32fast", + "hex", + "ic-cdk", + "serde", + "serde_bytes", + "sha2 0.10.9", +] + [[package]] name = "ic-management-canister-types" version = "0.5.0" @@ -1596,6 +2064,26 @@ dependencies = [ "serde_bytes", ] +[[package]] +name = "ic-management-canister-types" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4cdeb533e0ed67b4fd6b7f2c396e6b84cfe0f875e38911bed49a1f527c65fb3" +dependencies = [ + "candid", + "serde", + "serde_bytes", +] + +[[package]] +name = "ic-stable-structures" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d30d4cf17aff1024e13133897048bcba580e063c9000571ab766ca37e2996f4" +dependencies = [ + "ic_principal", +] + [[package]] name = "ic-transport-types" version = "0.45.0" @@ -1646,6 +2134,12 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "ic0" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05349d5c51ab6cc962c8963f877f826997ee6b737c6be3aafc9779eb8a4dd05" + [[package]] name = "ic_bls12_381" version = "0.10.1" @@ -1675,6 +2169,128 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "icp-canister-interfaces" +version = "1.7.0" +source = "git+https://github.com/dfinity/icp-cli?rev=f4c7b6627cab56b091c01cc18102f5dd88aa811f#f4c7b6627cab56b091c01cc18102f5dd88aa811f" +dependencies = [ + "bigdecimal", + "candid", + "ic-management-canister-types 0.9.0", + "serde", +] + +[[package]] +name = "icp-events" +version = "1.7.0" +source = "git+https://github.com/dfinity/icp-cli?rev=f4c7b6627cab56b091c01cc18102f5dd88aa811f#f4c7b6627cab56b091c01cc18102f5dd88aa811f" +dependencies = [ + "serde", + "tokio", +] + +[[package]] +name = "icp-project" +version = "1.7.0" +source = "git+https://github.com/dfinity/icp-cli?rev=f4c7b6627cab56b091c01cc18102f5dd88aa811f#f4c7b6627cab56b091c01cc18102f5dd88aa811f" +dependencies = [ + "async-trait", + "bigdecimal", + "camino", + "camino-tempfile", + "candid", + "candid_parser", + "dunce", + "flate2", + "futures", + "glob", + "handlebars", + "hex", + "ic-ledger-types", + "ic-management-canister-types 0.9.0", + "icp-canister-interfaces", + "icp-events", + "icrc-ledger-types", + "indexmap", + "indoc", + "itertools 0.14.0", + "num-bigint", + "num-integer", + "num-traits", + "pathdiff", + "rand 0.10.1", + "schemars 1.2.1", + "semver", + "serde", + "serde_json", + "serde_yaml", + "sha2 0.11.0", + "shellwords", + "snafu", + "strum 0.28.0", + "tar", + "time", + "tokio", + "tracing", + "url", + "wasmparser 0.255.0", + "winreg", +] + +[[package]] +name = "icp-sync-plugin" +version = "1.7.0" +source = "git+https://github.com/dfinity/icp-cli?rev=f4c7b6627cab56b091c01cc18102f5dd88aa811f#f4c7b6627cab56b091c01cc18102f5dd88aa811f" +dependencies = [ + "async-trait", + "bytes", + "camino", + "candid", + "console", + "icp-events", + "icp-project", + "semver", + "snafu", + "tokio", + "wasmtime", + "wasmtime-wasi", +] + +[[package]] +name = "icrc-cbor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90569d2894d9536c5416943556ac6339df249f06611b3c41029196b39e0dd119" +dependencies = [ + "candid", + "minicbor", + "num-bigint", + "num-traits", +] + +[[package]] +name = "icrc-ledger-types" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "263acc94fa47008ad00f76b8d3694efe7f8361870c9ec92cddcc588b571518b7" +dependencies = [ + "base32", + "candid", + "crc32fast", + "hex", + "ic-stable-structures", + "icrc-cbor", + "minicbor", + "num-bigint", + "num-traits", + "serde", + "serde_bytes", + "sha2 0.10.9", + "strum 0.26.3", + "strum_macros 0.26.4", + "time", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1792,7 +2408,7 @@ dependencies = [ [[package]] name = "imcp2" -version = "0.6.1" +version = "0.6.0" dependencies = [ "anyhow", "axum", @@ -1847,6 +2463,34 @@ dependencies = [ "wat", ] +[[package]] +name = "imcp2-deploy" +version = "0.6.1" +dependencies = [ + "async-trait", + "base64 0.22.1", + "camino", + "candid", + "flate2", + "hex", + "ic-agent", + "icp-canister-interfaces", + "icp-events", + "icp-project", + "icp-sync-plugin", + "imcp2-core", + "rmcp", + "serde", + "serde_json", + "sha2 0.11.0", + "tar", + "tempfile", + "tokio", + "tracing", + "url", + "wat", +] + [[package]] name = "imcp2-local" version = "0.6.1" @@ -1884,6 +2528,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1" +dependencies = [ + "rustversion", +] + [[package]] name = "instant" version = "0.1.13" @@ -1893,6 +2546,28 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "io-extras" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" +dependencies = [ + "io-lifetimes 3.0.1", + "windows-sys 0.60.2", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "io-lifetimes" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" + [[package]] name = "ipnet" version = "2.12.0" @@ -1927,12 +2602,41 @@ dependencies = [ "either", ] +[[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 = "ittapi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b996fe614c41395cdaedf3cf408a9534851090959d90d54a535f675550b64b1" +dependencies = [ + "anyhow", + "ittapi-sys", + "log", +] + +[[package]] +name = "ittapi-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5385394064fa2c886205dba02598013ce83d3e92d33dbdc0c52fe0e7bf4fc" +dependencies = [ + "cc", +] + [[package]] name = "jni" version = "0.22.4" @@ -1960,7 +2664,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.117", ] [[package]] @@ -1979,7 +2683,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2035,7 +2739,7 @@ dependencies = [ "ascii-canvas", "bit-set", "ena", - "itertools", + "itertools 0.11.0", "lalrpop-util", "petgraph", "pico-args", @@ -2081,6 +2785,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.17" @@ -2144,7 +2854,7 @@ dependencies = [ "proc-macro2", "quote", "regex-syntax", - "syn", + "syn 2.0.117", ] [[package]] @@ -2162,6 +2872,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + [[package]] name = "matchers" version = "0.2.0" @@ -2177,12 +2893,27 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + [[package]] name = "memchr" version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "memfd" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57804b2c9b69967f1536a56f86297e367a33b19e98852ed624b84551cdbc0d90" +dependencies = [ + "rustix 1.1.4", +] + [[package]] name = "merlin" version = "3.0.0" @@ -2211,6 +2942,26 @@ dependencies = [ "unicase", ] +[[package]] +name = "minicbor" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7005aaf257a59ff4de471a9d5538ec868a21586534fff7f85dd97d4043a6139" +dependencies = [ + "minicbor-derive", +] + +[[package]] +name = "minicbor-derive" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1154809406efdb7982841adb6311b3d095b46f78342dd646736122fe6b19e267" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2232,6 +2983,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -2295,6 +3052,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2306,6 +3064,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "object" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd229a0361b9d0d4396176e02d65897f487eebeab7caa6d443855ee152ca0b9c" +dependencies = [ + "crc32fast", + "hashbrown 0.17.1", + "indexmap", + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2402,6 +3172,15 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +dependencies = [ + "camino", +] + [[package]] name = "pem" version = "3.0.6" @@ -2457,7 +3236,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2511,6 +3290,12 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + [[package]] name = "pocket-ic" version = "15.0.0" @@ -2523,7 +3308,7 @@ dependencies = [ "flate2", "hex", "ic-certification", - "ic-management-canister-types", + "ic-management-canister-types 0.5.0", "ic-transport-types 0.45.0", "reqwest 0.12.28", "schemars 0.8.22", @@ -2533,8 +3318,8 @@ dependencies = [ "serde_json", "sha2 0.10.9", "slog", - "strum", - "strum_macros", + "strum 0.26.3", + "strum_macros 0.26.4", "tempfile", "thiserror 2.0.18", "tokio", @@ -2544,6 +3329,18 @@ dependencies = [ "wslpath", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2592,7 +3389,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -2682,6 +3479,29 @@ dependencies = [ "cc", ] +[[package]] +name = "pulley-interpreter" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd3de43f4f4cff536de8902097edbdf749321b97dda548f36a37918eb85bba3" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", + "wasmtime-internal-core", +] + +[[package]] +name = "pulley-macros" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8319a8fa5269b4895957f8795861c42822bd26218e495f70060a963323d94847" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "quinn" version = "0.11.9" @@ -2822,6 +3642,26 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2870,7 +3710,22 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "regalloc2" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757712e8e61590d6d4f5d563483755538b5aa13467837a3b41cd9832509a7f85" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.17.1", + "log", + "rustc-hash", + "serde", + "smallvec", ] [[package]] @@ -3053,9 +3908,15 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn", + "syn 2.0.117", ] +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3097,6 +3958,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix 1.1.4", +] + [[package]] name = "rustls" version = "0.23.40" @@ -3223,10 +4094,12 @@ checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "chrono", "dyn-clone", + "indexmap", "ref-cast", "schemars_derive 1.2.1", "serde", "serde_json", + "url", ] [[package]] @@ -3238,7 +4111,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn", + "syn 2.0.117", ] [[package]] @@ -3250,7 +4123,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn", + "syn 2.0.117", ] [[package]] @@ -3353,7 +4226,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3364,7 +4237,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3399,7 +4272,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3411,6 +4284,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3423,6 +4305,19 @@ dependencies = [ "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 = "sha2" version = "0.9.9" @@ -3467,6 +4362,16 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shellwords" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e515aa4699a88148ed5ef96413ceef0048ce95b43fbc955a33bde0a70fcae6" +dependencies = [ + "lazy_static", + "regex", +] + [[package]] name = "shlex" version = "2.0.1" @@ -3539,11 +4444,44 @@ dependencies = [ "serde_core", ] +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "snafu" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e45cb604038abb7b926b679887b3226d8d0f23874b66623625a0454be425a4b7" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287f59010008f0d7cf5e3b03196d666c1acc46c8d3e9cf34c28a1a7157601e72" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "socket2" @@ -3633,7 +4571,16 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -3646,7 +4593,19 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -3667,6 +4626,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -3695,9 +4665,26 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", ] +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tempfile" version = "3.27.0" @@ -3757,7 +4744,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3768,7 +4755,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3869,7 +4856,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3913,11 +4900,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", + "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_edit 0.22.27", ] +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.2", + "toml_datetime 1.1.2+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -3936,6 +4938,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -3944,7 +4955,7 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", "serde", - "serde_spanned", + "serde_spanned 0.6.9", "toml_datetime 0.6.11", "winnow 0.7.15", ] @@ -3964,18 +4975,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow 1.0.4", ] [[package]] name = "toml_writer" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" [[package]] name = "tower" @@ -4071,7 +5082,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4187,6 +5198,12 @@ 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" @@ -4203,6 +5220,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -4325,7 +5343,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -4338,6 +5356,23 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-compose" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27dc71f96d21a8b9153da9005c603f4f20d5781b7e6d2675ea0ca5def8713b75" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "log", + "petgraph", + "smallvec", + "wasm-encoder 0.258.3", + "wasmparser 0.258.3", + "wat", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -4350,12 +5385,22 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.258.0" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f64765e43c000fae008a48db8b288e175e237fc9ce20a26586836878c1bee89f" +dependencies = [ + "leb128fmt", + "wasmparser 0.258.3", +] + +[[package]] +name = "wasm-encoder" +version = "0.262.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e974fe6821a8cf64575d51ea2194e2c8f77e7b66e9afe7419ce8a97f9ee0d251" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" dependencies = [ "leb128fmt", - "wasmparser 0.258.0", + "wasmparser 0.262.0", ] [[package]] @@ -4370,6 +5415,18 @@ dependencies = [ "wasmparser 0.244.0", ] +[[package]] +name = "wasm-metadata" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e10808eb27efde692f1b1706089fe19296728bf872291f5b4e0d50fba88f2f" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder 0.258.3", + "wasmparser 0.258.3", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -4410,35 +5467,356 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.258.0" +version = "0.255.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e329ef4b5d46e73b91d3ac6924417cad55a8cbbf869c199283383427c3320b" +dependencies = [ + "bitflags", + "hashbrown 0.17.1", + "indexmap", + "semver", + "serde", +] + +[[package]] +name = "wasmparser" +version = "0.258.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9a61719f93a87b16d325921e251800c4833f8fab50fa21c7de73aed50086313" +checksum = "fe9269dd817e0e7021bf534ccd9761bc2d64778b6708e41228b8439d75eaa0b1" dependencies = [ "bitflags", + "hashbrown 0.17.1", + "indexmap", + "semver", + "serde", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "indexmap", + "semver", +] + +[[package]] +name = "wasmprinter" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14a457ba8670a3db3d357c1aeac3ed4fe378a76054a221789f0df0ea507e292b" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser 0.258.3", +] + +[[package]] +name = "wasmtime" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36abb25de5518707e5ba96b586366b09db168d2d4976cb9dac57265a317497ea" +dependencies = [ + "addr2line", + "async-trait", + "bitflags", + "bumpalo", + "bytes", + "cc", + "encoding_rs", + "futures", + "fxprof-processed-profile", + "gimli", + "ittapi", + "libc", + "log", + "mach2", + "memfd", + "object 0.40.0", + "once_cell", + "postcard", + "pulley-interpreter", + "rayon", + "rustix 1.1.4", + "semver", + "serde", + "serde_derive", + "serde_json", + "smallvec", + "target-lexicon", + "tempfile", + "wasm-compose", + "wasm-encoder 0.258.3", + "wasmparser 0.258.3", + "wasmtime-environ", + "wasmtime-internal-cache", + "wasmtime-internal-component-macro", + "wasmtime-internal-component-util", + "wasmtime-internal-core", + "wasmtime-internal-cranelift", + "wasmtime-internal-fiber", + "wasmtime-internal-jit-debug", + "wasmtime-internal-jit-icache-coherence", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", + "wat", + "windows-sys 0.61.2", + "wit-parser 0.258.3", +] + +[[package]] +name = "wasmtime-environ" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92f16a4d09fb1fbf2dcfeddb9818e1517a258e13b505b3bfdadcf47a19bf31ca" +dependencies = [ + "anyhow", + "cpp_demangle", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "hashbrown 0.17.1", "indexmap", + "log", + "object 0.40.0", + "postcard", + "rustc-demangle", "semver", + "serde", + "serde_derive", + "sha2 0.10.9", + "smallvec", + "target-lexicon", + "wasm-encoder 0.258.3", + "wasmparser 0.258.3", + "wasmprinter", + "wasmtime-internal-component-util", + "wasmtime-internal-core", +] + +[[package]] +name = "wasmtime-internal-cache" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96e4469e885ac50f8dc06337245f9905b22c68d55416267f9afd07f7f76687be" +dependencies = [ + "base64 0.22.1", + "directories-next", + "log", + "postcard", + "rustix 1.1.4", + "serde", + "serde_derive", + "sha2 0.10.9", + "toml 1.1.8+spec-1.1.0", + "wasmtime-environ", + "windows-sys 0.61.2", + "zstd", +] + +[[package]] +name = "wasmtime-internal-component-macro" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fca03bd4425856574c0807d1dee16fad43f947feaf4e9ae1d2129596ee4de4" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasmtime-internal-component-util", + "wasmtime-internal-wit-bindgen", + "wit-parser 0.258.3", +] + +[[package]] +name = "wasmtime-internal-component-util" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4006009fc3598af4e895ab18c6c33d2c6b7c657b1bd0a755ca7ee1abef6f0c16" + +[[package]] +name = "wasmtime-internal-core" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "947a732ad659f42c10ec639706fc8cb1f068dd9a55eebde8a1f24dfd06dfa525" +dependencies = [ + "anyhow", + "hashbrown 0.17.1", + "libm", + "serde", +] + +[[package]] +name = "wasmtime-internal-cranelift" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfb8b455d524939e03df42e3e749f8e26e26ba2acd8b08fa00b3bda55686750" +dependencies = [ + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli", + "itertools 0.14.0", + "log", + "object 0.40.0", + "pulley-interpreter", + "smallvec", + "target-lexicon", + "thiserror 2.0.18", + "wasmparser 0.258.3", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-fiber" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd8aa8711f68b2cdd94c2b85ba5c8317edaa1ac4ee0a0534b957123680239307" +dependencies = [ + "cc", + "libc", + "rustix 1.1.4", + "wasmtime-environ", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-jit-debug" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e873e4504faf50aa8ce255d6ea7210d87b39d5f379c2486782e2c5f179c70ca8" +dependencies = [ + "cc", + "object 0.40.0", + "rustix 1.1.4", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-jit-icache-coherence" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "197ab1fda5da958a5c0b8a97eb241beafc48ac568b82ef4f12f38d6866c74173" +dependencies = [ + "libc", + "wasmtime-internal-core", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-unwinder" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5577f3a5a979a7c3b1b23c1e9f13e70a8583acc8828471ec56f282117fc4952b" +dependencies = [ + "cranelift-codegen", + "log", + "object 0.40.0", + "wasmtime-environ", +] + +[[package]] +name = "wasmtime-internal-versioned-export-macros" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6399c9c4050ed431cf66e21c05fadd6f9731db44476f452f8c7142b4597b883" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "wasmtime-internal-wit-bindgen" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4eaf45d4da7681d234bb6efd846b4a528b08a75384a7fc23f125d0145364b06" +dependencies = [ + "anyhow", + "bitflags", + "heck", + "indexmap", + "wit-component 0.258.3", + "wit-parser 0.258.3", +] + +[[package]] +name = "wasmtime-wasi" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "821e45765c35555d3b0bdf9277cb99478f7d1f29a17b3315ba76e01220f02a38" +dependencies = [ + "async-trait", + "bitflags", + "bytes", + "cap-primitives", + "futures", + "rand 0.10.1", + "rustix 1.1.4", + "rustix-linux-procfs", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "wasmtime", + "wasmtime-wasi-io", + "wiggle", + "windows-sys 0.61.2", + "winx", +] + +[[package]] +name = "wasmtime-wasi-io" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cdff38d1c730bdaf4fcfbe0eedf3265fb9c7a4deb119deecbfc1c1778950716" +dependencies = [ + "async-trait", + "bytes", + "futures", + "tracing", + "wasmtime", +] + +[[package]] +name = "wast" +version = "35.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef140f1b49946586078353a453a1d28ba90adfc54dde75710bc1931de204d68" +dependencies = [ + "leb128", ] [[package]] name = "wast" -version = "258.0.0" +version = "262.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97f7defc7ecca8b19ac7f824598eadd0c53985ee00c74060d65051e9da5b58a1" +checksum = "30612a0c29249b5f185888a107e8a519d920226b7d07f9d351fd094e9f75eb23" dependencies = [ "bumpalo", "leb128fmt", "memchr", "unicode-width 0.2.2", - "wasm-encoder 0.258.0", + "wasm-encoder 0.262.0", ] [[package]] name = "wat" -version = "1.258.0" +version = "1.262.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7555c008cca87f2ac58d9f83ccda7e7b44611093ce28eb28f052e7c78024b9bf" +checksum = "e228027f2b07bbaf946980ebbe2437fca73be6f54c40c2a8d4f42f277075f8d1" dependencies = [ - "wast", + "wast 262.0.0", ] [[package]] @@ -4479,6 +5857,46 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "wiggle" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3872869836d404f722e2361f869c09630e43a73fcc7060f7892a929b74b6a3" +dependencies = [ + "bitflags", + "thiserror 2.0.18", + "tracing", + "wasmtime", + "wasmtime-environ", + "wiggle-macro", +] + +[[package]] +name = "wiggle-generate" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf7e31dec6de37e7e848aecbad54777dd246cd440a1a547ab1a1e31c27f55e49" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasmtime-environ", + "witx", +] + +[[package]] +name = "wiggle-macro" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2af323f33b28ea84b24e2bccef19e51c7a75aa1d2929280ad1a4865f2877dcc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "wiggle-generate", +] + [[package]] name = "winapi" version = "0.3.9" @@ -4531,7 +5949,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4542,7 +5960,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4740,6 +6158,26 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +[[package]] +name = "winreg" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags", + "windows-sys 0.52.0", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -4763,7 +6201,7 @@ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", "heck", - "wit-parser", + "wit-parser 0.244.0", ] [[package]] @@ -4776,10 +6214,10 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", - "wasm-metadata", + "syn 2.0.117", + "wasm-metadata 0.244.0", "wit-bindgen-core", - "wit-component", + "wit-component 0.244.0", ] [[package]] @@ -4792,7 +6230,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -4811,9 +6249,28 @@ dependencies = [ "serde_derive", "serde_json", "wasm-encoder 0.244.0", - "wasm-metadata", + "wasm-metadata 0.244.0", "wasmparser 0.244.0", - "wit-parser", + "wit-parser 0.244.0", +] + +[[package]] +name = "wit-component" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fca88950af0c1f42bc77e17315e48798b8d68340171b6d204f62bc19a878a6" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.258.3", + "wasm-metadata 0.258.3", + "wasmparser 0.258.3", + "wit-parser 0.258.3", ] [[package]] @@ -4834,6 +6291,37 @@ dependencies = [ "wasmparser 0.244.0", ] +[[package]] +name = "wit-parser" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2127663dcf3ee16614fb0e3c33d5c6c66fec9dad317a8b0fdd3869487a533" +dependencies = [ + "anyhow", + "hashbrown 0.17.1", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser 0.258.3", +] + +[[package]] +name = "witx" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e366f27a5cabcddb2706a78296a40b8fcc451e1a6aba2fc1d94b4a01bdaaef4b" +dependencies = [ + "anyhow", + "log", + "thiserror 1.0.69", + "wast 35.0.2", +] + [[package]] name = "writeable" version = "0.6.3" @@ -4846,6 +6334,16 @@ version = "0.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04a2ecdf2cc4d33a6a93d71bcfbc00bb1f635cdb8029a2cc0709204a045ec7a3" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + [[package]] name = "yoke" version = "0.8.3" @@ -4865,7 +6363,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -4886,7 +6384,7 @@ checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4906,7 +6404,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -4927,7 +6425,7 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4960,11 +6458,45 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.2+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 7d0dab9..eda6775 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "imcp2" -version = "0.6.1" +version = "0.6.0" edition = "2021" description = "Minimal MCP server that bridges an LLM to the Internet Computer, speaking textual Candid." license = "Apache-2.0" @@ -33,7 +33,7 @@ exclude = [ ] [workspace] -members = [".", "crates/imcp2-core", "crates/imcp2-local"] +members = [".", "crates/imcp2-core", "crates/imcp2-deploy", "crates/imcp2-local"] # The hosted server releases to crates.io (publish-crate.yml), never as dist # binaries — dist releases only imcp2-local (see dist-workspace.toml). @@ -45,7 +45,7 @@ dist = false # imcp2-local. [workspace.dependencies] httpdate = "1.0.3" -imcp2-core = { path = "crates/imcp2-core", version = "0.6.1" } +imcp2-core = { path = "crates/imcp2-core", version = "0.6.0" } rmcp = "2.1" axum = "0.8" anyhow = "1" diff --git a/crates/imcp2-deploy/Cargo.toml b/crates/imcp2-deploy/Cargo.toml new file mode 100644 index 0000000..84f0891 --- /dev/null +++ b/crates/imcp2-deploy/Cargo.toml @@ -0,0 +1,62 @@ +[package] +name = "imcp2-deploy" +version = "0.6.1" +edition = "2021" +description = "The imcp2 `icp_deploy_bundle` tool: deploys an icp project bundle through the icp CLI's own project library." +license = "Apache-2.0" +repository = "https://github.com/dfinity/imcp2" +# NOT publishable, by construction: the deploy runs on `icp-project` (and its +# siblings) pulled straight from the icp-cli git repository — those crates are +# `publish = false` upstream, and crates.io refuses any crate with a git +# dependency. That is why this is its own crate rather than a module of +# `imcp2-core`, and why only `imcp2-local` (released as dist binaries, never +# published) composes it: `imcp2-core` and `imcp2` keep publishing untouched. +publish = false + +[dependencies] +# The session engine (which Internet Identity session a call acts as, and +# the management identity minted from it) and the `SessionResolver` seam. +imcp2-core = { workspace = true } +rmcp = { workspace = true, features = ["server", "macros"] } +ic-agent = { workspace = true } +candid = { workspace = true } +# `rt-multi-thread`: the sync-plugin runtime runs a wasm component on a +# blocking thread (`block_in_place`), which only a multi-thread runtime has. +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "fs"] } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +base64 = { workspace = true } +sha2 = { workspace = true } +hex = { workspace = true } +url = { workspace = true } +async-trait = "0.1" +# `icp-project` spells every path as a UTF-8 path. +camino = "1" +# A bundle is the `.tar.gz` that `icp project bundle` writes. +tar = "0.4" +flate2 = "1" +# Each deploy extracts its bundle into a scratch directory that is removed +# when the deploy ends, whichever way it ends. +tempfile = "3" + +# The icp CLI's project library and the seams it is driven through, pinned to +# one commit so the four stay in lockstep (they share a workspace version and +# are not released independently). Bump the `rev` on all four together. +# +# * icp-project: manifests, the deploy operation, the id/artifact stores. +# * icp-events: the progress event stream the operations report on. +# * icp-sync-plugin: the wasmtime runtime that runs a canister's sync plugin +# (the asset uploads of a frontend canister). The heavy one — it is what +# brings wasmtime into this workspace. +# * icp-canister-interfaces: the engine-canister registry id a created +# canister's subnet is looked up in. +icp-project = { git = "https://github.com/dfinity/icp-cli", rev = "f4c7b6627cab56b091c01cc18102f5dd88aa811f" } +icp-events = { git = "https://github.com/dfinity/icp-cli", rev = "f4c7b6627cab56b091c01cc18102f5dd88aa811f" } +icp-sync-plugin = { git = "https://github.com/dfinity/icp-cli", rev = "f4c7b6627cab56b091c01cc18102f5dd88aa811f" } +icp-canister-interfaces = { git = "https://github.com/dfinity/icp-cli", rev = "f4c7b6627cab56b091c01cc18102f5dd88aa811f" } + +[dev-dependencies] +# The tests hand-build small bundles (a manifest plus a wasm) and load them +# through the same host the tool uses. +wat = "1" diff --git a/crates/imcp2-deploy/src/host.rs b/crates/imcp2-deploy/src/host.rs new file mode 100644 index 0000000..f5afc2d --- /dev/null +++ b/crates/imcp2-deploy/src/host.rs @@ -0,0 +1,964 @@ +//! The [`Host`] a bundle is deployed from: the project library's seams, each +//! implemented for an extracted bundle in a scratch directory. +//! +//! A bundle is the self-contained archive `icp project bundle` writes: the +//! project manifest rewritten so every canister is a pre-built wasm the +//! archive carries, the networks and environments inlined, and each sync +//! plugin's wasm and input files alongside. That shape is what makes the seams +//! small: nothing is built from source, no wasm is fetched over HTTP, no +//! recipe is resolved, and the only network a bundle targets from here is the +//! Internet Computer itself. +//! +//! The archive is untrusted input: it arrives in a tool call, and the agent +//! that passes it may have fetched it from anywhere. So the shape is not +//! assumed but enforced, in three places: +//! +//! * **Extraction** writes only regular files and directories, within a total +//! size budget, under the scratch directory — and keeps the list of what it +//! wrote ([`extract_bundle`]). +//! * **The filesystem the library reads through is that list**, not the disk +//! ([`BundleFs`]). A path the manifest names is normalized against the +//! bundle root (`..` cannot climb above it, an absolute path is re-anchored +//! at it) and looked up in the index; only a path the archive carried is +//! read, from where extraction put it. Nothing on the machine is reachable +//! by any spelling. The disk is the byte store because the sync-plugin +//! runtime needs real paths to mount. +//! * **The bundle is checked before any phase runs** ([`check_bundle`]): every +//! canister built by one pre-built wasm in the archive that hashes to its +//! recorded checksum, every sync plugin in the archive, no script steps +//! (which the library would run as host subprocesses; [`NoScripts`] refuses +//! them again at the seams, as a backstop), and no network but `ic`. +//! +//! Everything the project library reads or writes — the manifest, the wasms, +//! the `.icp` id and artifact stores, the build scratch — lives under the +//! extracted directory, which is removed when the deploy ends. + +use std::{ + collections::BTreeSet, + fmt, + io::Read, + sync::{Arc, Mutex, PoisonError}, +}; + +use async_trait::async_trait; +use camino::{Utf8Component, Utf8Path, Utf8PathBuf}; +use icp_events::StepReporter; +use icp_project::{ + canister::{ + build::{Build, BuildError, Builder, Params as BuildParams}, + recipe::{Fetched, Resolve, ResolveError}, + sync::{ + script::{ScriptInvocation, ScriptRunError, ScriptRunner}, + Syncer, + }, + wasm::{Fetch, FetchError}, + }, + files::{FileSystem, FsError, Scratch}, + host::{Host, Ignore}, + manifest::{ + prebuilt::SourceField, BuildStep, ProjectRootLocate, ProjectRootLocateImpl, SyncStep, + PROJECT_MANIFEST, + }, + network::{ + Access, AccessError, CollectFriendlyDomains, Configuration, NetworkAccess, NetworkUrls, + RootKeySource, RootKeySpec, + }, + prelude::IC_ROOT_KEY, + random::HostRandom, + store_artifact::ArtifactStore, + store_id::AccessImpl, + Canister, Lazy, Network, Project, ProjectLoadImpl, +}; +use sha2::{Digest, Sha256}; + +/// Archives above this are refused before anything is written: a bundle rides +/// in a tool call (or a file the call names), and a runaway input must not +/// fill the disk or the heap. +pub const MAX_BUNDLE_BYTES: usize = 256 * 1024 * 1024; + +/// What an archive may extract to, in total. Gzip expands by up to about a +/// thousand to one, so the archive limit alone bounds nothing on disk; this +/// one does, on the sum of the entries' sizes and on the decompressed stream +/// itself. +pub const MAX_EXTRACTED_BYTES: u64 = 1024 * 1024 * 1024; + +/// The directory under the project root where icp-project keeps its own +/// state (`.icp/data/mappings/.ids.json`, build artifacts). The deploy +/// seeds it from the caller's mapping; the archive may not supply it. +const STATE_DIR: &str = ".icp"; + +/// A plain error for the seams below: each carries one sentence, and the +/// library wraps it whole at its own boundary (`AccessError::new` and the +/// like), so the sentence is what the user reads. +#[derive(Debug)] +pub struct Unsupported(pub String); + +impl fmt::Display for Unsupported { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for Unsupported {} + +// ── The network ─────────────────────────────────────────────────────────── + +/// Network resolution for a bundle: the Internet Computer, and nothing else. +/// +/// A bundle's manifest inlines its networks, and a manifest may declare any: +/// a managed one the icp CLI would launch, a connected one with a root key to +/// fetch or a key spelled out. This server deploys to mainnet only, so the +/// one configuration honoured is a connected network with the pinned mainnet +/// root key — which is what every manifest's `ic` is. [`check_bundle`] +/// refuses the rest before any phase runs; this is the seam's own refusal, +/// for the library's own calls. +pub struct IcMainnet; + +impl IcMainnet { + /// The connected configuration of `network`, if it is mainnet. + pub fn mainnet(network: &Network) -> Result<&icp_project::network::Connected, Unsupported> { + match &network.configuration { + Configuration::Connected { connected } + if matches!(connected.root_key, RootKeySpec::Mainnet) => + { + Ok(connected) + } + _ => Err(Unsupported(format!( + "network '{}' is not the Internet Computer; this server deploys to the `ic` \ + network only. Deploy to the `ic` environment, or deploy elsewhere with `icp \ + deploy`", + network.name + ))), + } + } +} + +#[async_trait] +impl Access for IcMainnet { + async fn access(&self, network: &Network) -> Result { + let connected = Self::mainnet(network).map_err(AccessError::new)?; + Ok(NetworkAccess { + root_key: IC_ROOT_KEY.to_vec(), + root_key_source: RootKeySource::Mainnet, + api_url: connected.api_url.clone(), + http_gateway_url: connected.http_gateway_url.clone(), + use_friendly_domains: false, + }) + } + + async fn urls(&self, network: &Network) -> Result { + let connected = Self::mainnet(network).map_err(AccessError::new)?; + Ok(NetworkUrls { + api_url: connected.api_url.clone(), + http_gateway_url: connected.http_gateway_url.clone(), + }) + } + + /// Friendly domains are a managed-network convenience; there is no + /// managed network here to publish them to. + async fn publish_friendly_domains( + &self, + _network: &Network, + _collect: &CollectFriendlyDomains<'_>, + ) { + } +} + +// ── The filesystem ──────────────────────────────────────────────────────── + +/// The extracted bundle, as the filesystem the project library reads through. +/// +/// What the archive carried is an index of relative paths, filled in by +/// [`extract_bundle`] as it writes each entry. Every path the library asks +/// about is [normalized](BundleFs::normalize) against the bundle root and +/// answered from that index: present, it is read from under the root; absent, +/// it is "not in the bundle", whatever the disk holds at that spelling. So a +/// manifest's `path:` — absolute, climbing, or plain — can name only what the +/// archive brought with it. +/// +/// The one place a write may land is a scratch directory this filesystem made +/// (a pre-built build step copies its module there and the operation reads it +/// back); written files join the index and leave it with the directory. +#[derive(Clone, Debug)] +pub struct BundleFs(Arc); + +#[derive(Debug)] +struct Inner { + /// Canonical, so the absolute paths the library derives from it strip + /// back to the index's relative spellings whatever the scratch directory's + /// own path goes through. + root: Utf8PathBuf, + state: Mutex, +} + +#[derive(Debug, Default)] +struct State { + /// Every file in the bundle, relative to the root. + files: BTreeSet, + /// Scratch directories currently held, relative to the root: the prefixes + /// a write is allowed under. + scratch: BTreeSet, +} + +impl Inner { + fn state(&self) -> std::sync::MutexGuard<'_, State> { + self.state.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +impl State { + fn is_dir(&self, rel: &Utf8Path) -> bool { + rel.as_str().is_empty() + || self.scratch.contains(rel) + || self.files.iter().chain(&self.scratch).any(|p| p != rel && p.starts_with(rel)) + } + + fn in_scratch(&self, rel: &Utf8Path) -> bool { + self.scratch.iter().any(|s| rel != s && rel.starts_with(s)) + } +} + +/// Why the bundle could not serve a request. Wrapped in [`FsError`] at the +/// trait boundary, which displays it as itself. +#[derive(Debug)] +pub enum BundleFsError { + NotInBundle(Utf8PathBuf), + ReadOnly(Utf8PathBuf), + Io(Utf8PathBuf, std::io::Error), +} + +impl fmt::Display for BundleFsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotInBundle(p) => write!( + f, + "'{p}' is not in the bundle; a bundle carries every file its manifest names \ + (`icp project bundle` writes one that does)" + ), + Self::ReadOnly(p) => write!(f, "cannot write '{p}': a bundle is read-only"), + Self::Io(p, e) => write!(f, "'{p}': {e}"), + } + } +} + +impl std::error::Error for BundleFsError {} + +impl From for FsError { + fn from(e: BundleFsError) -> Self { + FsError::new(e) + } +} + +impl BundleFs { + /// An empty filesystem rooted at `dir`, which must exist. + pub fn new(dir: &Utf8Path) -> Result { + let root = std::fs::canonicalize(dir) + .map_err(|e| format!("cannot resolve '{dir}': {e}")) + .and_then(|p| { + Utf8PathBuf::from_path_buf(p).map_err(|p| format!("'{}' is not UTF-8", p.display())) + })?; + Ok(Self(Arc::new(Inner { root, state: Mutex::default() }))) + } + + /// The directory the bundle is extracted into. + pub fn root(&self) -> &Utf8Path { + &self.0.root + } + + /// Record a file extraction wrote, by its path in the archive. + fn index(&self, archive_path: &Utf8Path) { + self.0.state().files.insert(self.normalize(archive_path)); + } + + /// Resolve `path` to the relative spelling the index is keyed by: under + /// the root, with `.` dropped and `..` applied but never climbing above the + /// root, and an absolute path from elsewhere re-anchored at the root (so + /// it names what the bundle would hold there, which is nothing). + pub fn normalize(&self, path: &Utf8Path) -> Utf8PathBuf { + let path = path.strip_prefix(&self.0.root).unwrap_or(path); + let mut out = Utf8PathBuf::new(); + for component in path.components() { + match component { + Utf8Component::Normal(part) => out.push(part), + Utf8Component::ParentDir => { + out.pop(); + } + Utf8Component::CurDir | Utf8Component::RootDir | Utf8Component::Prefix(_) => {} + } + } + out + } + + /// Whether the bundle holds a file at `path`. + pub fn contains(&self, path: &Utf8Path) -> bool { + self.0.state().files.contains(&self.normalize(path)) + } + + /// Where a file in the bundle is on disk, for a runtime that needs a real + /// path, or "not in the bundle". + pub fn locate(&self, path: &Utf8Path) -> Result { + let rel = self.normalize(path); + if self.0.state().files.contains(&rel) { + Ok(self.0.root.join(rel)) + } else { + Err(BundleFsError::NotInBundle(rel)) + } + } + + fn read_bytes(&self, path: &Utf8Path) -> Result, BundleFsError> { + let on_disk = self.locate(path)?; + std::fs::read(&on_disk).map_err(|e| BundleFsError::Io(self.normalize(path), e)) + } + + fn write_bytes(&self, path: &Utf8Path, contents: &[u8]) -> Result<(), BundleFsError> { + let rel = self.normalize(path); + let mut state = self.0.state(); + if !state.in_scratch(&rel) { + return Err(BundleFsError::ReadOnly(rel)); + } + let on_disk = self.0.root.join(&rel); + if let Some(parent) = on_disk.parent() { + std::fs::create_dir_all(parent).map_err(|e| BundleFsError::Io(rel.clone(), e))?; + } + std::fs::write(&on_disk, contents).map_err(|e| BundleFsError::Io(rel.clone(), e))?; + state.files.insert(rel); + Ok(()) + } +} + +/// A scratch directory under the root: on disk for as long as this is held, +/// and in the index for exactly as long. +struct ScratchDir { + _dir: tempfile::TempDir, + rel: Utf8PathBuf, + path: Utf8PathBuf, + fs: Arc, +} + +impl Scratch for ScratchDir { + fn path(&self) -> &Utf8Path { + &self.path + } +} + +impl Drop for ScratchDir { + fn drop(&mut self) { + let mut state = self.fs.state(); + state.scratch.remove(&self.rel); + state.files.retain(|f| !f.starts_with(&self.rel)); + } +} + +#[async_trait] +impl FileSystem for BundleFs { + async fn read(&self, path: &Utf8Path) -> Result, FsError> { + Ok(self.read_bytes(path)?) + } + + async fn read_to_string(&self, path: &Utf8Path) -> Result { + let bytes = self.read_bytes(path)?; + String::from_utf8(bytes).map_err(|e| { + FsError::new(BundleFsError::Io(self.normalize(path), std::io::Error::other(e))) + }) + } + + async fn write(&self, path: &Utf8Path, contents: &[u8]) -> Result<(), FsError> { + Ok(self.write_bytes(path, contents)?) + } + + /// A directory is implied by the files beneath it, so in a scratch area + /// there is nothing to create; anywhere else there is nothing that may be. + async fn create_dir_all(&self, path: &Utf8Path) -> Result<(), FsError> { + let rel = self.normalize(path); + let state = self.0.state(); + if state.scratch.contains(&rel) || state.in_scratch(&rel) { + return Ok(()); + } + Err(BundleFsError::ReadOnly(rel).into()) + } + + async fn copy(&self, from: &Utf8Path, to: &Utf8Path) -> Result<(), FsError> { + let contents = self.read_bytes(from)?; + Ok(self.write_bytes(to, &contents)?) + } + + async fn exists(&self, path: &Utf8Path) -> bool { + let rel = self.normalize(path); + let state = self.0.state(); + state.files.contains(&rel) || state.is_dir(&rel) + } + + async fn is_file(&self, path: &Utf8Path) -> bool { + self.contains(path) + } + + async fn is_dir(&self, path: &Utf8Path) -> bool { + self.0.state().is_dir(&self.normalize(path)) + } + + async fn read_dir(&self, path: &Utf8Path) -> Result, FsError> { + let prefix = self.normalize(path); + let state = self.0.state(); + let mut children: Vec = state + .files + .iter() + .chain(&state.scratch) + .filter_map(|entry| entry.strip_prefix(&prefix).ok()) + .filter_map(|relative| relative.components().next()) + .map(|first| self.0.root.join(&prefix).join(first.as_str())) + .collect(); + children.sort(); + children.dedup(); + Ok(children) + } + + /// Nothing in the bundle is a symlink and every path is normalized on the + /// way in, so identity is the normalized path, under the root. + async fn canonicalize(&self, path: &Utf8Path) -> Option { + let rel = self.normalize(path); + self.exists(&rel).await.then(|| self.0.root.join(rel)) + } + + async fn scratch_dir(&self) -> Result, FsError> { + let dir = tempfile::Builder::new() + .prefix(".scratch-") + .tempdir_in(&self.0.root) + .map_err(|e| FsError::new(BundleFsError::Io(".scratch".into(), e)))?; + let path = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()) + .map_err(|p| FsError::new(Unsupported(format!("'{}' is not UTF-8", p.display()))))?; + let rel = self.normalize(&path); + self.0.state().scratch.insert(rel.clone()); + Ok(Box::new(ScratchDir { _dir: dir, rel, path, fs: self.0.clone() })) + } +} + +// ── Wasms, recipes, scripts ─────────────────────────────────────────────── + +/// Wasm resolution for a bundle: the archive carries every module it names, +/// so a source is a path inside the bundle (verified against its recorded +/// checksum) and a remote one is a bundle this tool does not understand. +pub struct BundledWasm(pub BundleFs); + +#[async_trait] +impl Fetch for BundledWasm { + async fn wasm( + &self, + source: &SourceField, + base_dir: &Utf8Path, + sha256: Option<&str>, + reporter: &StepReporter, + ) -> Result { + match source { + SourceField::Local(local) => { + let path = base_dir.join(&local.path); + let on_disk = self.0.locate(&path).map_err(FetchError::new)?; + if let Some(expected) = sha256 { + reporter.info(format!("Reading wasm: {}", local.path)); + let bytes = self.0.read_bytes(&path).map_err(FetchError::new)?; + reporter.info("Verifying checksum"); + verify_checksum(&self.0.normalize(&path), expected, &bytes) + .map_err(FetchError::new)?; + } + Ok(on_disk) + } + SourceField::Remote(_) => Err(FetchError::new(Unsupported( + "a bundle carries every wasm it names, so a remote wasm source is not \ + expected here; rebuild the bundle with `icp project bundle`" + .into(), + ))), + } + } +} + +fn verify_checksum(path: &Utf8Path, expected: &str, bytes: &[u8]) -> Result<(), Unsupported> { + let actual = hex::encode(Sha256::digest(bytes)); + if actual.eq_ignore_ascii_case(expected) { + Ok(()) + } else { + Err(Unsupported(format!( + "checksum mismatch for '{path}': the manifest records {expected}, the file hashes \ + to {actual}" + ))) + } +} + +/// Recipe resolution for a bundle: a bundled manifest spells every canister +/// out as explicit pre-built steps, so a recipe reference means the archive +/// is not a bundle. +pub struct NoRecipes; + +#[async_trait] +impl Resolve for NoRecipes { + async fn resolve( + &self, + recipe: &icp_project::manifest::recipe::Recipe, + ) -> Result { + Err(ResolveError::new(Unsupported(format!( + "the manifest references recipe '{recipe:?}', which a bundle never does; rebuild \ + it with `icp project bundle`" + )))) + } +} + +/// Build and sync for a bundle: pre-built steps and plugin steps run as they +/// do under the CLI, script steps are refused. +/// +/// [`check_bundle`] catches a script step before any phase runs; this is the +/// backstop at the seam the library would run one through, so a step the +/// check missed still cannot reach a shell. +pub struct NoScripts { + prebuilt: Builder, +} + +impl NoScripts { + fn script_refused(what: &str) -> Unsupported { + Unsupported(format!( + "the manifest declares a script {what} step, which a bundle never has and this \ + server does not run; rebuild the bundle with `icp project bundle`" + )) + } +} + +#[async_trait] +impl Build for NoScripts { + async fn build( + &self, + step: &BuildStep, + params: &BuildParams, + reporter: &StepReporter, + ) -> Result<(), BuildError> { + match step { + BuildStep::Prebuilt(_) => self.prebuilt.build(step, params, reporter).await, + BuildStep::Script(_) => Err(BuildError::new(Self::script_refused("build"))), + } + } +} + +#[async_trait] +impl ScriptRunner for NoScripts { + async fn run_script( + &self, + _invocation: ScriptInvocation, + _reporter: &StepReporter, + ) -> Result, ScriptRunError> { + Err(ScriptRunError { source: Box::new(Self::script_refused("sync")) }) + } +} + +// ── The check, the host, the extraction ─────────────────────────────────── + +/// Hold an environment's canisters to the shape a bundle has — before any +/// phase runs, so a bundle that would fail at its build or sync phase, or +/// be refused there, does not first spend cycles creating canisters. +/// +/// Each canister is built by exactly one pre-built step naming a wasm in the +/// archive that hashes to the checksum the manifest records; each sync step +/// is a plugin in the archive (hashing to its checksum, if one is recorded); +/// no step is a script. The environment's network is the Internet Computer. +pub fn check_bundle<'a>( + fs: &BundleFs, + network: &Network, + canisters: impl IntoIterator, +) -> Result<(), String> { + IcMainnet::mainnet(network).map_err(|e| e.0)?; + for (dir, canister) in canisters { + let name = &canister.name; + let at = |e: Unsupported| format!("canister '{name}': {e}"); + let present = |path: &Utf8Path| -> Result, String> { + fs.read_bytes(&dir.join(path)).map_err(|e| format!("canister '{name}': {e}")) + }; + + if canister.build.steps.iter().any(|s| matches!(s, BuildStep::Script(_))) { + return Err(at(NoScripts::script_refused("build"))); + } + if canister.sync.steps.iter().any(|s| matches!(s, SyncStep::Script(_))) { + return Err(at(NoScripts::script_refused("sync"))); + } + let [BuildStep::Prebuilt(adapter)] = canister.build.steps.as_slice() else { + return Err(at(Unsupported(format!( + "built by {} steps where a bundle has exactly one pre-built step; rebuild the \ + bundle with `icp project bundle`", + canister.build.steps.len() + )))); + }; + let SourceField::Local(local) = &adapter.source else { + return Err(at(Unsupported( + "built from a remote wasm source, which a bundle never is; rebuild the bundle \ + with `icp project bundle`" + .into(), + ))); + }; + let wasm = present(&local.path)?; + let Some(expected) = adapter.sha256.as_deref() else { + return Err(at(Unsupported(format!( + "the wasm '{}' carries no sha256, which a bundle always records; rebuild the \ + bundle with `icp project bundle`", + local.path + )))); + }; + verify_checksum(&local.path, expected, &wasm).map_err(at)?; + + for step in &canister.sync.steps { + let SyncStep::Plugin(plugin) = step else { continue }; + let SourceField::Local(local) = &plugin.source else { + return Err(at(Unsupported( + "synced by a remote plugin, which a bundle never is; rebuild the bundle \ + with `icp project bundle`" + .into(), + ))); + }; + let bytes = present(&local.path)?; + if let Some(expected) = plugin.sha256.as_deref() { + verify_checksum(&local.path, expected, &bytes).map_err(at)?; + } + } + } + Ok(()) +} + +/// The host for the bundle extracted into `fs`: its manifest is the project, +/// its `.icp` directory the id and artifact stores, and nothing outside it is +/// reachable. +pub fn host_for(fs: BundleFs) -> Host { + // The directory IS the project root: no climbing to a workspace above it. + let root = fs.root().to_path_buf(); + let locate: Arc = + Arc::new(ProjectRootLocateImpl::new(root.clone(), Some(root))); + let files: Arc = Arc::new(fs.clone()); + let wasm: Arc = Arc::new(BundledWasm(fs)); + let project = Lazy::<_, Project>::new(ProjectLoadImpl { + project_root_locate: locate.clone(), + recipe: Arc::new(NoRecipes), + files: files.clone(), + }); + let no_scripts = Arc::new(NoScripts { prebuilt: Builder::new(wasm.clone(), files.clone()) }); + Host { + project: Arc::new(project), + files, + ids: Arc::new(AccessImpl::new(locate.clone())), + artifacts: Arc::new(ArtifactStore::new(locate)), + builder: no_scripts.clone(), + syncer: Arc::new(Syncer::new( + no_scripts, + wasm.clone(), + Arc::new(icp_sync_plugin::Wasmtime), + )), + wasm, + network: Arc::new(IcMainnet), + random: Arc::new(HostRandom), + observer: Arc::new(Ignore), + } +} + +/// Extract a bundle archive into `into`, which must exist and be empty, and +/// return it as a filesystem indexed by what was written. +/// +/// Only regular files and directories are written — `icp project bundle` +/// writes nothing else, and a symlink or a device node is how an archive +/// reaches past its directory — and their sizes may not add up to more than +/// [`MAX_EXTRACTED_BYTES`]. `tar` refuses entry paths that would land outside +/// `into`. What it extracts is then required to look like a bundle, i.e. to +/// carry a project manifest at its root. +pub fn extract_bundle(bytes: &[u8], into: &Utf8Path) -> Result { + if bytes.len() > MAX_BUNDLE_BYTES { + return Err(format!( + "the bundle is {} bytes, above the {} byte limit", + bytes.len(), + MAX_BUNDLE_BYTES + )); + } + let fs = BundleFs::new(into)?; + let unreadable = + |e: std::io::Error| format!("the bundle is not a readable .tar.gz archive: {e}"); + let decoder = Limited { inner: flate2::read::GzDecoder::new(bytes), left: MAX_EXTRACTED_BYTES }; + let mut archive = tar::Archive::new(decoder); + let mut total: u64 = 0; + for entry in archive.entries().map_err(unreadable)? { + let mut entry = entry.map_err(unreadable)?; + let kind = entry.header().entry_type(); + let path = entry.path().map_err(unreadable)?.into_owned(); + let path = Utf8PathBuf::from_path_buf(path) + .map_err(|p| format!("the archive entry '{}' is not UTF-8", p.display()))?; + if !(kind.is_file() || kind.is_dir()) { + return Err(format!( + "the archive entry '{path}' is not a regular file or directory ({kind:?}), which \ + a bundle never carries; rebuild it with `icp project bundle`" + )); + } + // `.icp` is the library's own state under the project root: the id + // mappings a deploy reads to decide what exists, and the artifact + // store. A bundle never carries it (`icp project bundle` writes + // manifests, modules, plugins, argument files and images), and one + // that did could point a deploy at canisters the caller never named. + if path.components().next().is_some_and(|c| c.as_str() == STATE_DIR) { + return Err(format!( + "the archive entry '{path}' is under `{STATE_DIR}/`, the project state a bundle \ + never carries; rebuild it with `icp project bundle`" + )); + } + total = total.saturating_add(entry.size()); + if total > MAX_EXTRACTED_BYTES { + return Err(too_large()); + } + if entry.unpack_in(fs.root().as_std_path()).map_err(unreadable)? && kind.is_file() { + fs.index(&path); + } + } + if !fs.contains(Utf8Path::new(PROJECT_MANIFEST)) { + return Err(format!( + "the archive carries no `{PROJECT_MANIFEST}` at its root, so it is not an icp \ + project bundle (`icp project bundle` writes one)" + )); + } + Ok(fs) +} + +fn too_large() -> String { + format!("the bundle extracts to more than the {MAX_EXTRACTED_BYTES} byte limit") +} + +/// A reader that fails once more than `left` bytes have passed through it: +/// the decompressed stream's own bound, independent of what the entry +/// headers claim. +struct Limited { + inner: R, + left: u64, +} + +impl Read for Limited { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let n = self.inner.read(buf)?; + if n as u64 > self.left { + return Err(std::io::Error::other(too_large())); + } + self.left -= n as u64; + Ok(n) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use icp_project::network::{Connected, Managed}; + use std::io::Write; + + fn managed(name: &str) -> Network { + Network { + name: name.into(), + configuration: Configuration::Managed { managed: Managed::default() }, + } + } + + fn connected(name: &str, root_key: RootKeySpec) -> Network { + Network { + name: name.into(), + configuration: Configuration::Connected { + connected: Connected { + api_url: "https://icp-api.io".parse().unwrap(), + http_gateway_url: Some("https://icp.net".parse().unwrap()), + root_key, + }, + }, + } + } + + /// A gzipped tar of the given entries, each a regular file. + fn archive(entries: &[(&str, &[u8])]) -> Vec { + let mut archive = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::fast(), + )); + for (path, data) in entries { + let mut header = tar::Header::new_gnu(); + header.set_size(data.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + archive.append_data(&mut header, path, *data).unwrap(); + } + archive.into_inner().unwrap().finish().unwrap() + } + + fn scratch() -> (tempfile::TempDir, Utf8PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let path = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap(); + (dir, path) + } + + // Only the Internet Computer: a bundle built for `local` names a managed + // network, a testnet a connected one with a fetched key; both are refused + // with the way out, not deep inside the deploy. + #[tokio::test] + async fn only_the_ic_network_is_served() { + for network in [managed("local"), connected("testnet", RootKeySpec::Fetch)] { + let err = match IcMainnet.urls(&network).await { + Err(e) => e.to_string(), + Ok(_) => panic!("{} must be refused", network.name), + }; + assert!(err.contains("not the Internet Computer"), "{err}"); + assert!(err.contains("`ic`"), "{err}"); + assert!(IcMainnet.access(&network).await.is_err()); + } + } + + // The mainnet root key is pinned, never fetched: a bundle for `ic` + // resolves without touching the network. + #[tokio::test] + async fn the_ic_network_resolves_to_the_pinned_root_key() { + let access = IcMainnet.access(&connected("ic", RootKeySpec::Mainnet)).await.unwrap(); + assert_eq!(access.root_key, IC_ROOT_KEY.to_vec()); + assert_eq!(access.root_key_source, RootKeySource::Mainnet); + assert_eq!(access.api_url.as_str(), "https://icp-api.io/"); + } + + #[test] + fn an_archive_without_a_manifest_is_not_a_bundle() { + let (_dir, into) = scratch(); + let err = extract_bundle(&archive(&[("README", b"hello")]), &into).unwrap_err(); + assert!(err.contains(PROJECT_MANIFEST), "{err}"); + } + + #[test] + fn garbage_is_not_an_archive() { + let (_dir, into) = scratch(); + let err = extract_bundle(b"not a tarball", &into).unwrap_err(); + assert!(err.contains("not a readable"), "{err}"); + } + + #[test] + fn oversized_bundles_are_refused_before_extraction() { + let (_dir, into) = scratch(); + let big = vec![0u8; MAX_BUNDLE_BYTES + 1]; + let err = extract_bundle(&big, &into).unwrap_err(); + assert!(err.contains("limit"), "{err}"); + } + + // A small archive can claim a huge file: the entry's size is what tar + // writes (as a hole, if the data is not there), so the budget is checked + // against the sizes before anything is written. + #[test] + fn an_archive_that_extracts_past_the_budget_is_refused() { + let (_dir, into) = scratch(); + let mut archive = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::fast(), + )); + let mut header = tar::Header::new_gnu(); + header.set_path("huge").unwrap(); + header.set_size(MAX_EXTRACTED_BYTES + 1); + header.set_mode(0o644); + header.set_cksum(); + // Only the header is appended: the claimed data never follows. + archive.get_mut().write_all(header.as_bytes()).unwrap(); + let bytes = archive.into_inner().unwrap().finish().unwrap(); + let err = extract_bundle(&bytes, &into).unwrap_err(); + assert!(err.contains("extracts to more than"), "{err}"); + assert!(!into.join("huge").exists()); + } + + // A symlink is how an archive reaches past its directory; a bundle + // carries none, so one is refused rather than written — which is what + // lets the index below trust that every path under the root is a file + // extraction wrote. + #[test] + fn an_archive_with_a_symlink_is_refused() { + let (_dir, into) = scratch(); + let mut archive = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::fast(), + )); + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Symlink); + header.set_size(0); + header.set_mode(0o777); + header.set_cksum(); + archive.append_link(&mut header, "icp.yaml", "/etc/passwd").unwrap(); + let bytes = archive.into_inner().unwrap().finish().unwrap(); + let err = extract_bundle(&bytes, &into).unwrap_err(); + assert!(err.contains("not a regular file or directory"), "{err}"); + assert!(!into.join("icp.yaml").exists()); + } + + #[test] + fn an_archive_carrying_project_state_is_refused() { + let (_dir, into) = scratch(); + let bytes = archive(&[ + ("icp.yaml", b"canisters: []"), + (".icp/data/mappings/ic.ids.json", b"{\"backend\": \"aaaaa-aa\"}"), + ]); + let err = extract_bundle(&bytes, &into).unwrap_err(); + assert!(err.contains("`.icp/`"), "{err}"); + assert!(!into.join(".icp").exists()); + } + + // The index is the filesystem: what extraction wrote is readable by any + // spelling that normalizes to it, and nothing else is, however the disk + // looks at the spelling — absolute, climbing, or a file that happens to + // exist under the root without having come from the archive. + #[tokio::test] + async fn the_bundle_serves_its_index_and_nothing_else() { + let (_dir, into) = scratch(); + let fs = extract_bundle( + &archive(&[("icp.yaml", b"canisters: []"), ("./canisters/a.wasm", b"wasm")]), + &into, + ) + .unwrap(); + let root = fs.root().to_path_buf(); + + for spelling in ["canisters/a.wasm", "./canisters/a.wasm", "canisters/../canisters/a.wasm"] + { + assert_eq!(fs.read(Utf8Path::new(spelling)).await.unwrap(), b"wasm", "{spelling}"); + } + // The absolute paths the library derives from the root strip back. + assert_eq!(fs.read(&root.join("canisters/a.wasm")).await.unwrap(), b"wasm"); + assert_eq!( + fs.canonicalize(Utf8Path::new("canisters/a.wasm")).await, + Some(root.join("canisters/a.wasm")) + ); + assert!(fs.is_dir(Utf8Path::new("canisters")).await); + assert!(fs.is_dir(&root).await); + assert!(!fs.is_dir(Utf8Path::new("canisters/a.wasm")).await); + assert_eq!( + fs.read_dir(&root).await.unwrap(), + vec![root.join("canisters"), root.join("icp.yaml")] + ); + + std::fs::write(root.join("planted"), b"not from the archive").unwrap(); + for escape in ["/etc/hostname", "../../../../etc/hostname", "canisters/../../x", "planted"] + { + let err = fs.read(Utf8Path::new(escape)).await.unwrap_err().to_string(); + assert!(err.contains("not in the bundle"), "{escape}: {err}"); + assert!(!fs.exists(Utf8Path::new(escape)).await, "{escape}"); + } + let err = fs.locate(Utf8Path::new("/etc/hostname")).unwrap_err().to_string(); + assert!(err.contains("'etc/hostname' is not in the bundle"), "{err}"); + } + + // A build writes its output to a scratch directory and the operation + // reads it back through the same filesystem; nowhere else may be written, + // and the scratch goes — from the disk and the index — with its holder. + #[tokio::test] + async fn a_build_output_lands_in_scratch_and_nowhere_else() { + let (_dir, into) = scratch(); + let fs = extract_bundle(&archive(&[("icp.yaml", b"canisters: []")]), &into).unwrap(); + let scratch = fs.scratch_dir().await.unwrap(); + assert!(scratch.path().starts_with(fs.root())); + let out = scratch.path().join("out/module.wasm"); + fs.write(&out, b"built").await.unwrap(); + assert_eq!(fs.read(&out).await.unwrap(), b"built"); + assert!(fs.is_file(&out).await); + assert!(fs.is_dir(scratch.path()).await); + assert!(fs.create_dir_all(&scratch.path().join("more")).await.is_ok()); + + let err = fs.write(Utf8Path::new("canisters/x.wasm"), b"no").await.unwrap_err().to_string(); + assert!(err.contains("read-only"), "{err}"); + let err = fs.write(Utf8Path::new("/tmp/x"), b"no").await.unwrap_err().to_string(); + assert!(err.contains("read-only"), "{err}"); + assert!(!fs.root().join("tmp/x").exists()); + assert!(fs.create_dir_all(Utf8Path::new("elsewhere")).await.is_err()); + + let on_disk = out.clone(); + drop(scratch); + assert!(!fs.exists(&out).await, "scratch leaves the index with its holder"); + assert!(!on_disk.exists(), "and the disk"); + } +} diff --git a/crates/imcp2-deploy/src/lib.rs b/crates/imcp2-deploy/src/lib.rs new file mode 100644 index 0000000..f798d3e --- /dev/null +++ b/crates/imcp2-deploy/src/lib.rs @@ -0,0 +1,14 @@ +//! imcp2-deploy — deploying an icp project bundle to the Internet Computer +//! through the icp CLI's own project library (`icp-project`, the code behind +//! `icp deploy`). +//! +//! This crate is composed into the local server only. It depends on +//! `icp-project` straight from the icp-cli repository, which crates.io does +//! not allow, so it cannot be part of the published `imcp2-core`/`imcp2` +//! crates (see `Cargo.toml`). +//! +//! [`host`] is the `icp_project::Host` for a bundle: the archive `icp project +//! bundle` writes, treated as untrusted input. The deploy pipeline over it and +//! the MCP tools that expose it follow in their own changes. + +pub mod host; From 005384c1073ef9a62211c97be5c2d038da04402c Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Tue, 3 Nov 2026 19:36:26 +0000 Subject: [PATCH 02/12] Hold the bundle to the reserved `ic` network and its own root, and restore the crate version Four review findings on the host: - The network seam accepted any connected network claiming the mainnet root key, so a manifest could point the user's signed calls at an endpoint of its choosing. Only the reserved `ic` network is served now: that name, the mainnet endpoint and the mainnet root key, which is what the loader supplies and refuses to let a manifest redeclare. - A canister directory spelled absolutely (`/etc`) resolved inside the bundle for every reader that goes through `BundleFs`, but the sync-plugin runtime takes it as a host path and makes a directory outside the project its own sandbox root. `check_bundle` now holds each canister directory to the bundle root by spelling. - The `.icp/` guard at extraction tested the archive path's first component, which `./.icp/...` sidesteps. It tests the normalized path. - The root crate had slipped back to 0.6.0 while the others are at 0.6.1, which the release guard refuses. Restored, lockfile included. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 2 +- Cargo.toml | 4 +- crates/imcp2-deploy/src/host.rs | 135 +++++++++++++++++++++++++++----- 3 files changed, 118 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5a9a375..8ea8956 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2408,7 +2408,7 @@ dependencies = [ [[package]] name = "imcp2" -version = "0.6.0" +version = "0.6.1" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index eda6775..86e2670 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "imcp2" -version = "0.6.0" +version = "0.6.1" edition = "2021" description = "Minimal MCP server that bridges an LLM to the Internet Computer, speaking textual Candid." license = "Apache-2.0" @@ -45,7 +45,7 @@ dist = false # imcp2-local. [workspace.dependencies] httpdate = "1.0.3" -imcp2-core = { path = "crates/imcp2-core", version = "0.6.0" } +imcp2-core = { path = "crates/imcp2-core", version = "0.6.1" } rmcp = "2.1" axum = "0.8" anyhow = "1" diff --git a/crates/imcp2-deploy/src/host.rs b/crates/imcp2-deploy/src/host.rs index f5afc2d..9172ae3 100644 --- a/crates/imcp2-deploy/src/host.rs +++ b/crates/imcp2-deploy/src/host.rs @@ -63,7 +63,7 @@ use icp_project::{ Access, AccessError, CollectFriendlyDomains, Configuration, NetworkAccess, NetworkUrls, RootKeySource, RootKeySpec, }, - prelude::IC_ROOT_KEY, + prelude::{IC, IC_MAINNET_NETWORK_API_URL, IC_ROOT_KEY}, random::HostRandom, store_artifact::ArtifactStore, store_id::AccessImpl, @@ -107,28 +107,40 @@ impl std::error::Error for Unsupported {} /// /// A bundle's manifest inlines its networks, and a manifest may declare any: /// a managed one the icp CLI would launch, a connected one with a root key to -/// fetch or a key spelled out. This server deploys to mainnet only, so the -/// one configuration honoured is a connected network with the pinned mainnet -/// root key — which is what every manifest's `ic` is. [`check_bundle`] -/// refuses the rest before any phase runs; this is the seam's own refusal, -/// for the library's own calls. +/// fetch or a key spelled out — or one that claims the mainnet root key over +/// an endpoint of its choosing, which would send the user's signed calls +/// wherever it points. This server deploys to mainnet only, so the one +/// network honoured is the reserved `ic`: the project loader supplies it with +/// the mainnet endpoint and the pinned root key, and refuses a manifest that +/// redeclares the name. [`check_bundle`] refuses the rest before any phase +/// runs; this is the seam's own refusal, for the library's own calls. pub struct IcMainnet; impl IcMainnet { - /// The connected configuration of `network`, if it is mainnet. + /// The connected configuration of `network`, if it is the reserved `ic` + /// network as the loader supplies it: that name, the mainnet endpoint and + /// the mainnet root key. pub fn mainnet(network: &Network) -> Result<&icp_project::network::Connected, Unsupported> { + let refuse = || { + Unsupported(format!( + "network '{}' is not the Internet Computer; this server deploys to the `ic` \ + network only. Deploy to the `ic` environment, or deploy elsewhere with `icp \ + deploy`", + network.name + )) + }; + if network.name != IC { + return Err(refuse()); + } match &network.configuration { Configuration::Connected { connected } - if matches!(connected.root_key, RootKeySpec::Mainnet) => + if matches!(connected.root_key, RootKeySpec::Mainnet) + && connected.api_url.as_str().trim_end_matches('/') + == IC_MAINNET_NETWORK_API_URL => { Ok(connected) } - _ => Err(Unsupported(format!( - "network '{}' is not the Internet Computer; this server deploys to the `ic` \ - network only. Deploy to the `ic` environment, or deploy elsewhere with `icp \ - deploy`", - network.name - ))), + _ => Err(refuse()), } } } @@ -289,6 +301,20 @@ impl BundleFs { out } + /// Whether `dir` is spelled under the bundle root without climbing: the + /// root itself or a descendant, by path alone. A spelling that + /// normalizes into the root but is not written under it (`/etc`, + /// `/../x`) is not held, because a reader that takes the spelling + /// as a host path would leave the bundle. + pub fn holds_dir(&self, dir: &Utf8Path) -> bool { + match dir.strip_prefix(&self.0.root) { + Ok(rest) => rest + .components() + .all(|c| matches!(c, Utf8Component::Normal(_) | Utf8Component::CurDir)), + Err(_) => false, + } + } + /// Whether the bundle holds a file at `path`. pub fn contains(&self, path: &Utf8Path) -> bool { self.0.state().files.contains(&self.normalize(path)) @@ -551,10 +577,19 @@ impl ScriptRunner for NoScripts { /// phase runs, so a bundle that would fail at its build or sync phase, or /// be refused there, does not first spend cycles creating canisters. /// -/// Each canister is built by exactly one pre-built step naming a wasm in the -/// archive that hashes to the checksum the manifest records; each sync step -/// is a plugin in the archive (hashing to its checksum, if one is recorded); -/// no step is a script. The environment's network is the Internet Computer. +/// Each canister lives in a directory under the bundle root, and is built by +/// exactly one pre-built step naming a wasm in the archive that hashes to the +/// checksum the manifest records; each sync step is a plugin in the archive +/// (hashing to its checksum, if one is recorded); no step is a script. The +/// environment's network is the Internet Computer. +/// +/// The directory check matters because not every reader goes through +/// [`BundleFs`]: the sync-plugin runtime takes the canister directory as a +/// host path and confines a plugin's declared files to the project directory +/// only when the canister's lies inside it — a canister directory outside the +/// project is its own sandbox root. A manifest may spell a canister's path +/// absolutely, and the bundle can carry the `canister.yaml` the loader looks +/// for at that spelling, so the directory must be held to the root here. pub fn check_bundle<'a>( fs: &BundleFs, network: &Network, @@ -564,6 +599,12 @@ pub fn check_bundle<'a>( for (dir, canister) in canisters { let name = &canister.name; let at = |e: Unsupported| format!("canister '{name}': {e}"); + if !fs.holds_dir(dir) { + return Err(at(Unsupported(format!( + "its directory '{dir}' is not under the bundle root; a bundle's canisters live \ + under its root, which is where `icp project bundle` puts them" + )))); + } let present = |path: &Utf8Path| -> Result, String> { fs.read_bytes(&dir.join(path)).map_err(|e| format!("canister '{name}': {e}")) }; @@ -690,7 +731,7 @@ pub fn extract_bundle(bytes: &[u8], into: &Utf8Path) -> Result // store. A bundle never carries it (`icp project bundle` writes // manifests, modules, plugins, argument files and images), and one // that did could point a deploy at canisters the caller never named. - if path.components().next().is_some_and(|c| c.as_str() == STATE_DIR) { + if fs.normalize(&path).components().next().is_some_and(|c| c.as_str() == STATE_DIR) { return Err(format!( "the archive entry '{path}' is under `{STATE_DIR}/`, the project state a bundle \ never carries; rebuild it with `icp project bundle`" @@ -789,7 +830,25 @@ mod tests { // with the way out, not deep inside the deploy. #[tokio::test] async fn only_the_ic_network_is_served() { - for network in [managed("local"), connected("testnet", RootKeySpec::Fetch)] { + // A manifest may declare a network of any name, and it may claim the + // mainnet root key over an endpoint of its own: refused, like a + // network to be launched or one whose key would be fetched. So is + // the reserved name over another endpoint, should a loader ever let + // that through. + let mut elsewhere = connected("mainnet2", RootKeySpec::Mainnet); + let Configuration::Connected { connected: config } = &mut elsewhere.configuration else { + unreachable!() + }; + config.api_url = "http://127.0.0.1:4943".parse().unwrap(); + let mut ic_elsewhere = elsewhere.clone(); + ic_elsewhere.name = "ic".into(); + for network in [ + managed("local"), + connected("testnet", RootKeySpec::Fetch), + connected("mainnet2", RootKeySpec::Mainnet), + elsewhere, + ic_elsewhere, + ] { let err = match IcMainnet.urls(&network).await { Err(e) => e.to_string(), Ok(_) => panic!("{} must be refused", network.name), @@ -888,6 +947,42 @@ mod tests { let err = extract_bundle(&bytes, &into).unwrap_err(); assert!(err.contains("`.icp/`"), "{err}"); assert!(!into.join(".icp").exists()); + + // The same entry spelled `./.icp/...`: the archive path's first + // component is `.`, extraction writes it into `.icp/` all the same. + let (_dir, into) = scratch(); + let mut archive = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::fast(), + )); + let data: &[u8] = b"{\"backend\": \"aaaaa-aa\"}"; + let mut header = tar::Header::new_gnu(); + header.set_path("./.icp/data/mappings/ic.ids.json").unwrap(); + header.set_size(data.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + archive.append(&header, data).unwrap(); + let bytes = archive.into_inner().unwrap().finish().unwrap(); + let err = extract_bundle(&bytes, &into).unwrap_err(); + assert!(err.contains("`.icp/`"), "{err}"); + assert!(!into.join(".icp").exists()); + } + + // A canister directory is held to the bundle root by its spelling, since + // the sync-plugin runtime takes it as a host path: inside by name is + // held, and `/etc` or a climb out of the root is not, however it would + // normalize. + #[test] + fn a_canister_directory_is_under_the_root_by_spelling() { + let (_dir, into) = scratch(); + let fs = extract_bundle(&archive(&[("icp.yaml", b"canisters: []")]), &into).unwrap(); + let root = fs.root().to_path_buf(); + assert!(fs.holds_dir(&root)); + assert!(fs.holds_dir(&root.join("backend"))); + assert!(fs.holds_dir(&root.join("./backend"))); + assert!(!fs.holds_dir(Utf8Path::new("/etc"))); + assert!(!fs.holds_dir(&root.join("../elsewhere"))); + assert!(!fs.holds_dir(&root.join("backend/../../elsewhere"))); } // The index is the filesystem: what extraction wrote is readable by any From f9f8f23605380d289e8f5e2d0109fd301a46ce76 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Tue, 3 Nov 2026 20:29:01 +0000 Subject: [PATCH 03/12] Bound what the index, the artifact store and a gzipped module can cost Four more review findings on the host: - Directory lookups scanned the whole index per call, which a recursive glob over a bundle of many files turned quadratic. A path set orders by component, so a directory's descendants sit right after it: `is_dir` and `read_dir` now take one range lookup and read while the prefix holds. An archive is also capped at 250,000 entries, since the byte budget alone admits millions of empty files. - Canister names that differ only in letter case passed, and the library's artifact store keys build output by name on disk, where a case-insensitive volume would hand the last-built module to both. `check_bundle` refuses them. - A gzip-compressed module hashed correctly however much it expanded to, and the library gunzips it in memory, unbounded, to read its Candid interface. `check_bundle` streams it through a bounded decoder first, holding it to 100 MiB expanded, the IC's own ceiling on a module. - The `.icp/` guard at extraction compared case-sensitively, which `.ICP/` sidesteps on a case-insensitive disk. Compared without case. Co-Authored-By: Claude Fable 5.1 --- crates/imcp2-deploy/src/host.rs | 176 ++++++++++++++++++++++++++++++-- 1 file changed, 168 insertions(+), 8 deletions(-) diff --git a/crates/imcp2-deploy/src/host.rs b/crates/imcp2-deploy/src/host.rs index 9172ae3..8993fc3 100644 --- a/crates/imcp2-deploy/src/host.rs +++ b/crates/imcp2-deploy/src/host.rs @@ -34,9 +34,10 @@ //! extracted directory, which is removed when the deploy ends. use std::{ - collections::BTreeSet, + collections::{BTreeMap, BTreeSet}, fmt, io::Read, + ops::Bound, sync::{Arc, Mutex, PoisonError}, }; @@ -87,6 +88,18 @@ pub const MAX_EXTRACTED_BYTES: u64 = 1024 * 1024 * 1024; /// seeds it from the caller's mapping; the archive may not supply it. const STATE_DIR: &str = ".icp"; +/// The most entries an archive may hold. The byte budget alone would admit +/// millions of empty files, each a path in the index the loader walks; a +/// real bundle is a manifest, a few modules and plugins, and a frontend's +/// assets, well inside this. +pub const MAX_BUNDLE_ENTRIES: usize = 250_000; + +/// The most a gzip-compressed module may expand to: 100 MiB, the IC's own +/// ceiling on a module. The library gunzips a module in memory to read its +/// Candid interface, with no bound of its own, so the bound is applied here +/// before a deploy runs. +pub const MAX_WASM_BYTES: u64 = 100 * 1024 * 1024; + /// A plain error for the seams below: each carries one sentence, and the /// library wraps it whole at its own boundary (`AccessError::new` and the /// like), so the sentence is what the user reads. @@ -219,14 +232,26 @@ impl Inner { } impl State { + /// The entries of `set` strictly under `rel`. A path set orders by + /// component, so a directory's descendants sit right after it: one + /// range lookup, then take while the prefix holds, never a scan. + fn under<'a>( + set: &'a BTreeSet, + rel: &'a Utf8Path, + ) -> impl Iterator + 'a { + set.range::((Bound::Excluded(rel), Bound::Unbounded)) + .take_while(move |p| p.starts_with(rel)) + } + fn is_dir(&self, rel: &Utf8Path) -> bool { rel.as_str().is_empty() || self.scratch.contains(rel) - || self.files.iter().chain(&self.scratch).any(|p| p != rel && p.starts_with(rel)) + || Self::under(&self.files, rel).next().is_some() + || Self::under(&self.scratch, rel).next().is_some() } fn in_scratch(&self, rel: &Utf8Path) -> bool { - self.scratch.iter().any(|s| rel != s && rel.starts_with(s)) + rel.ancestors().skip(1).any(|a| self.scratch.contains(a)) } } @@ -425,10 +450,8 @@ impl FileSystem for BundleFs { async fn read_dir(&self, path: &Utf8Path) -> Result, FsError> { let prefix = self.normalize(path); let state = self.0.state(); - let mut children: Vec = state - .files - .iter() - .chain(&state.scratch) + let mut children: Vec = State::under(&state.files, &prefix) + .chain(State::under(&state.scratch, &prefix)) .filter_map(|entry| entry.strip_prefix(&prefix).ok()) .filter_map(|relative| relative.components().next()) .map(|first| self.0.root.join(&prefix).join(first.as_str())) @@ -508,6 +531,23 @@ fn verify_checksum(path: &Utf8Path, expected: &str, bytes: &[u8]) -> Result<(), } } +/// Hold a gzip-compressed module to `limit` bytes expanded, by streaming it +/// through a bounded decoder into nothing. A module that is not gzip passes +/// as it is: its size is its own. +fn gunzipped_within(path: &Utf8Path, bytes: &[u8], limit: u64) -> Result<(), Unsupported> { + if !bytes.starts_with(&[0x1f, 0x8b]) { + return Ok(()); + } + let mut decoder = Limited { inner: flate2::read::GzDecoder::new(bytes), left: limit }; + match std::io::copy(&mut decoder, &mut std::io::sink()) { + Ok(_) => Ok(()), + Err(_) => Err(Unsupported(format!( + "the gzip-compressed wasm '{path}' expands past {limit} bytes, the most a module \ + can be, or is not valid gzip" + ))), + } +} + /// Recipe resolution for a bundle: a bundled manifest spells every canister /// out as explicit pre-built steps, so a recipe reference means the archive /// is not a bundle. @@ -583,6 +623,11 @@ impl ScriptRunner for NoScripts { /// (hashing to its checksum, if one is recorded); no step is a script. The /// environment's network is the Internet Computer. /// +/// Canister names must also differ by more than letter case: the library's +/// artifact store keys a canister's build output by its name on disk, and +/// a case-insensitive volume (macOS and Windows by default) would hand the +/// last-built module to both. +/// /// The directory check matters because not every reader goes through /// [`BundleFs`]: the sync-plugin runtime takes the canister directory as a /// host path and confines a plugin's declared files to the project directory @@ -596,6 +641,19 @@ pub fn check_bundle<'a>( canisters: impl IntoIterator, ) -> Result<(), String> { IcMainnet::mainnet(network).map_err(|e| e.0)?; + let canisters: Vec<_> = canisters.into_iter().collect(); + let mut by_folded: BTreeMap = BTreeMap::new(); + for (_, canister) in &canisters { + if let Some(other) = by_folded.insert(canister.name.to_lowercase(), &canister.name) { + if other != canister.name { + return Err(format!( + "canisters '{other}' and '{}' differ only in letter case; the build artifact \ + store keys files by canister name, which a case-insensitive disk would merge", + canister.name + )); + } + } + } for (dir, canister) in canisters { let name = &canister.name; let at = |e: Unsupported| format!("canister '{name}': {e}"); @@ -638,6 +696,7 @@ pub fn check_bundle<'a>( )))); }; verify_checksum(&local.path, expected, &wasm).map_err(at)?; + gunzipped_within(&local.path, &wasm, MAX_WASM_BYTES).map_err(at)?; for step in &canister.sync.steps { let SyncStep::Plugin(plugin) = step else { continue }; @@ -701,6 +760,16 @@ pub fn host_for(fs: BundleFs) -> Host { /// `into`. What it extracts is then required to look like a bundle, i.e. to /// carry a project manifest at its root. pub fn extract_bundle(bytes: &[u8], into: &Utf8Path) -> Result { + extract_bundle_within(bytes, into, MAX_BUNDLE_ENTRIES) +} + +/// [`extract_bundle`] with the entry cap as a parameter, so a test can reach +/// it without writing a quarter of a million files. +fn extract_bundle_within( + bytes: &[u8], + into: &Utf8Path, + max_entries: usize, +) -> Result { if bytes.len() > MAX_BUNDLE_BYTES { return Err(format!( "the bundle is {} bytes, above the {} byte limit", @@ -714,8 +783,15 @@ pub fn extract_bundle(bytes: &[u8], into: &Utf8Path) -> Result let decoder = Limited { inner: flate2::read::GzDecoder::new(bytes), left: MAX_EXTRACTED_BYTES }; let mut archive = tar::Archive::new(decoder); let mut total: u64 = 0; + let mut entries: usize = 0; for entry in archive.entries().map_err(unreadable)? { let mut entry = entry.map_err(unreadable)?; + entries += 1; + if entries > max_entries { + return Err(format!( + "the bundle holds more than {max_entries} entries, more than a bundle ever has" + )); + } let kind = entry.header().entry_type(); let path = entry.path().map_err(unreadable)?.into_owned(); let path = Utf8PathBuf::from_path_buf(path) @@ -731,7 +807,14 @@ pub fn extract_bundle(bytes: &[u8], into: &Utf8Path) -> Result // store. A bundle never carries it (`icp project bundle` writes // manifests, modules, plugins, argument files and images), and one // that did could point a deploy at canisters the caller never named. - if fs.normalize(&path).components().next().is_some_and(|c| c.as_str() == STATE_DIR) { + // Compared without regard to case: on a case-insensitive disk `.ICP` + // is the same directory. + if fs + .normalize(&path) + .components() + .next() + .is_some_and(|c| c.as_str().eq_ignore_ascii_case(STATE_DIR)) + { return Err(format!( "the archive entry '{path}' is under `{STATE_DIR}/`, the project state a bundle \ never carries; rebuild it with `icp project bundle`" @@ -948,6 +1031,16 @@ mod tests { assert!(err.contains("`.icp/`"), "{err}"); assert!(!into.join(".icp").exists()); + // Spelled `.ICP/`: the same directory on a case-insensitive disk. + let (_dir, into) = scratch(); + let bytes = archive(&[ + ("icp.yaml", b"canisters: []"), + (".ICP/data/mappings/ic.ids.json", b"{\"backend\": \"aaaaa-aa\"}"), + ]); + let err = extract_bundle(&bytes, &into).unwrap_err(); + assert!(err.contains("`.icp/`"), "{err}"); + assert!(!into.join(".ICP").exists()); + // The same entry spelled `./.icp/...`: the archive path's first // component is `.`, extraction writes it into `.icp/` all the same. let (_dir, into) = scratch(); @@ -968,6 +1061,73 @@ mod tests { assert!(!into.join(".icp").exists()); } + #[test] + fn an_archive_with_too_many_entries_is_refused() { + let (_dir, into) = scratch(); + let mut archive = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::fast(), + )); + for i in 0..=8 { + let mut header = tar::Header::new_gnu(); + header.set_size(0); + header.set_mode(0o644); + header.set_cksum(); + archive.append_data(&mut header, format!("f/{i}"), &b""[..]).unwrap(); + } + let bytes = archive.into_inner().unwrap().finish().unwrap(); + let err = extract_bundle_within(&bytes, &into, 8).unwrap_err(); + assert!(err.contains("more than 8 entries"), "{err}"); + let err = extract_bundle_within(&bytes, &into, 9).unwrap_err(); + assert!(err.contains(PROJECT_MANIFEST), "{err}"); + } + + // A gzip-compressed module is bounded by what it expands to, read + // through a bounded decoder; a plain module passes as it is. + #[test] + fn a_gzipped_module_is_held_to_its_limit() { + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + encoder.write_all(&[0u8; 4096]).unwrap(); + let gz = encoder.finish().unwrap(); + let path = Utf8Path::new("backend.wasm.gz"); + gunzipped_within(path, &gz, 8192).unwrap(); + let err = gunzipped_within(path, &gz, 1024).unwrap_err(); + assert!(err.0.contains("expands past 1024"), "{err}"); + gunzipped_within(path, b"\0asm plain", 1).unwrap(); + } + + // Directory lookups come from the index by range, not by scanning it: + // what is under a directory, and whether anything is. + #[test] + fn directory_lookups_follow_the_index() { + let (_dir, into) = scratch(); + let fs = extract_bundle( + &archive(&[ + ("icp.yaml", b"canisters: []"), + ("a/b/c.txt", b"1"), + ("a/b/d.txt", b"2"), + ("a/e.txt", b"3"), + ("ab/f.txt", b"4"), + ]), + &into, + ) + .unwrap(); + let state = fs.0.state(); + assert!(state.is_dir(Utf8Path::new("a"))); + assert!(state.is_dir(Utf8Path::new("a/b"))); + assert!(!state.is_dir(Utf8Path::new("a/b/c.txt"))); + assert!(!state.is_dir(Utf8Path::new("a/x"))); + let under: Vec<_> = State::under(&state.files, Utf8Path::new("a")).collect(); + assert_eq!(under.len(), 3, "{under:?}"); + assert!(under.iter().all(|p| p.starts_with("a")), "{under:?}"); + drop(state); + let children = tokio::runtime::Runtime::new() + .unwrap() + .block_on(fs.read_dir(&fs.root().join("a"))) + .unwrap(); + assert_eq!(children, vec![fs.root().join("a/b"), fs.root().join("a/e.txt")]); + } + // A canister directory is held to the bundle root by its spelling, since // the sync-plugin runtime takes it as a host path: inside by name is // held, and `/etc` or a climb out of the root is not, however it would From a62aa3f30d70cf8416f26dfafddfa6b577aca38b Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Tue, 3 Nov 2026 22:33:14 +0000 Subject: [PATCH 04/12] Bound what loading the project retains and what an archive's paths create Two more costs a bundle could run up past the budgets that were there, and one it still can, named for what it is. The project loader reads an argument or variable file into every canister that names it and keeps each copy, so the extraction budget bounded none of it: fifty canisters naming one file of 100 MiB would hold 5 GiB. The loader now reads through a metered view of the bundle, charged by file size before each read, with 256 MiB for everything it loads; the phases keep reading the bundle itself, where a module is read as often as the deploy needs it. The entry cap counted archive records, but `unpack_in` creates the missing parents of every entry, so a few files at the bottom of long, distinct directory chains cost inodes the count never saw. Extraction now counts files and directories alike, charging each directory an entry's path implies once, read off the index and the directory entries written so far. Since that walk costs a lookup per component, an entry's path is first held to Linux's PATH_MAX, past which nothing could be created anyway. What stays unbounded is a sync plugin's memory: the runner in icp-sync-plugin bounds compute time and stack and builds its own engine and store, so a store limiter belongs there. The host's docs say so, so the gap is not mistaken for a bound. Co-Authored-By: Claude Fable 5.1 --- crates/imcp2-deploy/src/host.rs | 355 +++++++++++++++++++++++++++++--- 1 file changed, 323 insertions(+), 32 deletions(-) diff --git a/crates/imcp2-deploy/src/host.rs b/crates/imcp2-deploy/src/host.rs index 8993fc3..1646216 100644 --- a/crates/imcp2-deploy/src/host.rs +++ b/crates/imcp2-deploy/src/host.rs @@ -88,12 +88,27 @@ pub const MAX_EXTRACTED_BYTES: u64 = 1024 * 1024 * 1024; /// seeds it from the caller's mapping; the archive may not supply it. const STATE_DIR: &str = ".icp"; -/// The most entries an archive may hold. The byte budget alone would admit -/// millions of empty files, each a path in the index the loader walks; a -/// real bundle is a manifest, a few modules and plugins, and a frontend's -/// assets, well inside this. +/// The most files and directories an archive may extract to, counting the +/// directories its entries' paths imply and extraction creates on the way. +/// The byte budget alone would admit millions of empty files, each a path in +/// the index the loader walks, or a few files at the bottom of millions of +/// directories, each an inode; a real bundle is a manifest, a few modules and +/// plugins, and a frontend's assets, well inside this. pub const MAX_BUNDLE_ENTRIES: usize = 250_000; +/// The longest path an archive entry may have, in bytes: Linux's own `PATH_MAX`, +/// past which no path can be created anyway. Counting the directories a path +/// implies costs a lookup per component, so the path is held to a length first. +pub const MAX_ENTRY_PATH_BYTES: usize = 4096; + +/// What loading the project may read in all: the manifests, and the argument +/// and variable files the loader reads into each canister. Each read is +/// retained, and the loader reads a file once per canister that names it, +/// so the extraction budget bounds none of this: fifty canisters naming one +/// file of 100 MiB would hold 5 GiB. A canister's arguments fit in one +/// ingress message, so a real project reads a few MiB at most. +pub const MAX_PROJECT_BYTES: u64 = 256 * 1024 * 1024; + /// The most a gzip-compressed module may expand to: 100 MiB, the IC's own /// ceiling on a module. The library gunzips a module in memory to read its /// Candid interface, with no bound of its own, so the bound is applied here @@ -481,6 +496,92 @@ impl FileSystem for BundleFs { } } +/// [`BundleFs`] with a budget on what is read through it: the filesystem +/// the project loader is given, so what it retains is bounded. +/// +/// The loader reads a manifest, an argument file or a variable file into the +/// canister that names it, once per canister, and keeps it for the life of +/// the project. Every read is charged by the file's size before it is made; +/// the rest of the trait passes through. +struct Metered { + inner: BundleFs, + left: Mutex, +} + +impl Metered { + fn new(inner: BundleFs, budget: u64) -> Self { + Self { inner, left: Mutex::new(budget) } + } + + /// Charge `path`'s size to the budget, or refuse the read. + fn charge(&self, path: &Utf8Path) -> Result<(), FsError> { + let on_disk = self.inner.locate(path)?; + let size = std::fs::metadata(&on_disk) + .map_err(|e| BundleFsError::Io(self.inner.normalize(path), e))? + .len(); + let mut left = self.left.lock().unwrap_or_else(PoisonError::into_inner); + if size > *left { + return Err(FsError::new(Unsupported(format!( + "loading the project reads more than {} bytes of manifests, argument files and \ + variable files in all, more than a project ever holds; a canister's arguments \ + fit in one message", + MAX_PROJECT_BYTES + )))); + } + *left -= size; + Ok(()) + } +} + +#[async_trait] +impl FileSystem for Metered { + async fn read(&self, path: &Utf8Path) -> Result, FsError> { + self.charge(path)?; + self.inner.read(path).await + } + + async fn read_to_string(&self, path: &Utf8Path) -> Result { + self.charge(path)?; + self.inner.read_to_string(path).await + } + + async fn write(&self, path: &Utf8Path, contents: &[u8]) -> Result<(), FsError> { + self.inner.write(path, contents).await + } + + async fn create_dir_all(&self, path: &Utf8Path) -> Result<(), FsError> { + self.inner.create_dir_all(path).await + } + + async fn copy(&self, from: &Utf8Path, to: &Utf8Path) -> Result<(), FsError> { + self.inner.copy(from, to).await + } + + async fn exists(&self, path: &Utf8Path) -> bool { + self.inner.exists(path).await + } + + async fn is_file(&self, path: &Utf8Path) -> bool { + self.inner.is_file(path).await + } + + async fn is_dir(&self, path: &Utf8Path) -> bool { + self.inner.is_dir(path).await + } + + async fn read_dir(&self, path: &Utf8Path) -> Result, FsError> { + self.inner.read_dir(path).await + } + + async fn canonicalize(&self, path: &Utf8Path) -> Option { + self.inner.canonicalize(path).await + } + + async fn scratch_dir(&self) -> Result, FsError> { + self.inner.scratch_dir().await + } +} + // ── Wasms, recipes, scripts ─────────────────────────────────────────────── /// Wasm resolution for a bundle: the archive carries every module it names, @@ -719,17 +820,35 @@ pub fn check_bundle<'a>( /// The host for the bundle extracted into `fs`: its manifest is the project, /// its `.icp` directory the id and artifact stores, and nothing outside it is /// reachable. +/// +/// The project is loaded through a [`Metered`] view of the bundle, so what +/// the loader reads and retains — once per canister that names a file — stays +/// within [`MAX_PROJECT_BYTES`]; the phases read the bundle itself. +/// +/// What is not bounded here: the sync-plugin runtime (`icp-sync-plugin`'s +/// wasmtime runner) holds a plugin to a compute-time budget and a stack +/// limit, and each of its linear memories to the wasm32 address space, but +/// sets no store limiter, so a plugin may allocate up to that space before +/// its time runs out. The runner builds its own engine and store, so the +/// limiter belongs there, upstream; until it has one, a bundle's plugins run +/// with the memory bound the icp CLI gives them. pub fn host_for(fs: BundleFs) -> Host { + host_within(fs, MAX_PROJECT_BYTES) +} + +/// [`host_for`] with the project-loading budget as a parameter, so a test can +/// reach it without a quarter of a gigabyte of argument files. +fn host_within(fs: BundleFs, project_bytes: u64) -> Host { // The directory IS the project root: no climbing to a workspace above it. let root = fs.root().to_path_buf(); let locate: Arc = Arc::new(ProjectRootLocateImpl::new(root.clone(), Some(root))); let files: Arc = Arc::new(fs.clone()); - let wasm: Arc = Arc::new(BundledWasm(fs)); + let wasm: Arc = Arc::new(BundledWasm(fs.clone())); let project = Lazy::<_, Project>::new(ProjectLoadImpl { project_root_locate: locate.clone(), recipe: Arc::new(NoRecipes), - files: files.clone(), + files: Arc::new(Metered::new(fs, project_bytes)), }); let no_scripts = Arc::new(NoScripts { prebuilt: Builder::new(wasm.clone(), files.clone()) }); Host { @@ -756,15 +875,16 @@ pub fn host_for(fs: BundleFs) -> Host { /// Only regular files and directories are written — `icp project bundle` /// writes nothing else, and a symlink or a device node is how an archive /// reaches past its directory — and their sizes may not add up to more than -/// [`MAX_EXTRACTED_BYTES`]. `tar` refuses entry paths that would land outside -/// `into`. What it extracts is then required to look like a bundle, i.e. to -/// carry a project manifest at its root. +/// [`MAX_EXTRACTED_BYTES`], nor their number, with the directories their +/// paths imply, to more than [`MAX_BUNDLE_ENTRIES`]. `tar` refuses entry +/// paths that would land outside `into`. What it extracts is then required +/// to look like a bundle, i.e. to carry a project manifest at its root. pub fn extract_bundle(bytes: &[u8], into: &Utf8Path) -> Result { extract_bundle_within(bytes, into, MAX_BUNDLE_ENTRIES) } -/// [`extract_bundle`] with the entry cap as a parameter, so a test can reach -/// it without writing a quarter of a million files. +/// [`extract_bundle`] with the files-and-directories cap as a parameter, so a +/// test can reach it without writing a quarter of a million files. fn extract_bundle_within( bytes: &[u8], into: &Utf8Path, @@ -783,25 +903,44 @@ fn extract_bundle_within( let decoder = Limited { inner: flate2::read::GzDecoder::new(bytes), left: MAX_EXTRACTED_BYTES }; let mut archive = tar::Archive::new(decoder); let mut total: u64 = 0; - let mut entries: usize = 0; + // Files and directories written so far, counting each directory an + // entry's path implies: `unpack_in` creates the missing parents of every + // entry, so a few files at the bottom of long, distinct directory chains + // would otherwise cost inodes the entry count never sees. + let mut objects: usize = 0; + let too_many = || { + format!( + "the bundle holds more than {max_entries} files and directories, more than a bundle \ + ever has" + ) + }; + // The directories written as entries of their own; the ones files imply + // are read off the index. + let mut dirs: BTreeSet = BTreeSet::new(); for entry in archive.entries().map_err(unreadable)? { let mut entry = entry.map_err(unreadable)?; - entries += 1; - if entries > max_entries { - return Err(format!( - "the bundle holds more than {max_entries} entries, more than a bundle ever has" - )); + objects += 1; + if objects > max_entries { + return Err(too_many()); } let kind = entry.header().entry_type(); let path = entry.path().map_err(unreadable)?.into_owned(); let path = Utf8PathBuf::from_path_buf(path) .map_err(|p| format!("the archive entry '{}' is not UTF-8", p.display()))?; + if path.as_str().len() > MAX_ENTRY_PATH_BYTES { + return Err(format!( + "an archive entry's path is {} bytes long, more than the {MAX_ENTRY_PATH_BYTES} \ + a path can be", + path.as_str().len() + )); + } if !(kind.is_file() || kind.is_dir()) { return Err(format!( "the archive entry '{path}' is not a regular file or directory ({kind:?}), which \ a bundle never carries; rebuild it with `icp project bundle`" )); } + let rel = fs.normalize(&path); // `.icp` is the library's own state under the project root: the id // mappings a deploy reads to decide what exists, and the artifact // store. A bundle never carries it (`icp project bundle` writes @@ -809,23 +948,40 @@ fn extract_bundle_within( // that did could point a deploy at canisters the caller never named. // Compared without regard to case: on a case-insensitive disk `.ICP` // is the same directory. - if fs - .normalize(&path) - .components() - .next() - .is_some_and(|c| c.as_str().eq_ignore_ascii_case(STATE_DIR)) - { + if rel.components().next().is_some_and(|c| c.as_str().eq_ignore_ascii_case(STATE_DIR)) { return Err(format!( "the archive entry '{path}' is under `{STATE_DIR}/`, the project state a bundle \ never carries; rebuild it with `icp project bundle`" )); } + // The parents this entry implies that nothing before it did. A + // directory is known once a file sits under it or an entry wrote it, + // and a known directory's own parents are known too, so the walk up + // stops at the first one. + let implied = { + let state = fs.0.state(); + rel.ancestors() + .skip(1) + .filter(|dir| !dir.as_str().is_empty()) + .take_while(|dir| { + !dirs.contains(*dir) && State::under(&state.files, dir).next().is_none() + }) + .count() + }; + objects = objects.saturating_add(implied); + if objects > max_entries { + return Err(too_many()); + } total = total.saturating_add(entry.size()); if total > MAX_EXTRACTED_BYTES { return Err(too_large()); } - if entry.unpack_in(fs.root().as_std_path()).map_err(unreadable)? && kind.is_file() { - fs.index(&path); + if entry.unpack_in(fs.root().as_std_path()).map_err(unreadable)? { + if kind.is_file() { + fs.index(&path); + } else { + dirs.insert(rel); + } } } if !fs.contains(Utf8Path::new(PROJECT_MANIFEST)) { @@ -1061,25 +1217,105 @@ mod tests { assert!(!into.join(".icp").exists()); } + // The cap counts files and directories alike, the directories by what + // the paths imply: nine files under one directory are ten objects. #[test] fn an_archive_with_too_many_entries_is_refused() { let (_dir, into) = scratch(); + let paths: Vec = (0..=8).map(|i| format!("f/{i}")).collect(); + let bytes = empty_files(&paths); + let err = extract_bundle_within(&bytes, &into, 9).unwrap_err(); + assert!(err.contains("more than 9 files and directories"), "{err}"); + let (_dir, into) = scratch(); + let err = extract_bundle_within(&bytes, &into, 10).unwrap_err(); + assert!(err.contains(PROJECT_MANIFEST), "{err}"); + } + + // An entry's path implies the directories above it, which extraction + // creates whether or not the archive lists them: they count once each, + // however many entries share them, and an entry written as a directory + // counts as itself. + #[test] + fn the_directories_an_archive_implies_count_against_its_cap() { + // Two files at the bottom of one chain: 2 files + 3 directories. + let bytes = empty_files(&["a/b/c/x".into(), "a/b/c/y".into()]); + let (_dir, into) = scratch(); + let err = extract_bundle_within(&bytes, &into, 4).unwrap_err(); + assert!(err.contains("more than 4 files and directories"), "{err}"); + assert!(!into.join("a/b/c/y").exists(), "the refused entry is not written"); + let (_dir, into) = scratch(); + let err = extract_bundle_within(&bytes, &into, 5).unwrap_err(); + assert!(err.contains(PROJECT_MANIFEST), "{err}"); + + // Two files under distinct chains share nothing: 2 files + 6 dirs. + let bytes = empty_files(&["a/b/c/x".into(), "d/e/f/y".into()]); + let (_dir, into) = scratch(); + let err = extract_bundle_within(&bytes, &into, 7).unwrap_err(); + assert!(err.contains("more than 7 files and directories"), "{err}"); + let (_dir, into) = scratch(); + let err = extract_bundle_within(&bytes, &into, 8).unwrap_err(); + assert!(err.contains(PROJECT_MANIFEST), "{err}"); + + // One file far down a chain is refused on its own, before it is + // written: the directories it would create are the cost. + let deep = (0..64).map(|_| "d").collect::>().join("/") + "/x"; + let bytes = empty_files(&[deep]); + let (_dir, into) = scratch(); + let err = extract_bundle_within(&bytes, &into, 32).unwrap_err(); + assert!(err.contains("more than 32 files and directories"), "{err}"); + assert!(!into.join("d").exists()); + + // A directory entry counts as itself; a file under it then implies + // nothing new: 1 dir entry + 1 file. + let mut archive = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::fast(), + )); + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Directory); + header.set_size(0); + header.set_mode(0o755); + header.set_cksum(); + archive.append_data(&mut header, "a/", &b""[..]).unwrap(); + let mut header = tar::Header::new_gnu(); + header.set_size(0); + header.set_mode(0o644); + header.set_cksum(); + archive.append_data(&mut header, "a/x", &b""[..]).unwrap(); + let bytes = archive.into_inner().unwrap().finish().unwrap(); + let (_dir, into) = scratch(); + let err = extract_bundle_within(&bytes, &into, 2).unwrap_err(); + assert!(err.contains(PROJECT_MANIFEST), "{err}"); + assert!(into.join("a/x").exists()); + } + + // Counting what a path implies costs a lookup per component, so the + // path is held to a length first — one no filesystem would take anyway. + #[test] + fn an_entry_path_past_the_length_limit_is_refused() { + let long = (0..=MAX_ENTRY_PATH_BYTES / 2).map(|_| "a").collect::>().join("/"); + assert!(long.len() > MAX_ENTRY_PATH_BYTES); + let bytes = empty_files(&[long]); + let (_dir, into) = scratch(); + let err = extract_bundle(&bytes, &into).unwrap_err(); + assert!(err.contains("bytes long"), "{err}"); + assert!(!into.join("a").exists()); + } + + /// A gzipped tar of empty regular files at the given paths. + fn empty_files(paths: &[String]) -> Vec { let mut archive = tar::Builder::new(flate2::write::GzEncoder::new( Vec::new(), flate2::Compression::fast(), )); - for i in 0..=8 { + for path in paths { let mut header = tar::Header::new_gnu(); header.set_size(0); header.set_mode(0o644); header.set_cksum(); - archive.append_data(&mut header, format!("f/{i}"), &b""[..]).unwrap(); + archive.append_data(&mut header, path, &b""[..]).unwrap(); } - let bytes = archive.into_inner().unwrap().finish().unwrap(); - let err = extract_bundle_within(&bytes, &into, 8).unwrap_err(); - assert!(err.contains("more than 8 entries"), "{err}"); - let err = extract_bundle_within(&bytes, &into, 9).unwrap_err(); - assert!(err.contains(PROJECT_MANIFEST), "{err}"); + archive.into_inner().unwrap().finish().unwrap() } // A gzip-compressed module is bounded by what it expands to, read @@ -1188,6 +1424,61 @@ mod tests { assert!(err.contains("'etc/hostname' is not in the bundle"), "{err}"); } + // The loader reads an argument file into every canister that names it, + // so two canisters naming one file read it twice, and the budget counts + // both: what the project retains is bounded, not what the archive holds. + #[tokio::test] + async fn loading_the_project_is_held_to_its_read_budget() { + let manifest = "canisters:\n - name: a\n init_args:\n path: args.bin\n \ + format: bin\n build:\n steps:\n - type: pre-built\n \ + path: a.wasm\n - name: b\n init_args:\n path: args.bin\n \ + format: bin\n build:\n steps:\n - type: pre-built\n \ + path: a.wasm\n"; + let args = [7u8; 1000]; + let bytes = archive(&[("icp.yaml", manifest.as_bytes()), ("args.bin", &args)]); + + // Room for the manifest and one read of the file, not two. + let (_dir, into) = scratch(); + let fs = extract_bundle(&bytes, &into).unwrap(); + let host = host_within(fs, manifest.len() as u64 + 1500); + let err = host.project.load().await.expect_err("the second read is over budget"); + let chain = std::iter::successors(Some(&err as &dyn std::error::Error), |e| e.source()) + .map(ToString::to_string) + .collect::>() + .join(": "); + assert!(chain.contains("canister 'b'"), "{chain}"); + assert!(chain.contains("loading the project reads more than"), "{chain}"); + + // Room for both. + let (_dir, into) = scratch(); + let fs = extract_bundle(&bytes, &into).unwrap(); + let host = host_within(fs, manifest.len() as u64 + 2000); + let project = host.project.load().await.unwrap(); + assert_eq!(project.canisters.len(), 2); + } + + // The budget is the loader's alone: the phases read the bundle itself, + // where a module is read as many times as the deploy needs it. + #[tokio::test] + async fn the_read_budget_meters_reads_and_nothing_else() { + let (_dir, into) = scratch(); + let fs = extract_bundle( + &archive(&[("icp.yaml", b"canisters: []"), ("big", &[0u8; 100])]), + &into, + ) + .unwrap(); + let metered = Metered::new(fs.clone(), 150); + assert_eq!(metered.read(Utf8Path::new("big")).await.unwrap().len(), 100); + assert!(metered.is_file(Utf8Path::new("big")).await); + assert!(metered.exists(Utf8Path::new("big")).await); + let err = metered.read(Utf8Path::new("big")).await.unwrap_err().to_string(); + assert!(err.contains("loading the project reads more than"), "{err}"); + // A file the bundle does not hold is refused as such, not charged. + let err = metered.read(Utf8Path::new("missing")).await.unwrap_err().to_string(); + assert!(err.contains("not in the bundle"), "{err}"); + assert_eq!(fs.read(Utf8Path::new("big")).await.unwrap().len(), 100); + } + // A build writes its output to a scratch directory and the operation // reads it back through the same filesystem; nowhere else may be written, // and the scratch goes — from the disk and the index — with its holder. From a4cc17a178f260b26a9f336c1515182a8a7c13c0 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Wed, 4 Nov 2026 00:38:31 +0000 Subject: [PATCH 05/12] Read the bundle through icp-project's own confined filesystem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The index-backed filesystem went: the confinement it provided is now the library's own. dfinity/icp-cli#866 (issue #865) adds `files::Confined`, the host filesystem held to one directory: every path is resolved against the root before the disk sees it, and one that comes to rest outside — by climbing, by being absolute elsewhere, or through a symlink inside pointing out — is refused. The loader resolves every path a manifest names through the filesystem it is given, so that one seam holds all of them, canister directories included, which is also why the spelling check on canister directories went: a directory outside the root is not found, and the project does not load. What stays here is what the library cannot know about an archive: the bounds on its size, its decompressed size, its files and directories and their paths, the refusal of symlinks, device nodes and `.icp`, the metered view the loader reads through, and the checks on the bundle's shape before any phase runs. The four icp-cli crates are pinned to that branch's head until it is on main. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 148 ++++----- crates/imcp2-deploy/Cargo.toml | 12 +- crates/imcp2-deploy/src/host.rs | 548 ++++++-------------------------- crates/imcp2-deploy/src/lib.rs | 8 +- 4 files changed, 194 insertions(+), 522 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8ea8956..6869196 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -694,27 +694,27 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.136.4" +version = "0.136.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9c587da2696fbaa58e7a2fa2e5bfdd365d0928219f36992304ac1f15e5bc254" +checksum = "249a61efca6a54425e027f5704a6f68a0232b4df527e05a3ef947d89dbdcb078" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.136.4" +version = "0.136.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28a60027b7556b680b051651291053ec381edf4ffbc01784997ae51a64269bb" +checksum = "d5336a46e05db2d515440b1ce8a9363e7bc04b608c0f66cc98dddc5492a66fbf" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.136.4" +version = "0.136.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bfdb846acebfd591d601994ea283c98d525ac0bc9a84ca3f2c36bcf5b69ff1" +checksum = "01b3675b13214270fddbd257d5fbf4109be8b02d92fc636fe96e2829514a7235" dependencies = [ "cranelift-entity", "wasmtime-internal-core", @@ -722,9 +722,9 @@ dependencies = [ [[package]] name = "cranelift-bitset" -version = "0.136.4" +version = "0.136.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa9f8cc1080739d5f72b4ee7e00aff7d589f1284beaf0a19472c07878be7f299" +checksum = "d7c1d67aa02b6e174bd2b165c113e94af9498fcc20e2892d0f4d5ab8c9618d09" dependencies = [ "serde", "serde_derive", @@ -733,9 +733,9 @@ dependencies = [ [[package]] name = "cranelift-codegen" -version = "0.136.4" +version = "0.136.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d85417d493ecec833b5134546b4a252c78cc0bd5be11729a35d25dcff9e4e70b" +checksum = "26fe6f3ec5344c32602c414e986dcbe699fa501a3e3e7b02f887d9d12b14ae42" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -764,9 +764,9 @@ dependencies = [ [[package]] name = "cranelift-codegen-meta" -version = "0.136.4" +version = "0.136.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d01c8ed0098360bae69ad6b82952d4247516799ce8050d45ad168d6689f4eae6" +checksum = "96c5e6323d458486d95781defdb5016a1176fc911d8bc8e64b696adbda6e9044" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -777,24 +777,24 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" -version = "0.136.4" +version = "0.136.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04f9b86d809cec9c4e88b201f1b3ed3b0347674c9ccfbb8c355228669e34be81" +checksum = "f1260aa93b0bd48e8e55c1c7fd9ff6a260f1a1bc46e1f0151a5055b124e5b15d" [[package]] name = "cranelift-control" -version = "0.136.4" +version = "0.136.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce182038fb10b719841e65fdf5deeee258c684f63439f5be680c2be059274b58" +checksum = "50c179632a09850f81f1a1e9116cb09330c734a86689f3ee4c3f4ec81543acbd" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.136.4" +version = "0.136.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bca812b03a7b942796391f25bc02aa70a1a007fee3ce2756490c788429e9c39" +checksum = "d8c071188cb1c3b1803e23ae415a8d5f58d741d3a4b26a79b83dd61921112eac" dependencies = [ "cranelift-bitset", "serde", @@ -804,9 +804,9 @@ dependencies = [ [[package]] name = "cranelift-frontend" -version = "0.136.4" +version = "0.136.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0c30563d2aef95e8d1a00a920199c99117b350339599f0e9487a7a1cf5d59de" +checksum = "e2b85ba0a55d7685117b275fe6f36c819d438862e4223d28f0b97902460ff0ac" dependencies = [ "cranelift-codegen", "hashbrown 0.17.1", @@ -817,15 +817,15 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.136.4" +version = "0.136.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c54184333a275698edb1d3bdff226131a25d47133d792aa716e47e32b8b7f05" +checksum = "20ed364edcaed769da7b2709d540f8f7d0ff257749ba193f475e22dba6a0178b" [[package]] name = "cranelift-native" -version = "0.136.4" +version = "0.136.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb67a4c8a3180b5f1d77dffcd6317c05386ced42e5a31c7004523a9d99dc85c1" +checksum = "418d98c3dce08f0d13526692bbd1888bff4c5f45207f5093d168b5a224fbc88c" dependencies = [ "cranelift-codegen", "libc", @@ -834,9 +834,9 @@ dependencies = [ [[package]] name = "cranelift-srcgen" -version = "0.136.4" +version = "0.136.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b533db1679b4b728568bd490da6533cf50974c48a4d7534e78f1e0e02bc39769" +checksum = "4f016b9994e39cad68107f33fa5b8dcee8fb7e38332bde3f97690e5e8abe1b10" [[package]] name = "crc32fast" @@ -2171,8 +2171,8 @@ dependencies = [ [[package]] name = "icp-canister-interfaces" -version = "1.7.0" -source = "git+https://github.com/dfinity/icp-cli?rev=f4c7b6627cab56b091c01cc18102f5dd88aa811f#f4c7b6627cab56b091c01cc18102f5dd88aa811f" +version = "1.8.0" +source = "git+https://github.com/dfinity/icp-cli?rev=7f3c23188fb7be50447f90c4c8c749934d4759a3#7f3c23188fb7be50447f90c4c8c749934d4759a3" dependencies = [ "bigdecimal", "candid", @@ -2182,8 +2182,8 @@ dependencies = [ [[package]] name = "icp-events" -version = "1.7.0" -source = "git+https://github.com/dfinity/icp-cli?rev=f4c7b6627cab56b091c01cc18102f5dd88aa811f#f4c7b6627cab56b091c01cc18102f5dd88aa811f" +version = "1.8.0" +source = "git+https://github.com/dfinity/icp-cli?rev=7f3c23188fb7be50447f90c4c8c749934d4759a3#7f3c23188fb7be50447f90c4c8c749934d4759a3" dependencies = [ "serde", "tokio", @@ -2191,8 +2191,8 @@ dependencies = [ [[package]] name = "icp-project" -version = "1.7.0" -source = "git+https://github.com/dfinity/icp-cli?rev=f4c7b6627cab56b091c01cc18102f5dd88aa811f#f4c7b6627cab56b091c01cc18102f5dd88aa811f" +version = "1.8.0" +source = "git+https://github.com/dfinity/icp-cli?rev=7f3c23188fb7be50447f90c4c8c749934d4759a3#7f3c23188fb7be50447f90c4c8c749934d4759a3" dependencies = [ "async-trait", "bigdecimal", @@ -2239,8 +2239,8 @@ dependencies = [ [[package]] name = "icp-sync-plugin" -version = "1.7.0" -source = "git+https://github.com/dfinity/icp-cli?rev=f4c7b6627cab56b091c01cc18102f5dd88aa811f#f4c7b6627cab56b091c01cc18102f5dd88aa811f" +version = "1.8.0" +source = "git+https://github.com/dfinity/icp-cli?rev=7f3c23188fb7be50447f90c4c8c749934d4759a3#7f3c23188fb7be50447f90c4c8c749934d4759a3" dependencies = [ "async-trait", "bytes", @@ -3481,9 +3481,9 @@ dependencies = [ [[package]] name = "pulley-interpreter" -version = "49.0.4" +version = "49.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cd3de43f4f4cff536de8902097edbdf749321b97dda548f36a37918eb85bba3" +checksum = "1d74df9e20cba0f6dd07f01c3140535a6b23e40aecccfcacfb921621b41d0acb" dependencies = [ "cranelift-bitset", "log", @@ -3493,9 +3493,9 @@ dependencies = [ [[package]] name = "pulley-macros" -version = "49.0.4" +version = "49.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8319a8fa5269b4895957f8795861c42822bd26218e495f70060a963323d94847" +checksum = "8415513bfa407c06de6f11dbe9437f2352e075a5520ff491a131166d3d90aed1" dependencies = [ "proc-macro2", "quote", @@ -5515,9 +5515,9 @@ dependencies = [ [[package]] name = "wasmtime" -version = "49.0.4" +version = "49.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36abb25de5518707e5ba96b586366b09db168d2d4976cb9dac57265a317497ea" +checksum = "d45bf3b13781b9febb0eef42e1e145146c92ef891f345fd918e0e0fa1568c992" dependencies = [ "addr2line", "async-trait", @@ -5568,9 +5568,9 @@ dependencies = [ [[package]] name = "wasmtime-environ" -version = "49.0.4" +version = "49.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92f16a4d09fb1fbf2dcfeddb9818e1517a258e13b505b3bfdadcf47a19bf31ca" +checksum = "833f00f3dc6128159284c5b71e211f24c97d45e052230a14f767fd830a6fb8b0" dependencies = [ "anyhow", "cpp_demangle", @@ -5599,9 +5599,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-cache" -version = "49.0.4" +version = "49.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96e4469e885ac50f8dc06337245f9905b22c68d55416267f9afd07f7f76687be" +checksum = "85f1bafc5f2237c5fb6f8babc77b5db2eee8128766b8f1cfe52e771ebbcbed89" dependencies = [ "base64 0.22.1", "directories-next", @@ -5619,9 +5619,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-macro" -version = "49.0.4" +version = "49.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4fca03bd4425856574c0807d1dee16fad43f947feaf4e9ae1d2129596ee4de4" +checksum = "0d69c34412409072d2e8ec399101524c330e334ff313f77415e6984cc9c6a172" dependencies = [ "anyhow", "proc-macro2", @@ -5634,15 +5634,15 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-util" -version = "49.0.4" +version = "49.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4006009fc3598af4e895ab18c6c33d2c6b7c657b1bd0a755ca7ee1abef6f0c16" +checksum = "b168d39f2d6b896d89fe096afa4ccdd7a8e927b01469b590af84f02fca3126a1" [[package]] name = "wasmtime-internal-core" -version = "49.0.4" +version = "49.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "947a732ad659f42c10ec639706fc8cb1f068dd9a55eebde8a1f24dfd06dfa525" +checksum = "7ad86a65d12c6a9202584ceb658c9a20cf5b25c32eed61c9be5e5f863f88f0d9" dependencies = [ "anyhow", "hashbrown 0.17.1", @@ -5652,9 +5652,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-cranelift" -version = "49.0.4" +version = "49.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbfb8b455d524939e03df42e3e749f8e26e26ba2acd8b08fa00b3bda55686750" +checksum = "a71f0fd1dd40e4e18d4a20cdba29ff8e0eb2c0f21b12c2eb5869e0fea789dd05" dependencies = [ "cranelift-codegen", "cranelift-control", @@ -5678,9 +5678,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-fiber" -version = "49.0.4" +version = "49.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd8aa8711f68b2cdd94c2b85ba5c8317edaa1ac4ee0a0534b957123680239307" +checksum = "fa4dfa39e58735b24ce3d28bfc8c2b3041603cc8a941bb268395351beff90172" dependencies = [ "cc", "libc", @@ -5692,9 +5692,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-debug" -version = "49.0.4" +version = "49.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e873e4504faf50aa8ce255d6ea7210d87b39d5f379c2486782e2c5f179c70ca8" +checksum = "f2eb98d4486c7b0163447d77dea06f3cc87e5e5ba5fc93613f8ec42e27ea181d" dependencies = [ "cc", "object 0.40.0", @@ -5704,9 +5704,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-icache-coherence" -version = "49.0.4" +version = "49.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "197ab1fda5da958a5c0b8a97eb241beafc48ac568b82ef4f12f38d6866c74173" +checksum = "852cae234735d79db6c58fe0d04b32f4078ad01fdb3fd6d488ac366132ba9785" dependencies = [ "libc", "wasmtime-internal-core", @@ -5715,9 +5715,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-unwinder" -version = "49.0.4" +version = "49.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5577f3a5a979a7c3b1b23c1e9f13e70a8583acc8828471ec56f282117fc4952b" +checksum = "b9289378c82b9db7e7cf933d6bb441ad968536ac48f3f38c55b7cf9d3e8d87e1" dependencies = [ "cranelift-codegen", "log", @@ -5727,9 +5727,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-versioned-export-macros" -version = "49.0.4" +version = "49.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6399c9c4050ed431cf66e21c05fadd6f9731db44476f452f8c7142b4597b883" +checksum = "cc5c60c732d99dbcc5d670b71ba6ccf2b015cd16fc828990eaad2c4b8805ebdb" dependencies = [ "proc-macro2", "quote", @@ -5738,9 +5738,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-wit-bindgen" -version = "49.0.4" +version = "49.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4eaf45d4da7681d234bb6efd846b4a528b08a75384a7fc23f125d0145364b06" +checksum = "c77e4fcc7cd22772578458c5e233df47190ebc9b8b65788797d99a02787c9000" dependencies = [ "anyhow", "bitflags", @@ -5752,9 +5752,9 @@ dependencies = [ [[package]] name = "wasmtime-wasi" -version = "49.0.4" +version = "49.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "821e45765c35555d3b0bdf9277cb99478f7d1f29a17b3315ba76e01220f02a38" +checksum = "705b90d1fa1084cc73df656868948da77711b97f6b895d145a4949f86bcea674" dependencies = [ "async-trait", "bitflags", @@ -5777,9 +5777,9 @@ dependencies = [ [[package]] name = "wasmtime-wasi-io" -version = "49.0.4" +version = "49.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cdff38d1c730bdaf4fcfbe0eedf3265fb9c7a4deb119deecbfc1c1778950716" +checksum = "f3d2b6a255be488639f450b380e7e0923789f946de3463696cd30f2fe8a65cc3" dependencies = [ "async-trait", "bytes", @@ -5859,9 +5859,9 @@ dependencies = [ [[package]] name = "wiggle" -version = "49.0.4" +version = "49.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af3872869836d404f722e2361f869c09630e43a73fcc7060f7892a929b74b6a3" +checksum = "f1ef4f7720340c50b9d28718516f7ab881a169b9021268e7dc67729435d9ccfd" dependencies = [ "bitflags", "thiserror 2.0.18", @@ -5873,9 +5873,9 @@ dependencies = [ [[package]] name = "wiggle-generate" -version = "49.0.4" +version = "49.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf7e31dec6de37e7e848aecbad54777dd246cd440a1a547ab1a1e31c27f55e49" +checksum = "1692e27a88a6fa5bacd69a645e982a439c5ae835d9180394992627b0124cb705" dependencies = [ "heck", "proc-macro2", @@ -5887,9 +5887,9 @@ dependencies = [ [[package]] name = "wiggle-macro" -version = "49.0.4" +version = "49.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2af323f33b28ea84b24e2bccef19e51c7a75aa1d2929280ad1a4865f2877dcc" +checksum = "562354c56fa0b83911512f0b60baf33c824e431de67a6012939ce52d224713b9" dependencies = [ "proc-macro2", "quote", diff --git a/crates/imcp2-deploy/Cargo.toml b/crates/imcp2-deploy/Cargo.toml index 84f0891..a80425f 100644 --- a/crates/imcp2-deploy/Cargo.toml +++ b/crates/imcp2-deploy/Cargo.toml @@ -44,6 +44,10 @@ tempfile = "3" # one commit so the four stay in lockstep (they share a workspace version and # are not released independently). Bump the `rev` on all four together. # +# The pinned commit is the head of dfinity/icp-cli#866, the branch that adds +# `files::Confined` (the filesystem the bundle host reads through, see +# `src/host.rs`); once it is on icp-cli's main, re-pin to main. +# # * icp-project: manifests, the deploy operation, the id/artifact stores. # * icp-events: the progress event stream the operations report on. # * icp-sync-plugin: the wasmtime runtime that runs a canister's sync plugin @@ -51,10 +55,10 @@ tempfile = "3" # brings wasmtime into this workspace. # * icp-canister-interfaces: the engine-canister registry id a created # canister's subnet is looked up in. -icp-project = { git = "https://github.com/dfinity/icp-cli", rev = "f4c7b6627cab56b091c01cc18102f5dd88aa811f" } -icp-events = { git = "https://github.com/dfinity/icp-cli", rev = "f4c7b6627cab56b091c01cc18102f5dd88aa811f" } -icp-sync-plugin = { git = "https://github.com/dfinity/icp-cli", rev = "f4c7b6627cab56b091c01cc18102f5dd88aa811f" } -icp-canister-interfaces = { git = "https://github.com/dfinity/icp-cli", rev = "f4c7b6627cab56b091c01cc18102f5dd88aa811f" } +icp-project = { git = "https://github.com/dfinity/icp-cli", rev = "7f3c23188fb7be50447f90c4c8c749934d4759a3" } +icp-events = { git = "https://github.com/dfinity/icp-cli", rev = "7f3c23188fb7be50447f90c4c8c749934d4759a3" } +icp-sync-plugin = { git = "https://github.com/dfinity/icp-cli", rev = "7f3c23188fb7be50447f90c4c8c749934d4759a3" } +icp-canister-interfaces = { git = "https://github.com/dfinity/icp-cli", rev = "7f3c23188fb7be50447f90c4c8c749934d4759a3" } [dev-dependencies] # The tests hand-build small bundles (a manifest plus a wasm) and load them diff --git a/crates/imcp2-deploy/src/host.rs b/crates/imcp2-deploy/src/host.rs index 1646216..d827bcb 100644 --- a/crates/imcp2-deploy/src/host.rs +++ b/crates/imcp2-deploy/src/host.rs @@ -14,21 +14,27 @@ //! assumed but enforced, in three places: //! //! * **Extraction** writes only regular files and directories, within a total -//! size budget, under the scratch directory — and keeps the list of what it -//! wrote ([`extract_bundle`]). -//! * **The filesystem the library reads through is that list**, not the disk -//! ([`BundleFs`]). A path the manifest names is normalized against the -//! bundle root (`..` cannot climb above it, an absolute path is re-anchored -//! at it) and looked up in the index; only a path the archive carried is -//! read, from where extraction put it. Nothing on the machine is reachable -//! by any spelling. The disk is the byte store because the sync-plugin -//! runtime needs real paths to mount. +//! size budget and an object budget, under the scratch directory +//! ([`extract_bundle`]). +//! * **The filesystem the library reads through is confined to that +//! directory** — the library's own [`Confined`], the host filesystem held +//! to one root. A path the manifest names is resolved against the root +//! (`..` cannot climb above it, an absolute path elsewhere is refused, a +//! symlink pointing out leads nowhere) before the disk sees it. The loader +//! resolves every path a manifest names through that one seam, so nothing +//! on the machine is reachable by any spelling. The disk is the byte store +//! because the sync-plugin runtime needs real paths to mount. //! * **The bundle is checked before any phase runs** ([`check_bundle`]): every //! canister built by one pre-built wasm in the archive that hashes to its //! recorded checksum, every sync plugin in the archive, no script steps //! (which the library would run as host subprocesses; [`NoScripts`] refuses //! them again at the seams, as a backstop), and no network but `ic`. //! +//! The confinement comes from upstream: `icp project bundle` holds every path +//! to the project it bundles, and dfinity/icp-cli#866 (issue #865) gives the +//! loader the same rule for a project that came from elsewhere. This crate +//! pins that change; until it is on icp-cli's main, the pin is its branch. +//! //! Everything the project library reads or writes — the manifest, the wasms, //! the `.icp` id and artifact stores, the build scratch — lives under the //! extracted directory, which is removed when the deploy ends. @@ -37,12 +43,11 @@ use std::{ collections::{BTreeMap, BTreeSet}, fmt, io::Read, - ops::Bound, sync::{Arc, Mutex, PoisonError}, }; use async_trait::async_trait; -use camino::{Utf8Component, Utf8Path, Utf8PathBuf}; +use camino::{Utf8Path, Utf8PathBuf}; use icp_events::StepReporter; use icp_project::{ canister::{ @@ -54,7 +59,7 @@ use icp_project::{ }, wasm::{Fetch, FetchError}, }, - files::{FileSystem, FsError, Scratch}, + files::{Confined, FileSystem, FsError, Scratch}, host::{Host, Ignore}, manifest::{ prebuilt::SourceField, BuildStep, ProjectRootLocate, ProjectRootLocateImpl, SyncStep, @@ -90,8 +95,8 @@ const STATE_DIR: &str = ".icp"; /// The most files and directories an archive may extract to, counting the /// directories its entries' paths imply and extraction creates on the way. -/// The byte budget alone would admit millions of empty files, each a path in -/// the index the loader walks, or a few files at the bottom of millions of +/// The byte budget alone would admit millions of empty files, each a path +/// the loader may walk, or a few files at the bottom of millions of /// directories, each an inode; a real bundle is a manifest, a few modules and /// plugins, and a frontend's assets, well inside this. pub const MAX_BUNDLE_ENTRIES: usize = 250_000; @@ -206,294 +211,36 @@ impl Access for IcMainnet { // ── The filesystem ──────────────────────────────────────────────────────── -/// The extracted bundle, as the filesystem the project library reads through. -/// -/// What the archive carried is an index of relative paths, filled in by -/// [`extract_bundle`] as it writes each entry. Every path the library asks -/// about is [normalized](BundleFs::normalize) against the bundle root and -/// answered from that index: present, it is read from under the root; absent, -/// it is "not in the bundle", whatever the disk holds at that spelling. So a -/// manifest's `path:` — absolute, climbing, or plain — can name only what the -/// archive brought with it. +/// The extracted bundle, as the filesystem the project library reads +/// through: the library's own [`Confined`], rooted at the scratch directory. /// -/// The one place a write may land is a scratch directory this filesystem made -/// (a pre-built build step copies its module there and the operation reads it -/// back); written files join the index and leave it with the directory. -#[derive(Clone, Debug)] -pub struct BundleFs(Arc); - -#[derive(Debug)] -struct Inner { - /// Canonical, so the absolute paths the library derives from it strip - /// back to the index's relative spellings whatever the scratch directory's - /// own path goes through. - root: Utf8PathBuf, - state: Mutex, -} - -#[derive(Debug, Default)] -struct State { - /// Every file in the bundle, relative to the root. - files: BTreeSet, - /// Scratch directories currently held, relative to the root: the prefixes - /// a write is allowed under. - scratch: BTreeSet, -} - -impl Inner { - fn state(&self) -> std::sync::MutexGuard<'_, State> { - self.state.lock().unwrap_or_else(PoisonError::into_inner) - } -} - -impl State { - /// The entries of `set` strictly under `rel`. A path set orders by - /// component, so a directory's descendants sit right after it: one - /// range lookup, then take while the prefix holds, never a scan. - fn under<'a>( - set: &'a BTreeSet, - rel: &'a Utf8Path, - ) -> impl Iterator + 'a { - set.range::((Bound::Excluded(rel), Bound::Unbounded)) - .take_while(move |p| p.starts_with(rel)) - } - - fn is_dir(&self, rel: &Utf8Path) -> bool { - rel.as_str().is_empty() - || self.scratch.contains(rel) - || Self::under(&self.files, rel).next().is_some() - || Self::under(&self.scratch, rel).next().is_some() - } - - fn in_scratch(&self, rel: &Utf8Path) -> bool { - rel.ancestors().skip(1).any(|a| self.scratch.contains(a)) - } -} - -/// Why the bundle could not serve a request. Wrapped in [`FsError`] at the -/// trait boundary, which displays it as itself. -#[derive(Debug)] -pub enum BundleFsError { - NotInBundle(Utf8PathBuf), - ReadOnly(Utf8PathBuf), - Io(Utf8PathBuf, std::io::Error), -} - -impl fmt::Display for BundleFsError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::NotInBundle(p) => write!( - f, - "'{p}' is not in the bundle; a bundle carries every file its manifest names \ - (`icp project bundle` writes one that does)" - ), - Self::ReadOnly(p) => write!(f, "cannot write '{p}': a bundle is read-only"), - Self::Io(p, e) => write!(f, "'{p}': {e}"), - } - } -} - -impl std::error::Error for BundleFsError {} - -impl From for FsError { - fn from(e: BundleFsError) -> Self { - FsError::new(e) - } -} - -impl BundleFs { - /// An empty filesystem rooted at `dir`, which must exist. - pub fn new(dir: &Utf8Path) -> Result { - let root = std::fs::canonicalize(dir) - .map_err(|e| format!("cannot resolve '{dir}': {e}")) - .and_then(|p| { - Utf8PathBuf::from_path_buf(p).map_err(|p| format!("'{}' is not UTF-8", p.display())) - })?; - Ok(Self(Arc::new(Inner { root, state: Mutex::default() }))) - } - - /// The directory the bundle is extracted into. - pub fn root(&self) -> &Utf8Path { - &self.0.root - } - - /// Record a file extraction wrote, by its path in the archive. - fn index(&self, archive_path: &Utf8Path) { - self.0.state().files.insert(self.normalize(archive_path)); - } - - /// Resolve `path` to the relative spelling the index is keyed by: under - /// the root, with `.` dropped and `..` applied but never climbing above the - /// root, and an absolute path from elsewhere re-anchored at the root (so - /// it names what the bundle would hold there, which is nothing). - pub fn normalize(&self, path: &Utf8Path) -> Utf8PathBuf { - let path = path.strip_prefix(&self.0.root).unwrap_or(path); - let mut out = Utf8PathBuf::new(); - for component in path.components() { - match component { - Utf8Component::Normal(part) => out.push(part), - Utf8Component::ParentDir => { - out.pop(); - } - Utf8Component::CurDir | Utf8Component::RootDir | Utf8Component::Prefix(_) => {} - } - } - out - } - - /// Whether `dir` is spelled under the bundle root without climbing: the - /// root itself or a descendant, by path alone. A spelling that - /// normalizes into the root but is not written under it (`/etc`, - /// `/../x`) is not held, because a reader that takes the spelling - /// as a host path would leave the bundle. - pub fn holds_dir(&self, dir: &Utf8Path) -> bool { - match dir.strip_prefix(&self.0.root) { - Ok(rest) => rest - .components() - .all(|c| matches!(c, Utf8Component::Normal(_) | Utf8Component::CurDir)), - Err(_) => false, - } - } - - /// Whether the bundle holds a file at `path`. - pub fn contains(&self, path: &Utf8Path) -> bool { - self.0.state().files.contains(&self.normalize(path)) - } - - /// Where a file in the bundle is on disk, for a runtime that needs a real - /// path, or "not in the bundle". - pub fn locate(&self, path: &Utf8Path) -> Result { - let rel = self.normalize(path); - if self.0.state().files.contains(&rel) { - Ok(self.0.root.join(rel)) - } else { - Err(BundleFsError::NotInBundle(rel)) - } - } - - fn read_bytes(&self, path: &Utf8Path) -> Result, BundleFsError> { - let on_disk = self.locate(path)?; - std::fs::read(&on_disk).map_err(|e| BundleFsError::Io(self.normalize(path), e)) - } - - fn write_bytes(&self, path: &Utf8Path, contents: &[u8]) -> Result<(), BundleFsError> { - let rel = self.normalize(path); - let mut state = self.0.state(); - if !state.in_scratch(&rel) { - return Err(BundleFsError::ReadOnly(rel)); - } - let on_disk = self.0.root.join(&rel); - if let Some(parent) = on_disk.parent() { - std::fs::create_dir_all(parent).map_err(|e| BundleFsError::Io(rel.clone(), e))?; - } - std::fs::write(&on_disk, contents).map_err(|e| BundleFsError::Io(rel.clone(), e))?; - state.files.insert(rel); - Ok(()) - } -} - -/// A scratch directory under the root: on disk for as long as this is held, -/// and in the index for exactly as long. -struct ScratchDir { - _dir: tempfile::TempDir, - rel: Utf8PathBuf, - path: Utf8PathBuf, - fs: Arc, -} - -impl Scratch for ScratchDir { - fn path(&self) -> &Utf8Path { - &self.path +/// Every path the library asks about is resolved against that root before +/// the disk sees it, so a manifest's `path:` — absolute, climbing, or plain — +/// can name only what the archive brought with it; what is not there is +/// "not in the bundle", whatever the machine holds at that spelling. A +/// scratch directory the library asks for (a pre-built build step copies its +/// module there and the operation reads it back) is made under the root too. +pub type BundleFs = Confined; + +/// Where a file in the bundle is on disk, for a runtime that needs a real +/// path, or why there is none: the path leaves the root, or nothing is there. +pub fn locate(fs: &BundleFs, path: &Utf8Path) -> Result { + let on_disk = fs.resolve(path).map_err(|e| Unsupported(e.to_string()))?; + if on_disk.is_file() { + Ok(on_disk) + } else { + Err(Unsupported(format!( + "'{}' is not in the bundle; a bundle carries every file its manifest names (`icp \ + project bundle` writes one that does)", + on_disk.strip_prefix(fs.root()).unwrap_or(&on_disk) + ))) } } -impl Drop for ScratchDir { - fn drop(&mut self) { - let mut state = self.fs.state(); - state.scratch.remove(&self.rel); - state.files.retain(|f| !f.starts_with(&self.rel)); - } -} - -#[async_trait] -impl FileSystem for BundleFs { - async fn read(&self, path: &Utf8Path) -> Result, FsError> { - Ok(self.read_bytes(path)?) - } - - async fn read_to_string(&self, path: &Utf8Path) -> Result { - let bytes = self.read_bytes(path)?; - String::from_utf8(bytes).map_err(|e| { - FsError::new(BundleFsError::Io(self.normalize(path), std::io::Error::other(e))) - }) - } - - async fn write(&self, path: &Utf8Path, contents: &[u8]) -> Result<(), FsError> { - Ok(self.write_bytes(path, contents)?) - } - - /// A directory is implied by the files beneath it, so in a scratch area - /// there is nothing to create; anywhere else there is nothing that may be. - async fn create_dir_all(&self, path: &Utf8Path) -> Result<(), FsError> { - let rel = self.normalize(path); - let state = self.0.state(); - if state.scratch.contains(&rel) || state.in_scratch(&rel) { - return Ok(()); - } - Err(BundleFsError::ReadOnly(rel).into()) - } - - async fn copy(&self, from: &Utf8Path, to: &Utf8Path) -> Result<(), FsError> { - let contents = self.read_bytes(from)?; - Ok(self.write_bytes(to, &contents)?) - } - - async fn exists(&self, path: &Utf8Path) -> bool { - let rel = self.normalize(path); - let state = self.0.state(); - state.files.contains(&rel) || state.is_dir(&rel) - } - - async fn is_file(&self, path: &Utf8Path) -> bool { - self.contains(path) - } - - async fn is_dir(&self, path: &Utf8Path) -> bool { - self.0.state().is_dir(&self.normalize(path)) - } - - async fn read_dir(&self, path: &Utf8Path) -> Result, FsError> { - let prefix = self.normalize(path); - let state = self.0.state(); - let mut children: Vec = State::under(&state.files, &prefix) - .chain(State::under(&state.scratch, &prefix)) - .filter_map(|entry| entry.strip_prefix(&prefix).ok()) - .filter_map(|relative| relative.components().next()) - .map(|first| self.0.root.join(&prefix).join(first.as_str())) - .collect(); - children.sort(); - children.dedup(); - Ok(children) - } - - /// Nothing in the bundle is a symlink and every path is normalized on the - /// way in, so identity is the normalized path, under the root. - async fn canonicalize(&self, path: &Utf8Path) -> Option { - let rel = self.normalize(path); - self.exists(&rel).await.then(|| self.0.root.join(rel)) - } - - async fn scratch_dir(&self) -> Result, FsError> { - let dir = tempfile::Builder::new() - .prefix(".scratch-") - .tempdir_in(&self.0.root) - .map_err(|e| FsError::new(BundleFsError::Io(".scratch".into(), e)))?; - let path = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()) - .map_err(|p| FsError::new(Unsupported(format!("'{}' is not UTF-8", p.display()))))?; - let rel = self.normalize(&path); - self.0.state().scratch.insert(rel.clone()); - Ok(Box::new(ScratchDir { _dir: dir, rel, path, fs: self.0.clone() })) - } +/// The bytes of a file in the bundle, read where [`locate`] says. +fn read_bytes(fs: &BundleFs, path: &Utf8Path) -> Result, Unsupported> { + let on_disk = locate(fs, path)?; + std::fs::read(&on_disk).map_err(|e| Unsupported(format!("'{on_disk}': {e}"))) } /// [`BundleFs`] with a budget on what is read through it: the filesystem @@ -515,9 +262,9 @@ impl Metered { /// Charge `path`'s size to the budget, or refuse the read. fn charge(&self, path: &Utf8Path) -> Result<(), FsError> { - let on_disk = self.inner.locate(path)?; + let on_disk = locate(&self.inner, path).map_err(FsError::new)?; let size = std::fs::metadata(&on_disk) - .map_err(|e| BundleFsError::Io(self.inner.normalize(path), e))? + .map_err(|e| FsError::new(Unsupported(format!("'{on_disk}': {e}"))))? .len(); let mut left = self.left.lock().unwrap_or_else(PoisonError::into_inner); if size > *left { @@ -601,13 +348,12 @@ impl Fetch for BundledWasm { match source { SourceField::Local(local) => { let path = base_dir.join(&local.path); - let on_disk = self.0.locate(&path).map_err(FetchError::new)?; + let on_disk = locate(&self.0, &path).map_err(FetchError::new)?; if let Some(expected) = sha256 { reporter.info(format!("Reading wasm: {}", local.path)); - let bytes = self.0.read_bytes(&path).map_err(FetchError::new)?; + let bytes = read_bytes(&self.0, &path).map_err(FetchError::new)?; reporter.info("Verifying checksum"); - verify_checksum(&self.0.normalize(&path), expected, &bytes) - .map_err(FetchError::new)?; + verify_checksum(&local.path, expected, &bytes).map_err(FetchError::new)?; } Ok(on_disk) } @@ -718,24 +464,20 @@ impl ScriptRunner for NoScripts { /// phase runs, so a bundle that would fail at its build or sync phase, or /// be refused there, does not first spend cycles creating canisters. /// -/// Each canister lives in a directory under the bundle root, and is built by -/// exactly one pre-built step naming a wasm in the archive that hashes to the -/// checksum the manifest records; each sync step is a plugin in the archive -/// (hashing to its checksum, if one is recorded); no step is a script. The -/// environment's network is the Internet Computer. +/// Each canister is built by exactly one pre-built step naming a wasm in the +/// archive that hashes to the checksum the manifest records; each sync step +/// is a plugin in the archive (hashing to its checksum, if one is recorded); +/// no step is a script. The environment's network is the Internet Computer. /// /// Canister names must also differ by more than letter case: the library's /// artifact store keys a canister's build output by its name on disk, and /// a case-insensitive volume (macOS and Windows by default) would hand the /// last-built module to both. /// -/// The directory check matters because not every reader goes through -/// [`BundleFs`]: the sync-plugin runtime takes the canister directory as a -/// host path and confines a plugin's declared files to the project directory -/// only when the canister's lies inside it — a canister directory outside the -/// project is its own sandbox root. A manifest may spell a canister's path -/// absolutely, and the bundle can carry the `canister.yaml` the loader looks -/// for at that spelling, so the directory must be held to the root here. +/// Where each canister lives is the loader's business: it finds a canister +/// directory through the confined filesystem, so one outside the bundle root +/// — which the sync-plugin runtime would take as its own sandbox root — is +/// not found and the project does not load. pub fn check_bundle<'a>( fs: &BundleFs, network: &Network, @@ -758,14 +500,8 @@ pub fn check_bundle<'a>( for (dir, canister) in canisters { let name = &canister.name; let at = |e: Unsupported| format!("canister '{name}': {e}"); - if !fs.holds_dir(dir) { - return Err(at(Unsupported(format!( - "its directory '{dir}' is not under the bundle root; a bundle's canisters live \ - under its root, which is where `icp project bundle` puts them" - )))); - } let present = |path: &Utf8Path| -> Result, String> { - fs.read_bytes(&dir.join(path)).map_err(|e| format!("canister '{name}': {e}")) + read_bytes(fs, &dir.join(path)).map_err(|e| format!("canister '{name}': {e}")) }; if canister.build.steps.iter().any(|s| matches!(s, BuildStep::Script(_))) { @@ -870,7 +606,7 @@ fn host_within(fs: BundleFs, project_bytes: u64) -> Host { } /// Extract a bundle archive into `into`, which must exist and be empty, and -/// return it as a filesystem indexed by what was written. +/// return it as the filesystem the deploy reads through, confined to it. /// /// Only regular files and directories are written — `icp project bundle` /// writes nothing else, and a symlink or a device node is how an archive @@ -897,7 +633,7 @@ fn extract_bundle_within( MAX_BUNDLE_BYTES )); } - let fs = BundleFs::new(into)?; + let fs = Confined::new(into).map_err(|e| e.to_string())?; let unreadable = |e: std::io::Error| format!("the bundle is not a readable .tar.gz archive: {e}"); let decoder = Limited { inner: flate2::read::GzDecoder::new(bytes), left: MAX_EXTRACTED_BYTES }; @@ -914,8 +650,8 @@ fn extract_bundle_within( ever has" ) }; - // The directories written as entries of their own; the ones files imply - // are read off the index. + // The directories that exist so far, written as entries or implied by + // the paths of earlier ones, relative to the root. let mut dirs: BTreeSet = BTreeSet::new(); for entry in archive.entries().map_err(unreadable)? { let mut entry = entry.map_err(unreadable)?; @@ -940,7 +676,17 @@ fn extract_bundle_within( a bundle never carries; rebuild it with `icp project bundle`" )); } - let rel = fs.normalize(&path); + // Where the entry lands, relative to the root: `tar` refuses a path + // that would leave `into`, so resolving it against the root is only + // a matter of spelling (`./x` is `x`). + let rel = match fs.resolve(&path) { + Ok(resolved) => resolved.strip_prefix(fs.root()).unwrap_or(&resolved).to_path_buf(), + Err(_) => { + return Err(format!( + "the archive entry '{path}' would land outside the extraction directory" + )) + } + }; // `.icp` is the library's own state under the project root: the id // mappings a deploy reads to decide what exists, and the artifact // store. A bundle never carries it (`icp project bundle` writes @@ -954,21 +700,17 @@ fn extract_bundle_within( never carries; rebuild it with `icp project bundle`" )); } - // The parents this entry implies that nothing before it did. A - // directory is known once a file sits under it or an entry wrote it, - // and a known directory's own parents are known too, so the walk up - // stops at the first one. - let implied = { - let state = fs.0.state(); - rel.ancestors() - .skip(1) - .filter(|dir| !dir.as_str().is_empty()) - .take_while(|dir| { - !dirs.contains(*dir) && State::under(&state.files, dir).next().is_none() - }) - .count() - }; - objects = objects.saturating_add(implied); + // The parents this entry implies that nothing before it did. A known + // directory's own parents are known too, so the walk up stops at the + // first one. + let implied: Vec = rel + .ancestors() + .skip(1) + .filter(|dir| !dir.as_str().is_empty()) + .take_while(|dir| !dirs.contains(*dir)) + .map(Utf8Path::to_path_buf) + .collect(); + objects = objects.saturating_add(implied.len()); if objects > max_entries { return Err(too_many()); } @@ -977,14 +719,13 @@ fn extract_bundle_within( return Err(too_large()); } if entry.unpack_in(fs.root().as_std_path()).map_err(unreadable)? { - if kind.is_file() { - fs.index(&path); - } else { + dirs.extend(implied); + if kind.is_dir() { dirs.insert(rel); } } } - if !fs.contains(Utf8Path::new(PROJECT_MANIFEST)) { + if !fs.root().join(PROJECT_MANIFEST).is_file() { return Err(format!( "the archive carries no `{PROJECT_MANIFEST}` at its root, so it is not an icp \ project bundle (`icp project bundle` writes one)" @@ -1332,61 +1073,13 @@ mod tests { gunzipped_within(path, b"\0asm plain", 1).unwrap(); } - // Directory lookups come from the index by range, not by scanning it: - // what is under a directory, and whether anything is. - #[test] - fn directory_lookups_follow_the_index() { - let (_dir, into) = scratch(); - let fs = extract_bundle( - &archive(&[ - ("icp.yaml", b"canisters: []"), - ("a/b/c.txt", b"1"), - ("a/b/d.txt", b"2"), - ("a/e.txt", b"3"), - ("ab/f.txt", b"4"), - ]), - &into, - ) - .unwrap(); - let state = fs.0.state(); - assert!(state.is_dir(Utf8Path::new("a"))); - assert!(state.is_dir(Utf8Path::new("a/b"))); - assert!(!state.is_dir(Utf8Path::new("a/b/c.txt"))); - assert!(!state.is_dir(Utf8Path::new("a/x"))); - let under: Vec<_> = State::under(&state.files, Utf8Path::new("a")).collect(); - assert_eq!(under.len(), 3, "{under:?}"); - assert!(under.iter().all(|p| p.starts_with("a")), "{under:?}"); - drop(state); - let children = tokio::runtime::Runtime::new() - .unwrap() - .block_on(fs.read_dir(&fs.root().join("a"))) - .unwrap(); - assert_eq!(children, vec![fs.root().join("a/b"), fs.root().join("a/e.txt")]); - } - - // A canister directory is held to the bundle root by its spelling, since - // the sync-plugin runtime takes it as a host path: inside by name is - // held, and `/etc` or a climb out of the root is not, however it would - // normalize. - #[test] - fn a_canister_directory_is_under_the_root_by_spelling() { - let (_dir, into) = scratch(); - let fs = extract_bundle(&archive(&[("icp.yaml", b"canisters: []")]), &into).unwrap(); - let root = fs.root().to_path_buf(); - assert!(fs.holds_dir(&root)); - assert!(fs.holds_dir(&root.join("backend"))); - assert!(fs.holds_dir(&root.join("./backend"))); - assert!(!fs.holds_dir(Utf8Path::new("/etc"))); - assert!(!fs.holds_dir(&root.join("../elsewhere"))); - assert!(!fs.holds_dir(&root.join("backend/../../elsewhere"))); - } - - // The index is the filesystem: what extraction wrote is readable by any - // spelling that normalizes to it, and nothing else is, however the disk - // looks at the spelling — absolute, climbing, or a file that happens to - // exist under the root without having come from the archive. + // The confined filesystem serves what the archive carried, by any spelling + // that resolves to it, and refuses what resolves outside the root — an + // absolute path elsewhere, a climb above the root — whatever the machine + // holds at that spelling. (The filesystem's own tests, upstream, cover + // the spellings and the symlinks; this is the bundle seen through it.) #[tokio::test] - async fn the_bundle_serves_its_index_and_nothing_else() { + async fn the_bundle_serves_what_it_carries_and_nothing_outside_it() { let (_dir, into) = scratch(); let fs = extract_bundle( &archive(&[("icp.yaml", b"canisters: []"), ("./canisters/a.wasm", b"wasm")]), @@ -1398,30 +1091,30 @@ mod tests { for spelling in ["canisters/a.wasm", "./canisters/a.wasm", "canisters/../canisters/a.wasm"] { assert_eq!(fs.read(Utf8Path::new(spelling)).await.unwrap(), b"wasm", "{spelling}"); + assert_eq!( + locate(&fs, Utf8Path::new(spelling)).unwrap(), + root.join("canisters/a.wasm") + ); } - // The absolute paths the library derives from the root strip back. + // The absolute paths the library derives from the root resolve too. assert_eq!(fs.read(&root.join("canisters/a.wasm")).await.unwrap(), b"wasm"); - assert_eq!( - fs.canonicalize(Utf8Path::new("canisters/a.wasm")).await, - Some(root.join("canisters/a.wasm")) - ); assert!(fs.is_dir(Utf8Path::new("canisters")).await); assert!(fs.is_dir(&root).await); - assert!(!fs.is_dir(Utf8Path::new("canisters/a.wasm")).await); assert_eq!( fs.read_dir(&root).await.unwrap(), vec![root.join("canisters"), root.join("icp.yaml")] ); - std::fs::write(root.join("planted"), b"not from the archive").unwrap(); - for escape in ["/etc/hostname", "../../../../etc/hostname", "canisters/../../x", "planted"] - { + for escape in ["/etc/hostname", "../../../../etc/hostname", "canisters/../../x"] { let err = fs.read(Utf8Path::new(escape)).await.unwrap_err().to_string(); - assert!(err.contains("not in the bundle"), "{escape}: {err}"); + assert!(err.contains("is outside"), "{escape}: {err}"); assert!(!fs.exists(Utf8Path::new(escape)).await, "{escape}"); + let err = locate(&fs, Utf8Path::new(escape)).unwrap_err().to_string(); + assert!(err.contains("is outside"), "{escape}: {err}"); } - let err = fs.locate(Utf8Path::new("/etc/hostname")).unwrap_err().to_string(); - assert!(err.contains("'etc/hostname' is not in the bundle"), "{err}"); + // Inside the root but not there: not in the bundle. + let err = locate(&fs, Utf8Path::new("canisters/b.wasm")).unwrap_err().to_string(); + assert!(err.contains("'canisters/b.wasm' is not in the bundle"), "{err}"); } // The loader reads an argument file into every canister that names it, @@ -1478,33 +1171,4 @@ mod tests { assert!(err.contains("not in the bundle"), "{err}"); assert_eq!(fs.read(Utf8Path::new("big")).await.unwrap().len(), 100); } - - // A build writes its output to a scratch directory and the operation - // reads it back through the same filesystem; nowhere else may be written, - // and the scratch goes — from the disk and the index — with its holder. - #[tokio::test] - async fn a_build_output_lands_in_scratch_and_nowhere_else() { - let (_dir, into) = scratch(); - let fs = extract_bundle(&archive(&[("icp.yaml", b"canisters: []")]), &into).unwrap(); - let scratch = fs.scratch_dir().await.unwrap(); - assert!(scratch.path().starts_with(fs.root())); - let out = scratch.path().join("out/module.wasm"); - fs.write(&out, b"built").await.unwrap(); - assert_eq!(fs.read(&out).await.unwrap(), b"built"); - assert!(fs.is_file(&out).await); - assert!(fs.is_dir(scratch.path()).await); - assert!(fs.create_dir_all(&scratch.path().join("more")).await.is_ok()); - - let err = fs.write(Utf8Path::new("canisters/x.wasm"), b"no").await.unwrap_err().to_string(); - assert!(err.contains("read-only"), "{err}"); - let err = fs.write(Utf8Path::new("/tmp/x"), b"no").await.unwrap_err().to_string(); - assert!(err.contains("read-only"), "{err}"); - assert!(!fs.root().join("tmp/x").exists()); - assert!(fs.create_dir_all(Utf8Path::new("elsewhere")).await.is_err()); - - let on_disk = out.clone(); - drop(scratch); - assert!(!fs.exists(&out).await, "scratch leaves the index with its holder"); - assert!(!on_disk.exists(), "and the disk"); - } } diff --git a/crates/imcp2-deploy/src/lib.rs b/crates/imcp2-deploy/src/lib.rs index f798d3e..5cd62cb 100644 --- a/crates/imcp2-deploy/src/lib.rs +++ b/crates/imcp2-deploy/src/lib.rs @@ -8,7 +8,11 @@ //! crates (see `Cargo.toml`). //! //! [`host`] is the `icp_project::Host` for a bundle: the archive `icp project -//! bundle` writes, treated as untrusted input. The deploy pipeline over it and -//! the MCP tools that expose it follow in their own changes. +//! bundle` writes, treated as untrusted input. The confinement of the files +//! the library reads is the library's own (`icp_project::files::Confined`, +//! added for this use by dfinity/icp-cli#866, issue #865): this crate pins +//! that change and adds the bounds on the archive itself and the checks on +//! the bundle's shape. The deploy pipeline over it and the MCP tools that +//! expose it follow in their own changes. pub mod host; From 8f99b46b7a4ed161581c7c0b77672735e7ad3446 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Wed, 4 Nov 2026 15:12:47 +0000 Subject: [PATCH 06/12] Bound the bundle check by the archive, and hold a plugin's declared paths to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The check read, hashed and gunzipped a module once per canister naming it, and nothing summed the expansions: a manifest of ten thousand canisters on one gzip module expanding to the 100 MiB limit had it decompress a terabyte before refusing anything, and ten thousand kilobyte-sized gzip files did the same without repeating a name. Now each file is read, hashed and expanded once, by where it lies on disk (`Verified`), and the expansions are summed under `MAX_WASM_BYTES_IN_ALL`, the extraction budget again: compressing a module buys nothing against it. The check also said nothing about what a sync step declares under `dirs:` and `files:`. The runtime resolves each against the canister directory, confined to the project, and reads every `files:` entry into a string of its own before the plugin runs — so a missing file, or a path outside the bundle, failed at sync, after the canisters had been created and paid for, and a 100 MiB file declared a hundred times asked for 10 GiB. `check_declared` now holds each entry to the runtime's rule (relative, within the root, present, a directory where `dirs:` says so) and the files, counted as often as declared, to `MAX_PLUGIN_INPUT_BYTES` per step. Which of the two settings a plugin's interface accepts stays the runtime's to tell. Two regressions for what the move to `Confined` already fixed: the parents a directory entry implies are counted once, and an empty directory entry is a directory of the bundle. Co-Authored-By: Claude Fable 5.1 --- crates/imcp2-deploy/src/host.rs | 391 ++++++++++++++++++++++++++++++-- 1 file changed, 367 insertions(+), 24 deletions(-) diff --git a/crates/imcp2-deploy/src/host.rs b/crates/imcp2-deploy/src/host.rs index d827bcb..43dc489 100644 --- a/crates/imcp2-deploy/src/host.rs +++ b/crates/imcp2-deploy/src/host.rs @@ -26,9 +26,11 @@ //! because the sync-plugin runtime needs real paths to mount. //! * **The bundle is checked before any phase runs** ([`check_bundle`]): every //! canister built by one pre-built wasm in the archive that hashes to its -//! recorded checksum, every sync plugin in the archive, no script steps -//! (which the library would run as host subprocesses; [`NoScripts`] refuses -//! them again at the seams, as a backstop), and no network but `ic`. +//! recorded checksum, every sync plugin in the archive and what it declares +//! with it, no script steps (which the library would run as host +//! subprocesses; [`NoScripts`] refuses them again at the seams, as a +//! backstop), and no network but `ic`. The check's own work is bounded by +//! the archive, not by what the manifest repeats. //! //! The confinement comes from upstream: `icp project bundle` holds every path //! to the project it bundles, and dfinity/icp-cli#866 (issue #865) gives the @@ -47,7 +49,7 @@ use std::{ }; use async_trait::async_trait; -use camino::{Utf8Path, Utf8PathBuf}; +use camino::{Utf8Component, Utf8Path, Utf8PathBuf}; use icp_events::StepReporter; use icp_project::{ canister::{ @@ -62,8 +64,8 @@ use icp_project::{ files::{Confined, FileSystem, FsError, Scratch}, host::{Host, Ignore}, manifest::{ - prebuilt::SourceField, BuildStep, ProjectRootLocate, ProjectRootLocateImpl, SyncStep, - PROJECT_MANIFEST, + plugin::NamedPaths, prebuilt::SourceField, BuildStep, ProjectRootLocate, + ProjectRootLocateImpl, SyncStep, PROJECT_MANIFEST, }, network::{ Access, AccessError, CollectFriendlyDomains, Configuration, NetworkAccess, NetworkUrls, @@ -120,6 +122,22 @@ pub const MAX_PROJECT_BYTES: u64 = 256 * 1024 * 1024; /// before a deploy runs. pub const MAX_WASM_BYTES: u64 = 100 * 1024 * 1024; +/// What the gzip-compressed modules of one bundle may expand to in all, each +/// distinct module counted once: the extraction budget again, so that +/// compressing a module buys nothing against it. [`check_bundle`] expands +/// each module once to hold it to [`MAX_WASM_BYTES`]; without a sum, an +/// archive of ten thousand kilobyte-sized gzip files each expanding to that +/// limit would have the check decompress a terabyte before refusing anything. +pub const MAX_WASM_BYTES_IN_ALL: u64 = MAX_EXTRACTED_BYTES; + +/// What one sync step's `files:` entries may hold in all, an entry counted +/// each time it is declared: the sync-plugin runtime reads every declared +/// file into a string of its own before the plugin runs, so a file declared +/// a hundred times is held a hundred times over. A plugin's file inputs are +/// its configuration, a few KiB; the directories it declares are mounted, +/// not read, and cost nothing here. +pub const MAX_PLUGIN_INPUT_BYTES: u64 = 64 * 1024 * 1024; + /// A plain error for the seams below: each carries one sentence, and the /// library wraps it whole at its own boundary (`AccessError::new` and the /// like), so the sentence is what the user reads. @@ -367,7 +385,10 @@ impl Fetch for BundledWasm { } fn verify_checksum(path: &Utf8Path, expected: &str, bytes: &[u8]) -> Result<(), Unsupported> { - let actual = hex::encode(Sha256::digest(bytes)); + checksum_matches(path, expected, &hex::encode(Sha256::digest(bytes))) +} + +fn checksum_matches(path: &Utf8Path, expected: &str, actual: &str) -> Result<(), Unsupported> { if actual.eq_ignore_ascii_case(expected) { Ok(()) } else { @@ -379,15 +400,16 @@ fn verify_checksum(path: &Utf8Path, expected: &str, bytes: &[u8]) -> Result<(), } /// Hold a gzip-compressed module to `limit` bytes expanded, by streaming it -/// through a bounded decoder into nothing. A module that is not gzip passes -/// as it is: its size is its own. -fn gunzipped_within(path: &Utf8Path, bytes: &[u8], limit: u64) -> Result<(), Unsupported> { +/// through a bounded decoder into nothing, and say what it expanded to. A +/// module that is not gzip passes as it is: its size is its own, and it +/// expands to nothing. +fn gunzipped_within(path: &Utf8Path, bytes: &[u8], limit: u64) -> Result { if !bytes.starts_with(&[0x1f, 0x8b]) { - return Ok(()); + return Ok(0); } let mut decoder = Limited { inner: flate2::read::GzDecoder::new(bytes), left: limit }; match std::io::copy(&mut decoder, &mut std::io::sink()) { - Ok(_) => Ok(()), + Ok(expanded) => Ok(expanded), Err(_) => Err(Unsupported(format!( "the gzip-compressed wasm '{path}' expands past {limit} bytes, the most a module \ can be, or is not valid gzip" @@ -474,6 +496,14 @@ impl ScriptRunner for NoScripts { /// a case-insensitive volume (macOS and Windows by default) would hand the /// last-built module to both. /// +/// What a plugin declares under `dirs:` and `files:` is held to the bundle +/// the way the runtime will hold it at sync, and the files to what the +/// runtime will read of them ([`check_declared`]). +/// +/// The work the check does is bounded by the archive, not by the manifest: +/// a file is read, hashed and expanded once however many canisters name it +/// ([`Verified`]), and the expansions are summed ([`MAX_WASM_BYTES_IN_ALL`]). +/// /// Where each canister lives is the loader's business: it finds a canister /// directory through the confined filesystem, so one outside the bundle root /// — which the sync-plugin runtime would take as its own sandbox root — is @@ -482,6 +512,18 @@ pub fn check_bundle<'a>( fs: &BundleFs, network: &Network, canisters: impl IntoIterator, +) -> Result<(), String> { + check_bundle_within(fs, network, canisters, MAX_WASM_BYTES_IN_ALL, MAX_PLUGIN_INPUT_BYTES) +} + +/// [`check_bundle`] with the two sums as parameters, so a test can reach +/// them without a gigabyte of modules. +fn check_bundle_within<'a>( + fs: &BundleFs, + network: &Network, + canisters: impl IntoIterator, + wasm_in_all: u64, + plugin_input: u64, ) -> Result<(), String> { IcMainnet::mainnet(network).map_err(|e| e.0)?; let canisters: Vec<_> = canisters.into_iter().collect(); @@ -497,12 +539,10 @@ pub fn check_bundle<'a>( } } } + let mut verified = Verified { hashes: BTreeMap::new(), wasm_left: wasm_in_all }; for (dir, canister) in canisters { let name = &canister.name; let at = |e: Unsupported| format!("canister '{name}': {e}"); - let present = |path: &Utf8Path| -> Result, String> { - read_bytes(fs, &dir.join(path)).map_err(|e| format!("canister '{name}': {e}")) - }; if canister.build.steps.iter().any(|s| matches!(s, BuildStep::Script(_))) { return Err(at(NoScripts::script_refused("build"))); @@ -524,7 +564,7 @@ pub fn check_bundle<'a>( .into(), ))); }; - let wasm = present(&local.path)?; + let actual = verified.sha256(fs, dir, &local.path).map_err(at)?; let Some(expected) = adapter.sha256.as_deref() else { return Err(at(Unsupported(format!( "the wasm '{}' carries no sha256, which a bundle always records; rebuild the \ @@ -532,8 +572,7 @@ pub fn check_bundle<'a>( local.path )))); }; - verify_checksum(&local.path, expected, &wasm).map_err(at)?; - gunzipped_within(&local.path, &wasm, MAX_WASM_BYTES).map_err(at)?; + checksum_matches(&local.path, expected, &actual).map_err(at)?; for step in &canister.sync.steps { let SyncStep::Plugin(plugin) = step else { continue }; @@ -544,10 +583,124 @@ pub fn check_bundle<'a>( .into(), ))); }; - let bytes = present(&local.path)?; + let actual = verified.sha256(fs, dir, &local.path).map_err(at)?; if let Some(expected) = plugin.sha256.as_deref() { - verify_checksum(&local.path, expected, &bytes).map_err(at)?; + checksum_matches(&local.path, expected, &actual).map_err(at)?; } + let paths = |named: &'a Option<_>| { + named.iter().flat_map(NamedPaths::entries).map(|entry| entry.path) + }; + check_declared(fs, dir, paths(&plugin.dirs), paths(&plugin.files), plugin_input) + .map_err(at)?; + } + } + Ok(()) +} + +/// What [`check_bundle`] has read so far, by where each file lies on disk: +/// a module or plugin that several canisters name — or one canister names +/// under several spellings — is read, hashed and expanded once, so the work +/// the check does is bounded by what the archive holds, not by what the +/// manifest repeats. +struct Verified { + /// Each file's sha256, in hex, by its on-disk path. + hashes: BTreeMap, + /// What the gzip-compressed files seen so far may still expand to, in all. + wasm_left: u64, +} + +impl Verified { + /// The sha256 of the file at `path` under `dir`, read and hashed on first + /// sight — when a gzip-compressed one is also held to [`MAX_WASM_BYTES`] + /// expanded, and its expansion counted against the sum. + fn sha256( + &mut self, + fs: &BundleFs, + dir: &Utf8Path, + path: &Utf8Path, + ) -> Result { + let on_disk = locate(fs, &dir.join(path))?; + if let Some(hash) = self.hashes.get(&on_disk) { + return Ok(hash.clone()); + } + let bytes = + std::fs::read(&on_disk).map_err(|e| Unsupported(format!("'{on_disk}': {e}")))?; + let expanded = gunzipped_within(path, &bytes, MAX_WASM_BYTES)?; + if expanded > self.wasm_left { + return Err(Unsupported(format!( + "the bundle's gzip-compressed modules expand to more than \ + {MAX_WASM_BYTES_IN_ALL} bytes in all, more than a bundle extracts to" + ))); + } + self.wasm_left -= expanded; + let hash = hex::encode(Sha256::digest(&bytes)); + self.hashes.insert(on_disk, hash.clone()); + Ok(hash) + } +} + +/// Hold a sync step's declared `dirs:` and `files:` to the bundle, by the +/// runtime's own rule — each is written relative to the canister directory, +/// may rise out of it with `..` but not above the bundle root, and may not +/// be absolute — and to what is there: a `dirs:` entry names a directory, a +/// `files:` entry a file the runtime will read into memory, or a directory +/// it will mount. The files, counted as often as they are declared, are held +/// to `budget` bytes in all. +/// +/// The runtime refuses the same paths at sync, after the canisters have been +/// created; this is before. Which of the two settings the plugin's interface +/// accepts, and whether an entry carries a name, is settled by the plugin +/// itself and stays the runtime's to tell. +fn check_declared<'a>( + fs: &BundleFs, + dir: &Utf8Path, + dirs: impl IntoIterator, + files: impl IntoIterator, + budget: u64, +) -> Result<(), Unsupported> { + let resolve = |path: &str| -> Result { + let declared = Utf8Path::new(path); + if declared + .components() + .any(|c| matches!(c, Utf8Component::RootDir | Utf8Component::Prefix(_))) + { + return Err(Unsupported(format!( + "its sync step declares '{path}', an absolute path; a plugin's `dirs:` and \ + `files:` are written relative to the canister directory" + ))); + } + fs.resolve(&dir.join(declared)).map_err(|_| { + Unsupported(format!( + "its sync step declares '{path}', which lies outside the bundle; a plugin's \ + `dirs:` and `files:` stay within the project" + )) + }) + }; + for path in dirs { + if !resolve(path)?.is_dir() { + return Err(Unsupported(format!( + "its sync step declares '{path}' under `dirs:`, which is not a directory in the \ + bundle" + ))); + } + } + let mut left = budget; + for path in files { + let on_disk = resolve(path)?; + let meta = std::fs::metadata(&on_disk).map_err(|_| { + Unsupported(format!( + "its sync step declares '{path}' under `files:`, which is not in the bundle" + )) + })?; + if meta.is_file() { + if meta.len() > left { + return Err(Unsupported(format!( + "its sync step's `files:` hold more than {budget} bytes in all, counting a \ + file each time it is declared, more than a plugin's inputs ever do; the \ + runtime reads every declared file into memory before the plugin runs" + ))); + } + left -= meta.len(); } } Ok(()) @@ -567,7 +720,9 @@ pub fn check_bundle<'a>( /// sets no store limiter, so a plugin may allocate up to that space before /// its time runs out. The runner builds its own engine and store, so the /// limiter belongs there, upstream; until it has one, a bundle's plugins run -/// with the memory bound the icp CLI gives them. +/// with the memory bound the icp CLI gives them. Nor is the number of +/// canisters or steps a manifest declares: the phases do their work once per +/// canister, and each canister costs the caller the cycles that create it. pub fn host_for(fs: BundleFs) -> Host { host_within(fs, MAX_PROJECT_BYTES) } @@ -760,7 +915,11 @@ impl Read for Limited { #[cfg(test)] mod tests { use super::*; - use icp_project::network::{Connected, Managed}; + use icp_project::{ + host::EnvironmentSelection, + network::{Connected, Managed}, + Environment, + }; use std::io::Write; fn managed(name: &str) -> Network { @@ -1059,6 +1218,64 @@ mod tests { archive.into_inner().unwrap().finish().unwrap() } + /// A gzipped tar of the given regular files, then directory entries at + /// the given paths. + fn archive_with_dirs(files: &[(&str, &[u8])], dirs: &[&str]) -> Vec { + let mut archive = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::fast(), + )); + for (path, data) in files { + let mut header = tar::Header::new_gnu(); + header.set_size(data.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + archive.append_data(&mut header, path, *data).unwrap(); + } + for path in dirs { + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Directory); + header.set_size(0); + header.set_mode(0o755); + header.set_cksum(); + archive.append_data(&mut header, path, &b""[..]).unwrap(); + } + archive.into_inner().unwrap().finish().unwrap() + } + + // A directory entry makes its parents known like a file does: two + // sibling directory entries under one chain are four directories, the + // shared parents counted once. + #[test] + fn the_parents_a_directory_entry_implies_are_counted_once() { + let bytes = archive_with_dirs(&[], &["a/b/c/", "a/b/d/"]); + let (_dir, into) = scratch(); + let err = extract_bundle_within(&bytes, &into, 3).unwrap_err(); + assert!(err.contains("more than 3 files and directories"), "{err}"); + let (_dir, into) = scratch(); + let err = extract_bundle_within(&bytes, &into, 4).unwrap_err(); + assert!(err.contains(PROJECT_MANIFEST), "{err}"); + assert!(into.join("a/b/d").is_dir()); + } + + // A directory the archive lists as an entry of its own, with nothing + // under it, is a directory of the bundle: it is on disk, so it is found + // and listed like any other. + #[tokio::test] + async fn an_empty_directory_entry_is_a_directory_of_the_bundle() { + let (_dir, into) = scratch(); + let bytes = archive_with_dirs(&[("icp.yaml", b"canisters: []")], &["assets/"]); + let fs = extract_bundle(&bytes, &into).unwrap(); + let root = fs.root().to_path_buf(); + assert!(fs.is_dir(Utf8Path::new("assets")).await); + assert!(fs.exists(Utf8Path::new("assets")).await); + assert_eq!( + fs.read_dir(&root).await.unwrap(), + vec![root.join("assets"), root.join("icp.yaml")] + ); + assert!(fs.read_dir(Utf8Path::new("assets")).await.unwrap().is_empty()); + } + // A gzip-compressed module is bounded by what it expands to, read // through a bounded decoder; a plain module passes as it is. #[test] @@ -1067,10 +1284,10 @@ mod tests { encoder.write_all(&[0u8; 4096]).unwrap(); let gz = encoder.finish().unwrap(); let path = Utf8Path::new("backend.wasm.gz"); - gunzipped_within(path, &gz, 8192).unwrap(); + assert_eq!(gunzipped_within(path, &gz, 8192).unwrap(), 4096); let err = gunzipped_within(path, &gz, 1024).unwrap_err(); assert!(err.0.contains("expands past 1024"), "{err}"); - gunzipped_within(path, b"\0asm plain", 1).unwrap(); + assert_eq!(gunzipped_within(path, b"\0asm plain", 1).unwrap(), 0); } // The confined filesystem serves what the archive carried, by any spelling @@ -1171,4 +1388,130 @@ mod tests { assert!(err.contains("not in the bundle"), "{err}"); assert_eq!(fs.read(Utf8Path::new("big")).await.unwrap().len(), 100); } + + /// The `ic` environment of the bundle `bytes`, as a deploy would load + /// it: extracted, then read through the host. + async fn environment(bytes: &[u8]) -> (tempfile::TempDir, BundleFs, Environment) { + let (dir, into) = scratch(); + let fs = extract_bundle(bytes, &into).unwrap(); + let env = host_for(fs.clone()) + .get_environment(&EnvironmentSelection::Named("ic".into())) + .await + .unwrap(); + (dir, fs, env) + } + + fn check( + fs: &BundleFs, + env: &Environment, + wasm_in_all: u64, + plugin_input: u64, + ) -> Result<(), String> { + check_bundle_within( + fs, + &env.network, + env.canisters.values().map(|(dir, canister)| (dir.as_path(), canister)), + wasm_in_all, + plugin_input, + ) + } + + // A module is read, hashed and expanded once however many canisters name + // it, by whatever spelling: the sum of the expansions is a sum over the + // archive's files, so a manifest cannot multiply the work by repeating + // itself. + #[tokio::test] + async fn a_module_is_checked_once_however_many_canisters_name_it() { + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + encoder.write_all(&[0u8; 4096]).unwrap(); + let gz = encoder.finish().unwrap(); + let sha = hex::encode(Sha256::digest(&gz)); + let canister = |name: &str, path: &str| { + format!( + " - name: {name}\n build:\n steps:\n - type: pre-built\n \ + path: {path}\n sha256: {sha}\n" + ) + }; + + // Three canisters, one module, two spellings: expanded once. + let manifest = format!( + "canisters:\n{}{}{}", + canister("a", "m.wasm.gz"), + canister("b", "./m.wasm.gz"), + canister("c", "m.wasm.gz") + ); + let bytes = archive(&[("icp.yaml", manifest.as_bytes()), ("m.wasm.gz", &gz)]); + let (_dir, fs, env) = environment(&bytes).await; + check(&fs, &env, 4096, MAX_PLUGIN_INPUT_BYTES).unwrap(); + + // The same bytes at two paths are two modules: expanded twice, and + // the sum holds. + let manifest = + format!("canisters:\n{}{}", canister("a", "m.wasm.gz"), canister("b", "n.wasm.gz")); + let bytes = + archive(&[("icp.yaml", manifest.as_bytes()), ("m.wasm.gz", &gz), ("n.wasm.gz", &gz)]); + let (_dir, fs, env) = environment(&bytes).await; + let err = check(&fs, &env, 4096, MAX_PLUGIN_INPUT_BYTES).unwrap_err(); + assert!(err.contains("canister 'b'") && err.contains("in all"), "{err}"); + check(&fs, &env, 8192, MAX_PLUGIN_INPUT_BYTES).unwrap(); + } + + // What a plugin declares is held to the bundle before any phase, by the + // runtime's rule — relative to the canister directory, within the root, + // present, of the kind declared — and the files it will read are held + // to a sum, counting a file each time it is declared. + #[tokio::test] + async fn a_sync_step_declares_what_the_bundle_holds() { + let wasm = wat::parse_str("(module)").unwrap(); + let sha = hex::encode(Sha256::digest(&wasm)); + let bundle = |declared: &str| { + let canister = format!( + "name: backend\nbuild:\n steps:\n - type: pre-built\n path: backend.wasm\n \ + sha256: {sha}\nsync:\n steps:\n - type: plugin\n path: plugin.wasm\n{declared}" + ); + archive(&[ + ("icp.yaml", b"canisters:\n - canisters/backend\n"), + ("canisters/backend/canister.yaml", canister.as_bytes()), + ("canisters/backend/backend.wasm", &wasm), + ("canisters/backend/plugin.wasm", &wasm), + ("canisters/backend/config.txt", b"0123456789"), + ("canisters/backend/assets/index.html", b""), + ("shared/common.txt", b"abc"), + ]) + }; + let all = (MAX_WASM_BYTES_IN_ALL, MAX_PLUGIN_INPUT_BYTES); + + let accepted = [ + " files:\n config: config.txt\n seed: assets\n", + " files:\n shared: ../../shared/common.txt\n", + " dirs:\n - assets\n files:\n - config.txt\n", + ]; + for declared in accepted { + let (_dir, fs, env) = environment(&bundle(declared)).await; + check(&fs, &env, all.0, all.1).unwrap_or_else(|e| panic!("{declared}: {e}")); + } + + let refused = [ + (" files:\n config: missing.txt\n", "not in the bundle"), + (" dirs:\n - config.txt\n", "not a directory"), + (" files:\n etc: /etc/passwd\n", "an absolute path"), + (" files:\n up: ../../../x\n", "outside the bundle"), + ]; + for (declared, reason) in refused { + let (_dir, fs, env) = environment(&bundle(declared)).await; + let err = check(&fs, &env, all.0, all.1).unwrap_err(); + assert!( + err.contains("canister 'backend'") && err.contains(reason), + "{declared}: {err}" + ); + } + + // Ten bytes declared twice are twenty; a directory costs nothing. + let declared = + " files:\n a: config.txt\n b: config.txt\n seed: assets\n"; + let (_dir, fs, env) = environment(&bundle(declared)).await; + let err = check(&fs, &env, all.0, 19).unwrap_err(); + assert!(err.contains("`files:` hold more than 19 bytes in all"), "{err}"); + check(&fs, &env, all.0, 20).unwrap(); + } } From 9167943ca6538c795e4f989789734eff286f297e Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Thu, 5 Nov 2026 01:42:38 +0000 Subject: [PATCH 07/12] Pin the icp-cli stack through #868, where the confinement is lexical MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dfinity/icp-cli#868 completes the rule #866 began, split three ways (#863): the user's own project may link wherever it likes, `icp project bundle` writes an archive of regular files and directories alone, and a consumer of a bundle it does not trust refuses any other entry on the way in. `files::Confined` is accordingly held to the root by the spelling of each path alone — it no longer canonicalizes the root or follows symlinks to see where a path comes to rest — and `Confined::new` is infallible. This crate is that consumer: extraction already refuses a symlink, a device node and anything else that is not a regular file or directory, so the lexical check is complete here. The pin moves to the head of the stack, the docs say why the extractor's refusal is what the confinement relies on, and the one call site follows the signature. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 8 +++---- crates/imcp2-deploy/Cargo.toml | 16 +++++++------ crates/imcp2-deploy/src/host.rs | 41 ++++++++++++++++++++------------- crates/imcp2-deploy/src/lib.rs | 10 ++++---- 4 files changed, 44 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6869196..a779e5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2172,7 +2172,7 @@ dependencies = [ [[package]] name = "icp-canister-interfaces" version = "1.8.0" -source = "git+https://github.com/dfinity/icp-cli?rev=7f3c23188fb7be50447f90c4c8c749934d4759a3#7f3c23188fb7be50447f90c4c8c749934d4759a3" +source = "git+https://github.com/dfinity/icp-cli?rev=4b3b82e41b3242e3c16bcaba2473ab8423ac8a5d#4b3b82e41b3242e3c16bcaba2473ab8423ac8a5d" dependencies = [ "bigdecimal", "candid", @@ -2183,7 +2183,7 @@ dependencies = [ [[package]] name = "icp-events" version = "1.8.0" -source = "git+https://github.com/dfinity/icp-cli?rev=7f3c23188fb7be50447f90c4c8c749934d4759a3#7f3c23188fb7be50447f90c4c8c749934d4759a3" +source = "git+https://github.com/dfinity/icp-cli?rev=4b3b82e41b3242e3c16bcaba2473ab8423ac8a5d#4b3b82e41b3242e3c16bcaba2473ab8423ac8a5d" dependencies = [ "serde", "tokio", @@ -2192,7 +2192,7 @@ dependencies = [ [[package]] name = "icp-project" version = "1.8.0" -source = "git+https://github.com/dfinity/icp-cli?rev=7f3c23188fb7be50447f90c4c8c749934d4759a3#7f3c23188fb7be50447f90c4c8c749934d4759a3" +source = "git+https://github.com/dfinity/icp-cli?rev=4b3b82e41b3242e3c16bcaba2473ab8423ac8a5d#4b3b82e41b3242e3c16bcaba2473ab8423ac8a5d" dependencies = [ "async-trait", "bigdecimal", @@ -2240,7 +2240,7 @@ dependencies = [ [[package]] name = "icp-sync-plugin" version = "1.8.0" -source = "git+https://github.com/dfinity/icp-cli?rev=7f3c23188fb7be50447f90c4c8c749934d4759a3#7f3c23188fb7be50447f90c4c8c749934d4759a3" +source = "git+https://github.com/dfinity/icp-cli?rev=4b3b82e41b3242e3c16bcaba2473ab8423ac8a5d#4b3b82e41b3242e3c16bcaba2473ab8423ac8a5d" dependencies = [ "async-trait", "bytes", diff --git a/crates/imcp2-deploy/Cargo.toml b/crates/imcp2-deploy/Cargo.toml index a80425f..6dc927a 100644 --- a/crates/imcp2-deploy/Cargo.toml +++ b/crates/imcp2-deploy/Cargo.toml @@ -44,9 +44,11 @@ tempfile = "3" # one commit so the four stay in lockstep (they share a workspace version and # are not released independently). Bump the `rev` on all four together. # -# The pinned commit is the head of dfinity/icp-cli#866, the branch that adds -# `files::Confined` (the filesystem the bundle host reads through, see -# `src/host.rs`); once it is on icp-cli's main, re-pin to main. +# The pinned commit is the head of the dfinity/icp-cli stack #866 -> #868: +# #866 adds `files::Confined` (the filesystem the bundle host reads through, +# see `src/host.rs`), #868 has `icp project bundle` write an archive of +# regular files and directories alone, the shape that confinement relies on. +# Once the stack is on icp-cli's main, re-pin to main. # # * icp-project: manifests, the deploy operation, the id/artifact stores. # * icp-events: the progress event stream the operations report on. @@ -55,10 +57,10 @@ tempfile = "3" # brings wasmtime into this workspace. # * icp-canister-interfaces: the engine-canister registry id a created # canister's subnet is looked up in. -icp-project = { git = "https://github.com/dfinity/icp-cli", rev = "7f3c23188fb7be50447f90c4c8c749934d4759a3" } -icp-events = { git = "https://github.com/dfinity/icp-cli", rev = "7f3c23188fb7be50447f90c4c8c749934d4759a3" } -icp-sync-plugin = { git = "https://github.com/dfinity/icp-cli", rev = "7f3c23188fb7be50447f90c4c8c749934d4759a3" } -icp-canister-interfaces = { git = "https://github.com/dfinity/icp-cli", rev = "7f3c23188fb7be50447f90c4c8c749934d4759a3" } +icp-project = { git = "https://github.com/dfinity/icp-cli", rev = "4b3b82e41b3242e3c16bcaba2473ab8423ac8a5d" } +icp-events = { git = "https://github.com/dfinity/icp-cli", rev = "4b3b82e41b3242e3c16bcaba2473ab8423ac8a5d" } +icp-sync-plugin = { git = "https://github.com/dfinity/icp-cli", rev = "4b3b82e41b3242e3c16bcaba2473ab8423ac8a5d" } +icp-canister-interfaces = { git = "https://github.com/dfinity/icp-cli", rev = "4b3b82e41b3242e3c16bcaba2473ab8423ac8a5d" } [dev-dependencies] # The tests hand-build small bundles (a manifest plus a wasm) and load them diff --git a/crates/imcp2-deploy/src/host.rs b/crates/imcp2-deploy/src/host.rs index 43dc489..b76267f 100644 --- a/crates/imcp2-deploy/src/host.rs +++ b/crates/imcp2-deploy/src/host.rs @@ -18,12 +18,14 @@ //! ([`extract_bundle`]). //! * **The filesystem the library reads through is confined to that //! directory** — the library's own [`Confined`], the host filesystem held -//! to one root. A path the manifest names is resolved against the root -//! (`..` cannot climb above it, an absolute path elsewhere is refused, a -//! symlink pointing out leads nowhere) before the disk sees it. The loader -//! resolves every path a manifest names through that one seam, so nothing -//! on the machine is reachable by any spelling. The disk is the byte store -//! because the sync-plugin runtime needs real paths to mount. +//! to one root by the spelling of each path. A path the manifest names is +//! resolved against the root (`..` cannot climb above it, an absolute path +//! elsewhere is refused) before the disk sees it. The loader resolves +//! every path it reads through that one seam, so nothing on the machine +//! is reachable by any spelling. The check is lexical, which is why +//! extraction refuses a symlink: with none in the tree, no path under the +//! root leads out of it. The disk is the byte store because the +//! sync-plugin runtime needs real paths to mount. //! * **The bundle is checked before any phase runs** ([`check_bundle`]): every //! canister built by one pre-built wasm in the archive that hashes to its //! recorded checksum, every sync plugin in the archive and what it declares @@ -32,10 +34,15 @@ //! backstop), and no network but `ic`. The check's own work is bounded by //! the archive, not by what the manifest repeats. //! -//! The confinement comes from upstream: `icp project bundle` holds every path -//! to the project it bundles, and dfinity/icp-cli#866 (issue #865) gives the -//! loader the same rule for a project that came from elsewhere. This crate -//! pins that change; until it is on icp-cli's main, the pin is its branch. +//! The confinement comes from upstream, as one rule split three ways +//! (dfinity/icp-cli#863): the user's own project may link wherever it likes; +//! `icp project bundle` writes an archive of regular files and directories, +//! in-project links dereferenced and out-of-project links refused +//! (dfinity/icp-cli#868); and a consumer of a bundle it does not trust +//! refuses any other entry on the way in, after which the lexical +//! [`Confined`] (dfinity/icp-cli#866) is complete. This crate is that +//! consumer, and pins the stack; until it is on icp-cli's main, the pin is +//! its head. //! //! Everything the project library reads or writes — the manifest, the wasms, //! the `.icp` id and artifact stores, the build scratch — lives under the @@ -764,8 +771,10 @@ fn host_within(fs: BundleFs, project_bytes: u64) -> Host { /// return it as the filesystem the deploy reads through, confined to it. /// /// Only regular files and directories are written — `icp project bundle` -/// writes nothing else, and a symlink or a device node is how an archive -/// reaches past its directory — and their sizes may not add up to more than +/// writes nothing else, a symlink or a device node is how an archive reaches +/// past its directory, and the confinement the library reads through is +/// lexical, so it holds only for a tree without links — and their sizes may +/// not add up to more than /// [`MAX_EXTRACTED_BYTES`], nor their number, with the directories their /// paths imply, to more than [`MAX_BUNDLE_ENTRIES`]. `tar` refuses entry /// paths that would land outside `into`. What it extracts is then required @@ -788,7 +797,7 @@ fn extract_bundle_within( MAX_BUNDLE_BYTES )); } - let fs = Confined::new(into).map_err(|e| e.to_string())?; + let fs = Confined::new(into); let unreadable = |e: std::io::Error| format!("the bundle is not a readable .tar.gz archive: {e}"); let decoder = Limited { inner: flate2::read::GzDecoder::new(bytes), left: MAX_EXTRACTED_BYTES }; @@ -1055,8 +1064,8 @@ mod tests { // A symlink is how an archive reaches past its directory; a bundle // carries none, so one is refused rather than written — which is what - // lets the index below trust that every path under the root is a file - // extraction wrote. + // lets the confined filesystem, lexical as it is, trust that no path + // under the root leads out of it. #[test] fn an_archive_with_a_symlink_is_refused() { let (_dir, into) = scratch(); @@ -1294,7 +1303,7 @@ mod tests { // that resolves to it, and refuses what resolves outside the root — an // absolute path elsewhere, a climb above the root — whatever the machine // holds at that spelling. (The filesystem's own tests, upstream, cover - // the spellings and the symlinks; this is the bundle seen through it.) + // the spellings; this is the bundle seen through it.) #[tokio::test] async fn the_bundle_serves_what_it_carries_and_nothing_outside_it() { let (_dir, into) = scratch(); diff --git a/crates/imcp2-deploy/src/lib.rs b/crates/imcp2-deploy/src/lib.rs index 5cd62cb..0ee9bd4 100644 --- a/crates/imcp2-deploy/src/lib.rs +++ b/crates/imcp2-deploy/src/lib.rs @@ -10,9 +10,11 @@ //! [`host`] is the `icp_project::Host` for a bundle: the archive `icp project //! bundle` writes, treated as untrusted input. The confinement of the files //! the library reads is the library's own (`icp_project::files::Confined`, -//! added for this use by dfinity/icp-cli#866, issue #865): this crate pins -//! that change and adds the bounds on the archive itself and the checks on -//! the bundle's shape. The deploy pipeline over it and the MCP tools that -//! expose it follow in their own changes. +//! added for this use by dfinity/icp-cli#866, with dfinity/icp-cli#868 +//! holding what `icp project bundle` writes to the shape that confinement +//! relies on; issue #865): this crate pins that stack and adds the bounds on +//! the archive itself and the checks on the bundle's shape. The deploy +//! pipeline over it and the MCP tools that expose it follow in their own +//! changes. pub mod host; From 65b6522ab20f4d65e1a0864ea149f9ec31af425a Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Thu, 5 Nov 2026 22:06:31 +0000 Subject: [PATCH 08/12] Load the bundle as icp --untrusted loads a project, with the rule from upstream The rule for a project someone else handed over now lives in icp-project (dfinity/icp-cli#871, `icp_project::untrusted`, what the CLI's `--untrusted` flag applies), so the host composes it as the CLI does instead of assembling its own from the seams: the project is loaded through `Confined::refusing_links`, which refuses a path through a symlink anywhere under the root; no recipe is fetched (`NoRecipes`); and the loaded project is vetted (`Vetted`) before it is handed out, so a build step that is not a pre-built module from a file in the project, or a sync step that is not a plugin from one, fails the load. With the loader the one gate on the project's shape, the hand-rolled `NoRecipes`, the `Build` backstop and the script checks in `check_bundle` go; the builder is the library's own, and the syncer takes a runner that spawns nothing, since no script step reaches it. Extraction still refuses a symlink on the way in, and the filesystem now refuses one again should it appear under the root later. Pins the four icp-cli crates to the head of #871. Two tests: the load refuses a script step, a remote module and a recipe, naming each; and the bundle filesystem refuses a link made after extraction. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 8 +- crates/imcp2-deploy/Cargo.toml | 20 +-- crates/imcp2-deploy/src/host.rs | 231 ++++++++++++++++++-------------- crates/imcp2-deploy/src/lib.rs | 15 +-- 4 files changed, 152 insertions(+), 122 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a779e5a..f6dad90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2172,7 +2172,7 @@ dependencies = [ [[package]] name = "icp-canister-interfaces" version = "1.8.0" -source = "git+https://github.com/dfinity/icp-cli?rev=4b3b82e41b3242e3c16bcaba2473ab8423ac8a5d#4b3b82e41b3242e3c16bcaba2473ab8423ac8a5d" +source = "git+https://github.com/dfinity/icp-cli?rev=ae3367ef80cfb2798bc2c8395e39ad6af76a9be5#ae3367ef80cfb2798bc2c8395e39ad6af76a9be5" dependencies = [ "bigdecimal", "candid", @@ -2183,7 +2183,7 @@ dependencies = [ [[package]] name = "icp-events" version = "1.8.0" -source = "git+https://github.com/dfinity/icp-cli?rev=4b3b82e41b3242e3c16bcaba2473ab8423ac8a5d#4b3b82e41b3242e3c16bcaba2473ab8423ac8a5d" +source = "git+https://github.com/dfinity/icp-cli?rev=ae3367ef80cfb2798bc2c8395e39ad6af76a9be5#ae3367ef80cfb2798bc2c8395e39ad6af76a9be5" dependencies = [ "serde", "tokio", @@ -2192,7 +2192,7 @@ dependencies = [ [[package]] name = "icp-project" version = "1.8.0" -source = "git+https://github.com/dfinity/icp-cli?rev=4b3b82e41b3242e3c16bcaba2473ab8423ac8a5d#4b3b82e41b3242e3c16bcaba2473ab8423ac8a5d" +source = "git+https://github.com/dfinity/icp-cli?rev=ae3367ef80cfb2798bc2c8395e39ad6af76a9be5#ae3367ef80cfb2798bc2c8395e39ad6af76a9be5" dependencies = [ "async-trait", "bigdecimal", @@ -2240,7 +2240,7 @@ dependencies = [ [[package]] name = "icp-sync-plugin" version = "1.8.0" -source = "git+https://github.com/dfinity/icp-cli?rev=4b3b82e41b3242e3c16bcaba2473ab8423ac8a5d#4b3b82e41b3242e3c16bcaba2473ab8423ac8a5d" +source = "git+https://github.com/dfinity/icp-cli?rev=ae3367ef80cfb2798bc2c8395e39ad6af76a9be5#ae3367ef80cfb2798bc2c8395e39ad6af76a9be5" dependencies = [ "async-trait", "bytes", diff --git a/crates/imcp2-deploy/Cargo.toml b/crates/imcp2-deploy/Cargo.toml index 6dc927a..96d9b88 100644 --- a/crates/imcp2-deploy/Cargo.toml +++ b/crates/imcp2-deploy/Cargo.toml @@ -44,11 +44,13 @@ tempfile = "3" # one commit so the four stay in lockstep (they share a workspace version and # are not released independently). Bump the `rev` on all four together. # -# The pinned commit is the head of the dfinity/icp-cli stack #866 -> #868: -# #866 adds `files::Confined` (the filesystem the bundle host reads through, -# see `src/host.rs`), #868 has `icp project bundle` write an archive of -# regular files and directories alone, the shape that confinement relies on. -# Once the stack is on icp-cli's main, re-pin to main. +# The pinned commit is the head of the dfinity/icp-cli stack #866 -> #868 -> +# #871: #866 adds `files::Confined` (the filesystem the bundle host reads +# through, see `src/host.rs`), #868 has `icp project bundle` write an archive +# of regular files and directories alone, and #871 states the rule for an +# untrusted project (`icp_project::untrusted`, what `icp --untrusted` +# applies), which the host composes as the CLI does. Once the stack is on +# icp-cli's main, re-pin to main. # # * icp-project: manifests, the deploy operation, the id/artifact stores. # * icp-events: the progress event stream the operations report on. @@ -57,10 +59,10 @@ tempfile = "3" # brings wasmtime into this workspace. # * icp-canister-interfaces: the engine-canister registry id a created # canister's subnet is looked up in. -icp-project = { git = "https://github.com/dfinity/icp-cli", rev = "4b3b82e41b3242e3c16bcaba2473ab8423ac8a5d" } -icp-events = { git = "https://github.com/dfinity/icp-cli", rev = "4b3b82e41b3242e3c16bcaba2473ab8423ac8a5d" } -icp-sync-plugin = { git = "https://github.com/dfinity/icp-cli", rev = "4b3b82e41b3242e3c16bcaba2473ab8423ac8a5d" } -icp-canister-interfaces = { git = "https://github.com/dfinity/icp-cli", rev = "4b3b82e41b3242e3c16bcaba2473ab8423ac8a5d" } +icp-project = { git = "https://github.com/dfinity/icp-cli", rev = "ae3367ef80cfb2798bc2c8395e39ad6af76a9be5" } +icp-events = { git = "https://github.com/dfinity/icp-cli", rev = "ae3367ef80cfb2798bc2c8395e39ad6af76a9be5" } +icp-sync-plugin = { git = "https://github.com/dfinity/icp-cli", rev = "ae3367ef80cfb2798bc2c8395e39ad6af76a9be5" } +icp-canister-interfaces = { git = "https://github.com/dfinity/icp-cli", rev = "ae3367ef80cfb2798bc2c8395e39ad6af76a9be5" } [dev-dependencies] # The tests hand-build small bundles (a manifest plus a wasm) and load them diff --git a/crates/imcp2-deploy/src/host.rs b/crates/imcp2-deploy/src/host.rs index b76267f..14e636a 100644 --- a/crates/imcp2-deploy/src/host.rs +++ b/crates/imcp2-deploy/src/host.rs @@ -11,38 +11,47 @@ //! //! The archive is untrusted input: it arrives in a tool call, and the agent //! that passes it may have fetched it from anywhere. So the shape is not -//! assumed but enforced, in three places: +//! assumed but enforced, and the rule it is held to is the library's own: +//! `icp_project::untrusted`, the rule for a project someone else handed over +//! (dfinity/icp-cli#871), the one the CLI's `--untrusted` flag applies. The +//! host composes it as the CLI does: +//! +//! * **The project is loaded through [`Confined::refusing_links`]**, the +//! host filesystem held to the extraction directory by the spelling of +//! each path — `..` cannot climb above the root, an absolute path +//! elsewhere is refused — and refusing a path that passes through a +//! symlink anywhere under it, wherever the link leads. The loader resolves +//! every path it reads through that one seam, so a manifest can name only +//! what the archive brought with it. The disk is the byte store because +//! the sync-plugin runtime needs real paths to mount. +//! * **No recipe is fetched** ([`NoRecipes`]): a bundle spells every step +//! out, so a project naming one fails to load, naming the recipe. +//! * **The loaded project is vetted** ([`Vetted`]) before it is handed out, +//! to what is allowed rather than what is not: a build step is a pre-built +//! module from a file in the project, a sync step a plugin from one, and a +//! project with any other — a script, a download — fails the load. The +//! loader is the one gate on the project's shape; the builder and syncer +//! are the library's own, and see no other step. +//! +//! What a consumer of a bundle adds, this crate adds: //! //! * **Extraction** writes only regular files and directories, within a total //! size budget and an object budget, under the scratch directory -//! ([`extract_bundle`]). -//! * **The filesystem the library reads through is confined to that -//! directory** — the library's own [`Confined`], the host filesystem held -//! to one root by the spelling of each path. A path the manifest names is -//! resolved against the root (`..` cannot climb above it, an absolute path -//! elsewhere is refused) before the disk sees it. The loader resolves -//! every path it reads through that one seam, so nothing on the machine -//! is reachable by any spelling. The check is lexical, which is why -//! extraction refuses a symlink: with none in the tree, no path under the -//! root leads out of it. The disk is the byte store because the -//! sync-plugin runtime needs real paths to mount. +//! ([`extract_bundle`]), and refuses the `.icp` state a deploy seeds for +//! itself. //! * **The bundle is checked before any phase runs** ([`check_bundle`]): every //! canister built by one pre-built wasm in the archive that hashes to its //! recorded checksum, every sync plugin in the archive and what it declares -//! with it, no script steps (which the library would run as host -//! subprocesses; [`NoScripts`] refuses them again at the seams, as a -//! backstop), and no network but `ic`. The check's own work is bounded by +//! with it, and no network but `ic`. The check's own work is bounded by //! the archive, not by what the manifest repeats. //! -//! The confinement comes from upstream, as one rule split three ways -//! (dfinity/icp-cli#863): the user's own project may link wherever it likes; -//! `icp project bundle` writes an archive of regular files and directories, -//! in-project links dereferenced and out-of-project links refused -//! (dfinity/icp-cli#868); and a consumer of a bundle it does not trust -//! refuses any other entry on the way in, after which the lexical -//! [`Confined`] (dfinity/icp-cli#866) is complete. This crate is that -//! consumer, and pins the stack; until it is on icp-cli's main, the pin is -//! its head. +//! The rule is one, split three ways upstream (dfinity/icp-cli#863): the +//! user's own project may run, fetch and link whatever it likes; `icp +//! project bundle` writes an archive of regular files and directories with +//! every step spelled out (dfinity/icp-cli#868); and a consumer of a bundle +//! it does not trust loads it as untrusted (dfinity/icp-cli#866 for the +//! confined filesystem, #871 for the rest). This crate is that consumer, +//! and pins the stack; until it is on icp-cli's main, the pin is its head. //! //! Everything the project library reads or writes — the manifest, the wasms, //! the `.icp` id and artifact stores, the build scratch — lives under the @@ -60,8 +69,7 @@ use camino::{Utf8Component, Utf8Path, Utf8PathBuf}; use icp_events::StepReporter; use icp_project::{ canister::{ - build::{Build, BuildError, Builder, Params as BuildParams}, - recipe::{Fetched, Resolve, ResolveError}, + build::Builder, sync::{ script::{ScriptInvocation, ScriptRunError, ScriptRunner}, Syncer, @@ -82,6 +90,7 @@ use icp_project::{ random::HostRandom, store_artifact::ArtifactStore, store_id::AccessImpl, + untrusted::{NoRecipes, Vetted}, Canister, Lazy, Network, Project, ProjectLoadImpl, }; use sha2::{Digest, Sha256}; @@ -354,7 +363,7 @@ impl FileSystem for Metered { } } -// ── Wasms, recipes, scripts ─────────────────────────────────────────────── +// ── Wasms, scripts ──────────────────────────────────────────────────────── /// Wasm resolution for a bundle: the archive carries every module it names, /// so a source is a path inside the bundle (verified against its recorded @@ -424,57 +433,11 @@ fn gunzipped_within(path: &Utf8Path, bytes: &[u8], limit: u64) -> Result Result { - Err(ResolveError::new(Unsupported(format!( - "the manifest references recipe '{recipe:?}', which a bundle never does; rebuild \ - it with `icp project bundle`" - )))) - } -} - -/// Build and sync for a bundle: pre-built steps and plugin steps run as they -/// do under the CLI, script steps are refused. -/// -/// [`check_bundle`] catches a script step before any phase runs; this is the -/// backstop at the seam the library would run one through, so a step the -/// check missed still cannot reach a shell. -pub struct NoScripts { - prebuilt: Builder, -} - -impl NoScripts { - fn script_refused(what: &str) -> Unsupported { - Unsupported(format!( - "the manifest declares a script {what} step, which a bundle never has and this \ - server does not run; rebuild the bundle with `icp project bundle`" - )) - } -} - -#[async_trait] -impl Build for NoScripts { - async fn build( - &self, - step: &BuildStep, - params: &BuildParams, - reporter: &StepReporter, - ) -> Result<(), BuildError> { - match step { - BuildStep::Prebuilt(_) => self.prebuilt.build(step, params, reporter).await, - BuildStep::Script(_) => Err(BuildError::new(Self::script_refused("build"))), - } - } -} +/// The script runner the syncer is composed with. A bundle has no script +/// step for it to run — the vetted loader refuses a project with one — so +/// this spawns nothing; it is here because the syncer takes a runner, not +/// as a second gate. +pub struct NoScripts; #[async_trait] impl ScriptRunner for NoScripts { @@ -483,7 +446,12 @@ impl ScriptRunner for NoScripts { _invocation: ScriptInvocation, _reporter: &StepReporter, ) -> Result, ScriptRunError> { - Err(ScriptRunError { source: Box::new(Self::script_refused("sync")) }) + Err(ScriptRunError { + source: Box::new(Unsupported( + "a script sync step reached the runner, which the vetted loader admits none of" + .into(), + )), + }) } } @@ -495,8 +463,10 @@ impl ScriptRunner for NoScripts { /// /// Each canister is built by exactly one pre-built step naming a wasm in the /// archive that hashes to the checksum the manifest records; each sync step -/// is a plugin in the archive (hashing to its checksum, if one is recorded); -/// no step is a script. The environment's network is the Internet Computer. +/// is a plugin in the archive (hashing to its checksum, if one is recorded). +/// The environment's network is the Internet Computer. That no step is a +/// script and no module or plugin is remote, the loader has already seen +/// to ([`Vetted`]): the check reads what the loaded project names. /// /// Canister names must also differ by more than letter case: the library's /// artifact store keys a canister's build output by its name on disk, and @@ -551,12 +521,6 @@ fn check_bundle_within<'a>( let name = &canister.name; let at = |e: Unsupported| format!("canister '{name}': {e}"); - if canister.build.steps.iter().any(|s| matches!(s, BuildStep::Script(_))) { - return Err(at(NoScripts::script_refused("build"))); - } - if canister.sync.steps.iter().any(|s| matches!(s, SyncStep::Script(_))) { - return Err(at(NoScripts::script_refused("sync"))); - } let [BuildStep::Prebuilt(adapter)] = canister.build.steps.as_slice() else { return Err(at(Unsupported(format!( "built by {} steps where a bundle has exactly one pre-built step; rebuild the \ @@ -743,20 +707,23 @@ fn host_within(fs: BundleFs, project_bytes: u64) -> Host { Arc::new(ProjectRootLocateImpl::new(root.clone(), Some(root))); let files: Arc = Arc::new(fs.clone()); let wasm: Arc = Arc::new(BundledWasm(fs.clone())); - let project = Lazy::<_, Project>::new(ProjectLoadImpl { + // The untrusted rule, composed as `icp --untrusted` composes it: no + // recipe is fetched, and the project is vetted as it loads. The loader + // is the one gate on the project's shape, so the builder is the + // library's own. + let project = Lazy::<_, Project>::new(Vetted::new(ProjectLoadImpl { project_root_locate: locate.clone(), recipe: Arc::new(NoRecipes), files: Arc::new(Metered::new(fs, project_bytes)), - }); - let no_scripts = Arc::new(NoScripts { prebuilt: Builder::new(wasm.clone(), files.clone()) }); + })); Host { project: Arc::new(project), - files, + files: files.clone(), ids: Arc::new(AccessImpl::new(locate.clone())), artifacts: Arc::new(ArtifactStore::new(locate)), - builder: no_scripts.clone(), + builder: Arc::new(Builder::new(wasm.clone(), files)), syncer: Arc::new(Syncer::new( - no_scripts, + Arc::new(NoScripts), wasm.clone(), Arc::new(icp_sync_plugin::Wasmtime), )), @@ -771,10 +738,8 @@ fn host_within(fs: BundleFs, project_bytes: u64) -> Host { /// return it as the filesystem the deploy reads through, confined to it. /// /// Only regular files and directories are written — `icp project bundle` -/// writes nothing else, a symlink or a device node is how an archive reaches -/// past its directory, and the confinement the library reads through is -/// lexical, so it holds only for a tree without links — and their sizes may -/// not add up to more than +/// writes nothing else, and a symlink or a device node is how an archive +/// reaches past its directory — and their sizes may not add up to more than /// [`MAX_EXTRACTED_BYTES`], nor their number, with the directories their /// paths imply, to more than [`MAX_BUNDLE_ENTRIES`]. `tar` refuses entry /// paths that would land outside `into`. What it extracts is then required @@ -797,7 +762,10 @@ fn extract_bundle_within( MAX_BUNDLE_BYTES )); } - let fs = Confined::new(into); + // A link is refused on the way in, and the filesystem refuses one again + // should it appear under the root later: the rule is the shape of the + // tree, and the two hold it on either side of the extraction. + let fs = Confined::refusing_links(into); let unreadable = |e: std::io::Error| format!("the bundle is not a readable .tar.gz archive: {e}"); let decoder = Limited { inner: flate2::read::GzDecoder::new(bytes), left: MAX_EXTRACTED_BYTES }; @@ -1063,9 +1031,9 @@ mod tests { } // A symlink is how an archive reaches past its directory; a bundle - // carries none, so one is refused rather than written — which is what - // lets the confined filesystem, lexical as it is, trust that no path - // under the root leads out of it. + // carries none, so one is refused rather than written. The filesystem + // the bundle is then read through refuses one as well (the test after + // this), so neither gate stands alone. #[test] fn an_archive_with_a_symlink_is_refused() { let (_dir, into) = scratch(); @@ -1085,6 +1053,67 @@ mod tests { assert!(!into.join("icp.yaml").exists()); } + // Should a link appear under the root after extraction, the filesystem + // the bundle is read through refuses any path that passes through it, + // wherever it leads: `Confined::refusing_links`, the untrusted rule's + // own filesystem. + #[cfg(unix)] + #[tokio::test] + async fn the_bundle_filesystem_refuses_a_link_made_after_extraction() { + let (_dir, into) = scratch(); + let fs = extract_bundle(&archive(&[("icp.yaml", b"canisters: []")]), &into).unwrap(); + std::os::unix::fs::symlink("/etc/passwd", into.join("out.txt")).unwrap(); + std::os::unix::fs::symlink("icp.yaml", into.join("in.yaml")).unwrap(); + for spelling in ["out.txt", "in.yaml", "./in.yaml"] { + let err = fs.read(Utf8Path::new(spelling)).await.unwrap_err().to_string(); + assert!(err.contains("passes through"), "{spelling}: {err}"); + assert!(locate(&fs, Utf8Path::new(spelling)).is_err(), "{spelling}"); + } + assert_eq!(fs.read(Utf8Path::new("icp.yaml")).await.unwrap(), b"canisters: []"); + } + + /// An error and its causes, outermost first. + fn chain(err: &(dyn std::error::Error + 'static)) -> String { + let mut text = err.to_string(); + let mut source = err.source(); + while let Some(cause) = source { + text.push_str(": "); + text.push_str(&cause.to_string()); + source = cause.source(); + } + text + } + + // The rule for a project someone else handed over is the library's own, + // and it holds at the load: a script step, a module fetched from a URL, + // or a recipe fails the load, before any phase could act on it. + #[tokio::test] + async fn a_project_asking_more_than_an_untrusted_one_may_fails_to_load() { + for (manifest, refused) in [ + ( + "canisters:\n - name: backend\n build:\n steps:\n - type: script\n command: touch /tmp/pwned\n", + "not a pre-built module from a file in the project", + ), + ( + "canisters:\n - name: backend\n build:\n steps:\n - type: pre-built\n url: https://example.com/backend.wasm\n sha256: abc\n", + "not a pre-built module from a file in the project", + ), + ( + "canisters:\n - name: backend\n recipe:\n type: \"@dfinity/rust@v1\"\n", + "recipe '@dfinity/rust@v1' is not fetched", + ), + ] { + let (_dir, into) = scratch(); + let fs = extract_bundle(&archive(&[("icp.yaml", manifest.as_bytes())]), &into).unwrap(); + let err = host_for(fs).project.load().await.expect_err(refused); + let err = chain(&err); + assert!(err.contains(refused), "{refused}: {err}"); + // The refusal names the step, so the reader sees what was asked. + let named = ["touch /tmp/pwned", "example.com/backend.wasm", "@dfinity/rust@v1"]; + assert!(named.iter().any(|n| err.contains(n)), "{err}"); + } + } + #[test] fn an_archive_carrying_project_state_is_refused() { let (_dir, into) = scratch(); diff --git a/crates/imcp2-deploy/src/lib.rs b/crates/imcp2-deploy/src/lib.rs index 0ee9bd4..ff0b486 100644 --- a/crates/imcp2-deploy/src/lib.rs +++ b/crates/imcp2-deploy/src/lib.rs @@ -8,13 +8,12 @@ //! crates (see `Cargo.toml`). //! //! [`host`] is the `icp_project::Host` for a bundle: the archive `icp project -//! bundle` writes, treated as untrusted input. The confinement of the files -//! the library reads is the library's own (`icp_project::files::Confined`, -//! added for this use by dfinity/icp-cli#866, with dfinity/icp-cli#868 -//! holding what `icp project bundle` writes to the shape that confinement -//! relies on; issue #865): this crate pins that stack and adds the bounds on -//! the archive itself and the checks on the bundle's shape. The deploy -//! pipeline over it and the MCP tools that expose it follow in their own -//! changes. +//! bundle` writes, treated as untrusted input. The rule it is held to is the +//! library's own, `icp_project::untrusted`, the one `icp --untrusted` +//! applies (dfinity/icp-cli#871, on #866 for the confined filesystem and +//! #868 for what `icp project bundle` writes; issue #865): this crate pins +//! that stack, composes the rule as the CLI does, and adds the bounds on the +//! archive itself and the checks on the bundle's shape. The deploy pipeline +//! over it and the MCP tools that expose it follow in their own changes. pub mod host; From f20ff1230ccf5ba670882f4c18717850e36825df Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Fri, 6 Nov 2026 16:18:26 +0000 Subject: [PATCH 09/12] Follow #871 to its head, where the untrusted rule is on what a project runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dfinity/icp-cli#871 has moved its line: between reading and running, rather than around the project directory. Reading is no longer restricted — the loader reads argument files, manifests and recipes from wherever the manifest points, through the ordinary filesystem — and what runs is held to the list: each build step a pre-built module, each sync step a plugin, and the file each names within the project, judged by where it really is once every link is followed and every `..` applied. `Confined::refusing_links` and `NoRecipes` are gone from upstream, `Vetted::new` takes the filesystem the paths resolve through, and `--untrusted` sits on `build`, `deploy` and `sync` rather than on every command. The host follows. The bundle is still read through `Confined` — a bundle has no business outside its own directory, and the vet resolves each module through the same seam — but the confinement is this crate's choice now, not the rule's. No recipe is fetched, by this crate's rule rather than the library's: the CLI would render one and vet the steps it produces, a bundle has none, so the resolver that refuses them is back here, naming the recipe. The refusal wording follows upstream: a step is "not a pre-built module", not "from a file in the project", and a module linked out of the root is refused for where it resolves, not for passing through a link. Pins the four icp-cli crates to the head of #871. The test that read a link made after extraction as a filesystem refusal now loads a project through one: a link out fails the load naming where it leads, a link within is let be, a dangling one is refused. The read-budget test's archive carries the module its manifest names, as the vet now asks. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 8 +- crates/imcp2-deploy/Cargo.toml | 16 +-- crates/imcp2-deploy/src/host.rs | 182 ++++++++++++++++++++++---------- crates/imcp2-deploy/src/lib.rs | 5 +- 4 files changed, 140 insertions(+), 71 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f6dad90..30bdd21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2172,7 +2172,7 @@ dependencies = [ [[package]] name = "icp-canister-interfaces" version = "1.8.0" -source = "git+https://github.com/dfinity/icp-cli?rev=ae3367ef80cfb2798bc2c8395e39ad6af76a9be5#ae3367ef80cfb2798bc2c8395e39ad6af76a9be5" +source = "git+https://github.com/dfinity/icp-cli?rev=4bba4a648ac03296ceeccc5bd0f4571a7ffa635a#4bba4a648ac03296ceeccc5bd0f4571a7ffa635a" dependencies = [ "bigdecimal", "candid", @@ -2183,7 +2183,7 @@ dependencies = [ [[package]] name = "icp-events" version = "1.8.0" -source = "git+https://github.com/dfinity/icp-cli?rev=ae3367ef80cfb2798bc2c8395e39ad6af76a9be5#ae3367ef80cfb2798bc2c8395e39ad6af76a9be5" +source = "git+https://github.com/dfinity/icp-cli?rev=4bba4a648ac03296ceeccc5bd0f4571a7ffa635a#4bba4a648ac03296ceeccc5bd0f4571a7ffa635a" dependencies = [ "serde", "tokio", @@ -2192,7 +2192,7 @@ dependencies = [ [[package]] name = "icp-project" version = "1.8.0" -source = "git+https://github.com/dfinity/icp-cli?rev=ae3367ef80cfb2798bc2c8395e39ad6af76a9be5#ae3367ef80cfb2798bc2c8395e39ad6af76a9be5" +source = "git+https://github.com/dfinity/icp-cli?rev=4bba4a648ac03296ceeccc5bd0f4571a7ffa635a#4bba4a648ac03296ceeccc5bd0f4571a7ffa635a" dependencies = [ "async-trait", "bigdecimal", @@ -2240,7 +2240,7 @@ dependencies = [ [[package]] name = "icp-sync-plugin" version = "1.8.0" -source = "git+https://github.com/dfinity/icp-cli?rev=ae3367ef80cfb2798bc2c8395e39ad6af76a9be5#ae3367ef80cfb2798bc2c8395e39ad6af76a9be5" +source = "git+https://github.com/dfinity/icp-cli?rev=4bba4a648ac03296ceeccc5bd0f4571a7ffa635a#4bba4a648ac03296ceeccc5bd0f4571a7ffa635a" dependencies = [ "async-trait", "bytes", diff --git a/crates/imcp2-deploy/Cargo.toml b/crates/imcp2-deploy/Cargo.toml index 96d9b88..4e7f67d 100644 --- a/crates/imcp2-deploy/Cargo.toml +++ b/crates/imcp2-deploy/Cargo.toml @@ -48,9 +48,11 @@ tempfile = "3" # #871: #866 adds `files::Confined` (the filesystem the bundle host reads # through, see `src/host.rs`), #868 has `icp project bundle` write an archive # of regular files and directories alone, and #871 states the rule for an -# untrusted project (`icp_project::untrusted`, what `icp --untrusted` -# applies), which the host composes as the CLI does. Once the stack is on -# icp-cli's main, re-pin to main. +# untrusted project (`icp_project::untrusted`, what `--untrusted` on `icp +# build`, `deploy` and `sync` applies): what it runs is held to a list, with +# each module's path judged by where it really resolves. The host composes +# the rule as the CLI does. Once the stack is on icp-cli's main, re-pin to +# main. # # * icp-project: manifests, the deploy operation, the id/artifact stores. # * icp-events: the progress event stream the operations report on. @@ -59,10 +61,10 @@ tempfile = "3" # brings wasmtime into this workspace. # * icp-canister-interfaces: the engine-canister registry id a created # canister's subnet is looked up in. -icp-project = { git = "https://github.com/dfinity/icp-cli", rev = "ae3367ef80cfb2798bc2c8395e39ad6af76a9be5" } -icp-events = { git = "https://github.com/dfinity/icp-cli", rev = "ae3367ef80cfb2798bc2c8395e39ad6af76a9be5" } -icp-sync-plugin = { git = "https://github.com/dfinity/icp-cli", rev = "ae3367ef80cfb2798bc2c8395e39ad6af76a9be5" } -icp-canister-interfaces = { git = "https://github.com/dfinity/icp-cli", rev = "ae3367ef80cfb2798bc2c8395e39ad6af76a9be5" } +icp-project = { git = "https://github.com/dfinity/icp-cli", rev = "4bba4a648ac03296ceeccc5bd0f4571a7ffa635a" } +icp-events = { git = "https://github.com/dfinity/icp-cli", rev = "4bba4a648ac03296ceeccc5bd0f4571a7ffa635a" } +icp-sync-plugin = { git = "https://github.com/dfinity/icp-cli", rev = "4bba4a648ac03296ceeccc5bd0f4571a7ffa635a" } +icp-canister-interfaces = { git = "https://github.com/dfinity/icp-cli", rev = "4bba4a648ac03296ceeccc5bd0f4571a7ffa635a" } [dev-dependencies] # The tests hand-build small bundles (a manifest plus a wasm) and load them diff --git a/crates/imcp2-deploy/src/host.rs b/crates/imcp2-deploy/src/host.rs index 14e636a..6785f50 100644 --- a/crates/imcp2-deploy/src/host.rs +++ b/crates/imcp2-deploy/src/host.rs @@ -13,28 +13,34 @@ //! that passes it may have fetched it from anywhere. So the shape is not //! assumed but enforced, and the rule it is held to is the library's own: //! `icp_project::untrusted`, the rule for a project someone else handed over -//! (dfinity/icp-cli#871), the one the CLI's `--untrusted` flag applies. The +//! (dfinity/icp-cli#871), the one `--untrusted` on `icp build`, `deploy` and +//! `sync` applies. The line it draws is between reading and running: what +//! the project runs is held to a list, stated as what is allowed, and the //! host composes it as the CLI does: //! -//! * **The project is loaded through [`Confined::refusing_links`]**, the -//! host filesystem held to the extraction directory by the spelling of -//! each path — `..` cannot climb above the root, an absolute path -//! elsewhere is refused — and refusing a path that passes through a -//! symlink anywhere under it, wherever the link leads. The loader resolves -//! every path it reads through that one seam, so a manifest can name only -//! what the archive brought with it. The disk is the byte store because -//! the sync-plugin runtime needs real paths to mount. -//! * **No recipe is fetched** ([`NoRecipes`]): a bundle spells every step -//! out, so a project naming one fails to load, naming the recipe. -//! * **The loaded project is vetted** ([`Vetted`]) before it is handed out, -//! to what is allowed rather than what is not: a build step is a pre-built -//! module from a file in the project, a sync step a plugin from one, and a -//! project with any other — a script, a download — fails the load. The -//! loader is the one gate on the project's shape; the builder and syncer -//! are the library's own, and see no other step. +//! * **The loaded project is vetted** ([`Vetted`]) before it is handed out: +//! each build step is a pre-built module, each sync step a plugin, and the +//! file each names is within the project, judged by where it really is +//! once every link is followed and every `..` applied. A project with any +//! other step — a script, a download — or a module or plugin that resolves +//! outside the root fails the load, naming what was found. The loader is +//! the one gate on the project's shape; the builder and syncer are the +//! library's own, and see no other step. //! //! What a consumer of a bundle adds, this crate adds: //! +//! * **The project is read through [`Confined`]**, the host filesystem held +//! to the extraction directory by the spelling of each path — `..` cannot +//! climb above the root, an absolute path elsewhere is refused — so a +//! manifest can name only what the archive brought with it, and the vet +//! resolves each module's real path through the same seam. The disk is +//! the byte store because the sync-plugin runtime needs real paths to +//! mount. The CLI reads an untrusted project through the host filesystem, +//! since reading is not what the rule restricts; a bundle has no business +//! outside its own directory, and is held to it. +//! * **No recipe is fetched** ([`NoRecipes`]): the CLI renders one and vets +//! the steps it produces, but a bundle spells every step out, so a project +//! naming a recipe is not a bundle and fails to load, naming the recipe. //! * **Extraction** writes only regular files and directories, within a total //! size budget and an object budget, under the scratch directory //! ([`extract_bundle`]), and refuses the `.icp` state a deploy seeds for @@ -49,9 +55,10 @@ //! user's own project may run, fetch and link whatever it likes; `icp //! project bundle` writes an archive of regular files and directories with //! every step spelled out (dfinity/icp-cli#868); and a consumer of a bundle -//! it does not trust loads it as untrusted (dfinity/icp-cli#866 for the -//! confined filesystem, #871 for the rest). This crate is that consumer, -//! and pins the stack; until it is on icp-cli's main, the pin is its head. +//! it does not trust loads it as untrusted (dfinity/icp-cli#871, on #866 +//! for the confined filesystem this crate reads it through). This crate is +//! that consumer, and pins the stack; until it is on icp-cli's main, the +//! pin is its head. //! //! Everything the project library reads or writes — the manifest, the wasms, //! the `.icp` id and artifact stores, the build scratch — lives under the @@ -70,6 +77,7 @@ use icp_events::StepReporter; use icp_project::{ canister::{ build::Builder, + recipe::{Fetched, Resolve, ResolveError}, sync::{ script::{ScriptInvocation, ScriptRunError, ScriptRunner}, Syncer, @@ -79,7 +87,7 @@ use icp_project::{ files::{Confined, FileSystem, FsError, Scratch}, host::{Host, Ignore}, manifest::{ - plugin::NamedPaths, prebuilt::SourceField, BuildStep, ProjectRootLocate, + plugin::NamedPaths, prebuilt::SourceField, recipe::Recipe, BuildStep, ProjectRootLocate, ProjectRootLocateImpl, SyncStep, PROJECT_MANIFEST, }, network::{ @@ -90,7 +98,7 @@ use icp_project::{ random::HostRandom, store_artifact::ArtifactStore, store_id::AccessImpl, - untrusted::{NoRecipes, Vetted}, + untrusted::Vetted, Canister, Lazy, Network, Project, ProjectLoadImpl, }; use sha2::{Digest, Sha256}; @@ -363,7 +371,7 @@ impl FileSystem for Metered { } } -// ── Wasms, scripts ──────────────────────────────────────────────────────── +// ── Wasms, recipes, scripts ─────────────────────────────────────────────── /// Wasm resolution for a bundle: the archive carries every module it names, /// so a source is a path inside the bundle (verified against its recorded @@ -433,6 +441,23 @@ fn gunzipped_within(path: &Utf8Path, bytes: &[u8], limit: u64) -> Result Result { + Err(ResolveError::new(Unsupported(format!( + "the manifest names recipe '{}', which a bundle never does: a bundle spells every \ + step out; rebuild it with `icp project bundle`", + recipe.recipe_type + )))) + } +} + /// The script runner the syncer is composed with. A bundle has no script /// step for it to run — the vetted loader refuses a project with one — so /// this spawns nothing; it is here because the syncer takes a runner, not @@ -707,15 +732,19 @@ fn host_within(fs: BundleFs, project_bytes: u64) -> Host { Arc::new(ProjectRootLocateImpl::new(root.clone(), Some(root))); let files: Arc = Arc::new(fs.clone()); let wasm: Arc = Arc::new(BundledWasm(fs.clone())); - // The untrusted rule, composed as `icp --untrusted` composes it: no - // recipe is fetched, and the project is vetted as it loads. The loader - // is the one gate on the project's shape, so the builder is the - // library's own. - let project = Lazy::<_, Project>::new(Vetted::new(ProjectLoadImpl { - project_root_locate: locate.clone(), - recipe: Arc::new(NoRecipes), - files: Arc::new(Metered::new(fs, project_bytes)), - })); + // The untrusted rule, composed as `icp --untrusted` composes it: the + // project is vetted as it loads, each module's path resolved through + // the bundle's own filesystem. That no recipe is fetched is this + // crate's rule, not the library's: a bundle has none. The loader is the + // one gate on the project's shape, so the builder is the library's own. + let project = Lazy::<_, Project>::new(Vetted::new( + ProjectLoadImpl { + project_root_locate: locate.clone(), + recipe: Arc::new(NoRecipes), + files: Arc::new(Metered::new(fs, project_bytes)), + }, + files.clone(), + )); Host { project: Arc::new(project), files: files.clone(), @@ -762,10 +791,11 @@ fn extract_bundle_within( MAX_BUNDLE_BYTES )); } - // A link is refused on the way in, and the filesystem refuses one again - // should it appear under the root later: the rule is the shape of the - // tree, and the two hold it on either side of the extraction. - let fs = Confined::refusing_links(into); + // A link is refused on the way in, so nothing under the root leads out + // of it. Should one appear later, the filesystem follows it as the host + // does, and the vet sees a module named through it for where it really + // is, refusing one that resolves outside the root. + let fs = Confined::new(into); let unreadable = |e: std::io::Error| format!("the bundle is not a readable .tar.gz archive: {e}"); let decoder = Limited { inner: flate2::read::GzDecoder::new(bytes), left: MAX_EXTRACTED_BYTES }; @@ -1031,9 +1061,9 @@ mod tests { } // A symlink is how an archive reaches past its directory; a bundle - // carries none, so one is refused rather than written. The filesystem - // the bundle is then read through refuses one as well (the test after - // this), so neither gate stands alone. + // carries none, so one is refused rather than written. Should one appear + // later, the vet refuses a module that resolves through it to outside + // the root (the test after this), so neither gate stands alone. #[test] fn an_archive_with_a_symlink_is_refused() { let (_dir, into) = scratch(); @@ -1054,22 +1084,51 @@ mod tests { } // Should a link appear under the root after extraction, the filesystem - // the bundle is read through refuses any path that passes through it, - // wherever it leads: `Confined::refusing_links`, the untrusted rule's - // own filesystem. + // follows it as the host does, and the vet sees a module named through + // it for where it really is: one that resolves outside the root fails + // the load, naming where it leads, and one that resolves within is let + // be. The untrusted rule's own test, run through this host. #[cfg(unix)] #[tokio::test] - async fn the_bundle_filesystem_refuses_a_link_made_after_extraction() { + async fn a_module_linked_out_of_the_bundle_fails_the_load() { + use std::os::unix::fs::symlink; + let manifest = |path: &str| { + format!( + "canisters:\n - name: backend\n build:\n steps:\n - type: \ + pre-built\n path: {path}\n" + ) + }; + let elsewhere = tempfile::tempdir().unwrap(); + let secret = elsewhere.path().join("secret.wasm"); + std::fs::write(&secret, b"\0asm").unwrap(); + + // Out: refused, naming the link, where it leads and the root. let (_dir, into) = scratch(); - let fs = extract_bundle(&archive(&[("icp.yaml", b"canisters: []")]), &into).unwrap(); - std::os::unix::fs::symlink("/etc/passwd", into.join("out.txt")).unwrap(); - std::os::unix::fs::symlink("icp.yaml", into.join("in.yaml")).unwrap(); - for spelling in ["out.txt", "in.yaml", "./in.yaml"] { - let err = fs.read(Utf8Path::new(spelling)).await.unwrap_err().to_string(); - assert!(err.contains("passes through"), "{spelling}: {err}"); - assert!(locate(&fs, Utf8Path::new(spelling)).is_err(), "{spelling}"); - } - assert_eq!(fs.read(Utf8Path::new("icp.yaml")).await.unwrap(), b"canisters: []"); + let bytes = archive(&[("icp.yaml", manifest("out.wasm").as_bytes())]); + let fs = extract_bundle(&bytes, &into).unwrap(); + symlink(&secret, into.join("out.wasm")).unwrap(); + let err = chain(&host_for(fs).project.load().await.expect_err("a link out")); + assert!(err.contains("outside the project"), "{err}"); + assert!(err.contains("out.wasm") && err.contains("secret.wasm"), "{err}"); + + // Within: let be, and the module is served through the link. + let (_dir, into) = scratch(); + let bytes = + archive(&[("icp.yaml", manifest("in.wasm").as_bytes()), ("backend.wasm", b"\0asm")]); + let fs = extract_bundle(&bytes, &into).unwrap(); + symlink("backend.wasm", into.join("in.wasm")).unwrap(); + let project = host_for(fs.clone()).project.load().await.unwrap(); + assert_eq!(project.canisters.len(), 1); + assert_eq!(fs.read(Utf8Path::new("in.wasm")).await.unwrap(), b"\0asm"); + + // Dangling: nothing can be said about where it leads, so refused. + let (_dir, into) = scratch(); + let bytes = archive(&[("icp.yaml", manifest("gone.wasm").as_bytes())]); + let fs = extract_bundle(&bytes, &into).unwrap(); + symlink(into.join("nowhere.wasm"), into.join("gone.wasm")).unwrap(); + let err = chain(&host_for(fs).project.load().await.expect_err("a dangling link")); + assert!(err.contains("does not resolve to a file"), "{err}"); + assert!(err.contains("gone.wasm"), "{err}"); } /// An error and its causes, outermost first. @@ -1085,22 +1144,23 @@ mod tests { } // The rule for a project someone else handed over is the library's own, - // and it holds at the load: a script step, a module fetched from a URL, - // or a recipe fails the load, before any phase could act on it. + // and it holds at the load: a script step or a module fetched from a URL + // fails the load, before any phase could act on it. So does a recipe, + // by this crate's rule: the library would render it, a bundle has none. #[tokio::test] async fn a_project_asking_more_than_an_untrusted_one_may_fails_to_load() { for (manifest, refused) in [ ( "canisters:\n - name: backend\n build:\n steps:\n - type: script\n command: touch /tmp/pwned\n", - "not a pre-built module from a file in the project", + "not a pre-built module, which is all an untrusted project may have", ), ( "canisters:\n - name: backend\n build:\n steps:\n - type: pre-built\n url: https://example.com/backend.wasm\n sha256: abc\n", - "not a pre-built module from a file in the project", + "not a pre-built module, which is all an untrusted project may have", ), ( "canisters:\n - name: backend\n recipe:\n type: \"@dfinity/rust@v1\"\n", - "recipe '@dfinity/rust@v1' is not fetched", + "recipe '@dfinity/rust@v1', which a bundle never does", ), ] { let (_dir, into) = scratch(); @@ -1383,7 +1443,13 @@ mod tests { format: bin\n build:\n steps:\n - type: pre-built\n \ path: a.wasm\n"; let args = [7u8; 1000]; - let bytes = archive(&[("icp.yaml", manifest.as_bytes()), ("args.bin", &args)]); + // The module is in the archive, as the vet asks; what the loader + // reads of it is nothing. + let bytes = archive(&[ + ("icp.yaml", manifest.as_bytes()), + ("args.bin", &args), + ("a.wasm", b"\0asm"), + ]); // Room for the manifest and one read of the file, not two. let (_dir, into) = scratch(); diff --git a/crates/imcp2-deploy/src/lib.rs b/crates/imcp2-deploy/src/lib.rs index ff0b486..2bb2b52 100644 --- a/crates/imcp2-deploy/src/lib.rs +++ b/crates/imcp2-deploy/src/lib.rs @@ -10,8 +10,9 @@ //! [`host`] is the `icp_project::Host` for a bundle: the archive `icp project //! bundle` writes, treated as untrusted input. The rule it is held to is the //! library's own, `icp_project::untrusted`, the one `icp --untrusted` -//! applies (dfinity/icp-cli#871, on #866 for the confined filesystem and -//! #868 for what `icp project bundle` writes; issue #865): this crate pins +//! applies (dfinity/icp-cli#871, on #866 for the confined filesystem this +//! crate reads it through and #868 for what `icp project bundle` writes; +//! issue #865): this crate pins //! that stack, composes the rule as the CLI does, and adds the bounds on the //! archive itself and the checks on the bundle's shape. The deploy pipeline //! over it and the MCP tools that expose it follow in their own changes. From d497d8fac036cf1b1943d47c54457bbb9ca47753 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Mon, 9 Nov 2026 14:34:03 +0000 Subject: [PATCH 10/12] Follow #871 to its head: what a step names must be a regular file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dfinity/icp-cli#871 now asks, after resolving where a module or plugin really is, whether a regular file is there, since a directory cannot be read as a module and a pipe is read from whoever writes to it. The vet asks the filesystem it is given, and a path that does not resolve is now refused as resolving "to anything" rather than "to a file". The host follows, and resolves the extraction root once before the filesystem is held to it. The vet hands the filesystem a resolved path, and `Confined` holds every path to the root by spelling alone, so a root named through a link — macOS keeps its temporary directories under `/var`, a link to `/private/var` — would put every resolved path outside it and refuse every bundle. `dunce` resolves it, as icp-project's own host filesystem does, keeping a Windows path free of the `\\?\` prefix. Pins the four icp-cli crates to the head of #871. Two tests: a directory of the bundle named as a module fails the load naming what was found, and a root named through a link is the directory it leads to. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 9 ++--- crates/imcp2-deploy/Cargo.toml | 18 ++++++---- crates/imcp2-deploy/src/host.rs | 62 +++++++++++++++++++++++++++++---- 3 files changed, 71 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 30bdd21..60963bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2172,7 +2172,7 @@ dependencies = [ [[package]] name = "icp-canister-interfaces" version = "1.8.0" -source = "git+https://github.com/dfinity/icp-cli?rev=4bba4a648ac03296ceeccc5bd0f4571a7ffa635a#4bba4a648ac03296ceeccc5bd0f4571a7ffa635a" +source = "git+https://github.com/dfinity/icp-cli?rev=9056f29c4c2a574551a3334d34913ce04c2cf5cb#9056f29c4c2a574551a3334d34913ce04c2cf5cb" dependencies = [ "bigdecimal", "candid", @@ -2183,7 +2183,7 @@ dependencies = [ [[package]] name = "icp-events" version = "1.8.0" -source = "git+https://github.com/dfinity/icp-cli?rev=4bba4a648ac03296ceeccc5bd0f4571a7ffa635a#4bba4a648ac03296ceeccc5bd0f4571a7ffa635a" +source = "git+https://github.com/dfinity/icp-cli?rev=9056f29c4c2a574551a3334d34913ce04c2cf5cb#9056f29c4c2a574551a3334d34913ce04c2cf5cb" dependencies = [ "serde", "tokio", @@ -2192,7 +2192,7 @@ dependencies = [ [[package]] name = "icp-project" version = "1.8.0" -source = "git+https://github.com/dfinity/icp-cli?rev=4bba4a648ac03296ceeccc5bd0f4571a7ffa635a#4bba4a648ac03296ceeccc5bd0f4571a7ffa635a" +source = "git+https://github.com/dfinity/icp-cli?rev=9056f29c4c2a574551a3334d34913ce04c2cf5cb#9056f29c4c2a574551a3334d34913ce04c2cf5cb" dependencies = [ "async-trait", "bigdecimal", @@ -2240,7 +2240,7 @@ dependencies = [ [[package]] name = "icp-sync-plugin" version = "1.8.0" -source = "git+https://github.com/dfinity/icp-cli?rev=4bba4a648ac03296ceeccc5bd0f4571a7ffa635a#4bba4a648ac03296ceeccc5bd0f4571a7ffa635a" +source = "git+https://github.com/dfinity/icp-cli?rev=9056f29c4c2a574551a3334d34913ce04c2cf5cb#9056f29c4c2a574551a3334d34913ce04c2cf5cb" dependencies = [ "async-trait", "bytes", @@ -2471,6 +2471,7 @@ dependencies = [ "base64 0.22.1", "camino", "candid", + "dunce", "flate2", "hex", "ic-agent", diff --git a/crates/imcp2-deploy/Cargo.toml b/crates/imcp2-deploy/Cargo.toml index 4e7f67d..592e337 100644 --- a/crates/imcp2-deploy/Cargo.toml +++ b/crates/imcp2-deploy/Cargo.toml @@ -39,6 +39,10 @@ flate2 = "1" # Each deploy extracts its bundle into a scratch directory that is removed # when the deploy ends, whichever way it ends. tempfile = "3" +# The extraction directory is resolved to the spelling the disk knows it by +# (see `extract_bundle`); `dunce` keeps a Windows path free of the `\\?\` +# prefix, as icp-project's own host filesystem does. +dunce = "1" # The icp CLI's project library and the seams it is driven through, pinned to # one commit so the four stay in lockstep (they share a workspace version and @@ -50,9 +54,9 @@ tempfile = "3" # of regular files and directories alone, and #871 states the rule for an # untrusted project (`icp_project::untrusted`, what `--untrusted` on `icp # build`, `deploy` and `sync` applies): what it runs is held to a list, with -# each module's path judged by where it really resolves. The host composes -# the rule as the CLI does. Once the stack is on icp-cli's main, re-pin to -# main. +# what each step names a regular file judged by where it really resolves. +# The host composes the rule as the CLI does. Once the stack is on icp-cli's +# main, re-pin to main. # # * icp-project: manifests, the deploy operation, the id/artifact stores. # * icp-events: the progress event stream the operations report on. @@ -61,10 +65,10 @@ tempfile = "3" # brings wasmtime into this workspace. # * icp-canister-interfaces: the engine-canister registry id a created # canister's subnet is looked up in. -icp-project = { git = "https://github.com/dfinity/icp-cli", rev = "4bba4a648ac03296ceeccc5bd0f4571a7ffa635a" } -icp-events = { git = "https://github.com/dfinity/icp-cli", rev = "4bba4a648ac03296ceeccc5bd0f4571a7ffa635a" } -icp-sync-plugin = { git = "https://github.com/dfinity/icp-cli", rev = "4bba4a648ac03296ceeccc5bd0f4571a7ffa635a" } -icp-canister-interfaces = { git = "https://github.com/dfinity/icp-cli", rev = "4bba4a648ac03296ceeccc5bd0f4571a7ffa635a" } +icp-project = { git = "https://github.com/dfinity/icp-cli", rev = "9056f29c4c2a574551a3334d34913ce04c2cf5cb" } +icp-events = { git = "https://github.com/dfinity/icp-cli", rev = "9056f29c4c2a574551a3334d34913ce04c2cf5cb" } +icp-sync-plugin = { git = "https://github.com/dfinity/icp-cli", rev = "9056f29c4c2a574551a3334d34913ce04c2cf5cb" } +icp-canister-interfaces = { git = "https://github.com/dfinity/icp-cli", rev = "9056f29c4c2a574551a3334d34913ce04c2cf5cb" } [dev-dependencies] # The tests hand-build small bundles (a manifest plus a wasm) and load them diff --git a/crates/imcp2-deploy/src/host.rs b/crates/imcp2-deploy/src/host.rs index 6785f50..1ed90f1 100644 --- a/crates/imcp2-deploy/src/host.rs +++ b/crates/imcp2-deploy/src/host.rs @@ -19,11 +19,12 @@ //! host composes it as the CLI does: //! //! * **The loaded project is vetted** ([`Vetted`]) before it is handed out: -//! each build step is a pre-built module, each sync step a plugin, and the -//! file each names is within the project, judged by where it really is -//! once every link is followed and every `..` applied. A project with any -//! other step — a script, a download — or a module or plugin that resolves -//! outside the root fails the load, naming what was found. The loader is +//! each build step is a pre-built module, each sync step a plugin, and +//! what each names is a regular file within the project, judged by where +//! it really is once every link is followed and every `..` applied. A +//! project with any other step — a script, a download — or a module or +//! plugin that resolves outside the root, or to a directory or a pipe, +//! fails the load, naming what was found. The loader is //! the one gate on the project's shape; the builder and syncer are the //! library's own, and see no other step. //! @@ -791,11 +792,22 @@ fn extract_bundle_within( MAX_BUNDLE_BYTES )); } + // The root as the disk knows it. The vet resolves each module's path + // through this filesystem and then asks it whether a regular file is at + // the resolved spelling, which the filesystem holds to the root by + // spelling alone; a root named through a link (macOS keeps its temporary + // directories under `/var`, a link to `/private/var`) would put every + // resolved path outside it. So the root is resolved once, here, before + // the filesystem is held to it. + let into = dunce::canonicalize(into) + .map_err(|e| format!("the extraction directory '{into}' cannot be resolved: {e}"))?; + let into = Utf8PathBuf::from_path_buf(into) + .map_err(|p| format!("the extraction directory '{}' is not UTF-8", p.display()))?; // A link is refused on the way in, so nothing under the root leads out // of it. Should one appear later, the filesystem follows it as the host // does, and the vet sees a module named through it for where it really // is, refusing one that resolves outside the root. - let fs = Confined::new(into); + let fs = Confined::new(&into); let unreadable = |e: std::io::Error| format!("the bundle is not a readable .tar.gz archive: {e}"); let decoder = Limited { inner: flate2::read::GzDecoder::new(bytes), left: MAX_EXTRACTED_BYTES }; @@ -1127,10 +1139,46 @@ mod tests { let fs = extract_bundle(&bytes, &into).unwrap(); symlink(into.join("nowhere.wasm"), into.join("gone.wasm")).unwrap(); let err = chain(&host_for(fs).project.load().await.expect_err("a dangling link")); - assert!(err.contains("does not resolve to a file"), "{err}"); + assert!(err.contains("does not resolve to anything"), "{err}"); assert!(err.contains("gone.wasm"), "{err}"); } + // What a step names must be a regular file: a directory of the bundle + // named as a module is refused at the load, naming what was found. (A + // pipe cannot arrive in an archive: extraction writes files and + // directories alone.) + #[tokio::test] + async fn a_directory_named_as_a_module_fails_the_load() { + let manifest = "canisters:\n - name: backend\n build:\n steps:\n \ + - type: pre-built\n path: assets\n"; + let (_dir, into) = scratch(); + let bytes = archive_with_dirs(&[("icp.yaml", manifest.as_bytes())], &["assets"]); + let fs = extract_bundle(&bytes, &into).unwrap(); + let err = chain(&host_for(fs).project.load().await.expect_err("a directory")); + assert!(err.contains("not a regular file"), "{err}"); + assert!(err.contains("assets"), "{err}"); + } + + // The root is taken as the disk knows it: named through a link, the + // extraction lands in the directory the link leads to, and the project + // in it loads — the vet's resolved paths lie under the root it was given. + #[cfg(unix)] + #[tokio::test] + async fn a_root_named_through_a_link_is_the_directory_it_leads_to() { + let manifest = "canisters:\n - name: backend\n build:\n steps:\n \ + - type: pre-built\n path: backend.wasm\n"; + let (dir, into) = scratch(); + let alias = Utf8PathBuf::from_path_buf(dir.path().with_extension("alias")).unwrap(); + std::os::unix::fs::symlink(&into, &alias).unwrap(); + let bytes = archive(&[("icp.yaml", manifest.as_bytes()), ("backend.wasm", b"\0asm")]); + let fs = extract_bundle(&bytes, &alias).unwrap(); + assert_eq!(fs.root(), dunce::canonicalize(&into).unwrap()); + assert!(into.join("backend.wasm").is_file()); + let project = host_for(fs).project.load().await.unwrap(); + assert_eq!(project.canisters.len(), 1); + std::fs::remove_file(&alias).unwrap(); + } + /// An error and its causes, outermost first. fn chain(err: &(dyn std::error::Error + 'static)) -> String { let mut text = err.to_string(); From 0b9449549b2f44663d02abc5fe33b1a6631ba107 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Wed, 11 Nov 2026 00:59:14 +0000 Subject: [PATCH 11/12] Follow dfinity/icp-cli#873: the project confined beneath a directory handle The pin moves from the #866 -> #871 stack to #873, the same `--untrusted` feature with `files::Confined` built on cap-std. What changes for the host: The confined filesystem opens the root once as a directory handle and makes every read beneath it, so the operating system refuses a path that leaves the root; on top of that a path through a symlink is refused wherever it leads, within the root or without. The vet asks that filesystem whether what a step names is a regular file, so a module named through a link now fails the load as not a regular file of the project, where before one resolving within the root was let be. The root is matched as spelled, so the extraction directory is no longer canonicalized and `dunce` goes; a root named through a link holds the project as it is. `NoRecipes` is the library's own now, and so is holding a plugin's `dirs:` and `files:` to the project at the load: this crate's resolver goes, and its declared-paths check keeps only the byte budget the runtime's reads are held to. The tests read the new refusals, and the docs name #873 on #868. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 22 ++- crates/imcp2-deploy/Cargo.toml | 30 ++-- crates/imcp2-deploy/src/host.rs | 284 ++++++++++++++------------------ crates/imcp2-deploy/src/lib.rs | 11 +- 4 files changed, 158 insertions(+), 189 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 60963bf..54c016f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -516,6 +516,18 @@ dependencies = [ "winx", ] +[[package]] +name = "cap-std" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ec78e242cfa2cfe276807ac2ecc00315a6c97786977414bcd1c3963b6c91b8" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes 3.0.1", + "rustix 1.1.4", +] + [[package]] name = "cc" version = "1.2.64" @@ -2172,7 +2184,7 @@ dependencies = [ [[package]] name = "icp-canister-interfaces" version = "1.8.0" -source = "git+https://github.com/dfinity/icp-cli?rev=9056f29c4c2a574551a3334d34913ce04c2cf5cb#9056f29c4c2a574551a3334d34913ce04c2cf5cb" +source = "git+https://github.com/dfinity/icp-cli?rev=4b0656f6722e2ef78941d6b10ce9f71a389fd414#4b0656f6722e2ef78941d6b10ce9f71a389fd414" dependencies = [ "bigdecimal", "candid", @@ -2183,7 +2195,7 @@ dependencies = [ [[package]] name = "icp-events" version = "1.8.0" -source = "git+https://github.com/dfinity/icp-cli?rev=9056f29c4c2a574551a3334d34913ce04c2cf5cb#9056f29c4c2a574551a3334d34913ce04c2cf5cb" +source = "git+https://github.com/dfinity/icp-cli?rev=4b0656f6722e2ef78941d6b10ce9f71a389fd414#4b0656f6722e2ef78941d6b10ce9f71a389fd414" dependencies = [ "serde", "tokio", @@ -2192,7 +2204,7 @@ dependencies = [ [[package]] name = "icp-project" version = "1.8.0" -source = "git+https://github.com/dfinity/icp-cli?rev=9056f29c4c2a574551a3334d34913ce04c2cf5cb#9056f29c4c2a574551a3334d34913ce04c2cf5cb" +source = "git+https://github.com/dfinity/icp-cli?rev=4b0656f6722e2ef78941d6b10ce9f71a389fd414#4b0656f6722e2ef78941d6b10ce9f71a389fd414" dependencies = [ "async-trait", "bigdecimal", @@ -2200,6 +2212,7 @@ dependencies = [ "camino-tempfile", "candid", "candid_parser", + "cap-std", "dunce", "flate2", "futures", @@ -2240,7 +2253,7 @@ dependencies = [ [[package]] name = "icp-sync-plugin" version = "1.8.0" -source = "git+https://github.com/dfinity/icp-cli?rev=9056f29c4c2a574551a3334d34913ce04c2cf5cb#9056f29c4c2a574551a3334d34913ce04c2cf5cb" +source = "git+https://github.com/dfinity/icp-cli?rev=4b0656f6722e2ef78941d6b10ce9f71a389fd414#4b0656f6722e2ef78941d6b10ce9f71a389fd414" dependencies = [ "async-trait", "bytes", @@ -2471,7 +2484,6 @@ dependencies = [ "base64 0.22.1", "camino", "candid", - "dunce", "flate2", "hex", "ic-agent", diff --git a/crates/imcp2-deploy/Cargo.toml b/crates/imcp2-deploy/Cargo.toml index 592e337..a65d274 100644 --- a/crates/imcp2-deploy/Cargo.toml +++ b/crates/imcp2-deploy/Cargo.toml @@ -39,24 +39,20 @@ flate2 = "1" # Each deploy extracts its bundle into a scratch directory that is removed # when the deploy ends, whichever way it ends. tempfile = "3" -# The extraction directory is resolved to the spelling the disk knows it by -# (see `extract_bundle`); `dunce` keeps a Windows path free of the `\\?\` -# prefix, as icp-project's own host filesystem does. -dunce = "1" # The icp CLI's project library and the seams it is driven through, pinned to # one commit so the four stay in lockstep (they share a workspace version and # are not released independently). Bump the `rev` on all four together. # -# The pinned commit is the head of the dfinity/icp-cli stack #866 -> #868 -> -# #871: #866 adds `files::Confined` (the filesystem the bundle host reads -# through, see `src/host.rs`), #868 has `icp project bundle` write an archive -# of regular files and directories alone, and #871 states the rule for an -# untrusted project (`icp_project::untrusted`, what `--untrusted` on `icp -# build`, `deploy` and `sync` applies): what it runs is held to a list, with -# what each step names a regular file judged by where it really resolves. -# The host composes the rule as the CLI does. Once the stack is on icp-cli's -# main, re-pin to main. +# The pinned commit is the head of dfinity/icp-cli#873, on #868: #868 has +# `icp project bundle` write an archive of regular files and directories +# alone, and #873 states the rule for an untrusted project +# (`icp_project::untrusted`, what `--untrusted` on `icp build`, `deploy` and +# `sync` applies): the project is read through a filesystem confined to its +# root by a directory handle and through no symlink, names no recipe, and +# what it runs is held to a list of regular files there. The host composes +# the rule as the CLI does. Once the stack is on icp-cli's main, re-pin to +# main. # # * icp-project: manifests, the deploy operation, the id/artifact stores. # * icp-events: the progress event stream the operations report on. @@ -65,10 +61,10 @@ dunce = "1" # brings wasmtime into this workspace. # * icp-canister-interfaces: the engine-canister registry id a created # canister's subnet is looked up in. -icp-project = { git = "https://github.com/dfinity/icp-cli", rev = "9056f29c4c2a574551a3334d34913ce04c2cf5cb" } -icp-events = { git = "https://github.com/dfinity/icp-cli", rev = "9056f29c4c2a574551a3334d34913ce04c2cf5cb" } -icp-sync-plugin = { git = "https://github.com/dfinity/icp-cli", rev = "9056f29c4c2a574551a3334d34913ce04c2cf5cb" } -icp-canister-interfaces = { git = "https://github.com/dfinity/icp-cli", rev = "9056f29c4c2a574551a3334d34913ce04c2cf5cb" } +icp-project = { git = "https://github.com/dfinity/icp-cli", rev = "4b0656f6722e2ef78941d6b10ce9f71a389fd414" } +icp-events = { git = "https://github.com/dfinity/icp-cli", rev = "4b0656f6722e2ef78941d6b10ce9f71a389fd414" } +icp-sync-plugin = { git = "https://github.com/dfinity/icp-cli", rev = "4b0656f6722e2ef78941d6b10ce9f71a389fd414" } +icp-canister-interfaces = { git = "https://github.com/dfinity/icp-cli", rev = "4b0656f6722e2ef78941d6b10ce9f71a389fd414" } [dev-dependencies] # The tests hand-build small bundles (a manifest plus a wasm) and load them diff --git a/crates/imcp2-deploy/src/host.rs b/crates/imcp2-deploy/src/host.rs index 1ed90f1..0aa43b8 100644 --- a/crates/imcp2-deploy/src/host.rs +++ b/crates/imcp2-deploy/src/host.rs @@ -13,53 +13,54 @@ //! that passes it may have fetched it from anywhere. So the shape is not //! assumed but enforced, and the rule it is held to is the library's own: //! `icp_project::untrusted`, the rule for a project someone else handed over -//! (dfinity/icp-cli#871), the one `--untrusted` on `icp build`, `deploy` and -//! `sync` applies. The line it draws is between reading and running: what -//! the project runs is held to a list, stated as what is allowed, and the -//! host composes it as the CLI does: +//! (dfinity/icp-cli#873), the one `--untrusted` on `icp build`, `deploy` and +//! `sync` applies. The host composes it as the CLI does, from the library's +//! three parts: //! +//! * **The project is read through [`Confined`]**, the host filesystem held +//! to the extraction directory: the root is opened once as a directory +//! handle and every read is made beneath it, so the operating system +//! refuses a path that leaves the root, by `..`, by an absolute path or by +//! a symlink; and on top of that a path through a symlink is refused +//! wherever it leads. A manifest can name only what the archive brought +//! with it. The disk is the byte store because the sync-plugin runtime +//! needs real paths to mount. +//! * **No recipe is fetched** ([`NoRecipes`]): a recipe is build +//! instructions read from a file or fetched from a URL while the project +//! loads, so a project naming one fails to load, naming the recipe. A +//! bundle spells every step out and names none. //! * **The loaded project is vetted** ([`Vetted`]) before it is handed out: //! each build step is a pre-built module, each sync step a plugin, and -//! what each names is a regular file within the project, judged by where -//! it really is once every link is followed and every `..` applied. A -//! project with any other step — a script, a download — or a module or -//! plugin that resolves outside the root, or to a directory or a pipe, -//! fails the load, naming what was found. The loader is -//! the one gate on the project's shape; the builder and syncer are the -//! library's own, and see no other step. +//! what each names is a regular file the confined filesystem will give; +//! what a plugin step hands its plugin, each `dirs:` and `files:` entry, +//! is a directory or a regular file there too. A project with any other +//! step — a script, a download — or a path that is not such a file fails +//! the load, naming it. The loader is the one gate on the project's +//! shape; the builder and syncer are the library's own, and see no other +//! step. //! //! What a consumer of a bundle adds, this crate adds: //! -//! * **The project is read through [`Confined`]**, the host filesystem held -//! to the extraction directory by the spelling of each path — `..` cannot -//! climb above the root, an absolute path elsewhere is refused — so a -//! manifest can name only what the archive brought with it, and the vet -//! resolves each module's real path through the same seam. The disk is -//! the byte store because the sync-plugin runtime needs real paths to -//! mount. The CLI reads an untrusted project through the host filesystem, -//! since reading is not what the rule restricts; a bundle has no business -//! outside its own directory, and is held to it. -//! * **No recipe is fetched** ([`NoRecipes`]): the CLI renders one and vets -//! the steps it produces, but a bundle spells every step out, so a project -//! naming a recipe is not a bundle and fails to load, naming the recipe. //! * **Extraction** writes only regular files and directories, within a total //! size budget and an object budget, under the scratch directory //! ([`extract_bundle`]), and refuses the `.icp` state a deploy seeds for -//! itself. +//! itself: the library takes what is under `.icp` as its own earlier +//! writes, and vets none of it. //! * **The bundle is checked before any phase runs** ([`check_bundle`]): every //! canister built by one pre-built wasm in the archive that hashes to its -//! recorded checksum, every sync plugin in the archive and what it declares -//! with it, and no network but `ic`. The check's own work is bounded by -//! the archive, not by what the manifest repeats. +//! recorded checksum, every sync plugin in the archive with the bytes it +//! declares held to a sum, and no network but `ic`. The check's own work +//! is bounded by the archive, not by what the manifest repeats. +//! * **What the load retains is metered**, so a manifest naming large +//! argument files over and over costs no more than [`MAX_PROJECT_BYTES`]. //! //! The rule is one, split three ways upstream (dfinity/icp-cli#863): the //! user's own project may run, fetch and link whatever it likes; `icp //! project bundle` writes an archive of regular files and directories with //! every step spelled out (dfinity/icp-cli#868); and a consumer of a bundle -//! it does not trust loads it as untrusted (dfinity/icp-cli#871, on #866 -//! for the confined filesystem this crate reads it through). This crate is -//! that consumer, and pins the stack; until it is on icp-cli's main, the -//! pin is its head. +//! it does not trust loads it as untrusted (dfinity/icp-cli#873, on #868). +//! This crate is that consumer, and pins the stack; until it is on icp-cli's +//! main, the pin is its head. //! //! Everything the project library reads or writes — the manifest, the wasms, //! the `.icp` id and artifact stores, the build scratch — lives under the @@ -73,12 +74,11 @@ use std::{ }; use async_trait::async_trait; -use camino::{Utf8Component, Utf8Path, Utf8PathBuf}; +use camino::{Utf8Path, Utf8PathBuf}; use icp_events::StepReporter; use icp_project::{ canister::{ build::Builder, - recipe::{Fetched, Resolve, ResolveError}, sync::{ script::{ScriptInvocation, ScriptRunError, ScriptRunner}, Syncer, @@ -88,7 +88,7 @@ use icp_project::{ files::{Confined, FileSystem, FsError, Scratch}, host::{Host, Ignore}, manifest::{ - plugin::NamedPaths, prebuilt::SourceField, recipe::Recipe, BuildStep, ProjectRootLocate, + plugin::NamedPaths, prebuilt::SourceField, BuildStep, ProjectRootLocate, ProjectRootLocateImpl, SyncStep, PROJECT_MANIFEST, }, network::{ @@ -99,7 +99,7 @@ use icp_project::{ random::HostRandom, store_artifact::ArtifactStore, store_id::AccessImpl, - untrusted::Vetted, + untrusted::{NoRecipes, Vetted}, Canister, Lazy, Network, Project, ProjectLoadImpl, }; use sha2::{Digest, Sha256}; @@ -257,10 +257,11 @@ impl Access for IcMainnet { /// The extracted bundle, as the filesystem the project library reads /// through: the library's own [`Confined`], rooted at the scratch directory. /// -/// Every path the library asks about is resolved against that root before -/// the disk sees it, so a manifest's `path:` — absolute, climbing, or plain — -/// can name only what the archive brought with it; what is not there is -/// "not in the bundle", whatever the machine holds at that spelling. A +/// Every path the library asks about is opened beneath that root's directory +/// handle, and refused if it passes through a symlink, so a manifest's +/// `path:` — absolute, climbing, or plain — can name only what the archive +/// brought with it; what is not there is "not in the bundle", whatever the +/// machine holds at that spelling or behind a link. A /// scratch directory the library asks for (a pre-built build step copies its /// module there and the operation reads it back) is made under the root too. pub type BundleFs = Confined; @@ -442,23 +443,6 @@ fn gunzipped_within(path: &Utf8Path, bytes: &[u8], limit: u64) -> Result Result { - Err(ResolveError::new(Unsupported(format!( - "the manifest names recipe '{}', which a bundle never does: a bundle spells every \ - step out; rebuild it with `icp project bundle`", - recipe.recipe_type - )))) - } -} - /// The script runner the syncer is composed with. A bundle has no script /// step for it to run — the vetted loader refuses a project with one — so /// this spawns nothing; it is here because the syncer takes a runner, not @@ -584,11 +568,8 @@ fn check_bundle_within<'a>( if let Some(expected) = plugin.sha256.as_deref() { checksum_matches(&local.path, expected, &actual).map_err(at)?; } - let paths = |named: &'a Option<_>| { - named.iter().flat_map(NamedPaths::entries).map(|entry| entry.path) - }; - check_declared(fs, dir, paths(&plugin.dirs), paths(&plugin.files), plugin_input) - .map_err(at)?; + let files = plugin.files.iter().flat_map(NamedPaths::entries).map(|entry| entry.path); + check_declared(fs, dir, files, plugin_input).map_err(at)?; } } Ok(()) @@ -636,59 +617,29 @@ impl Verified { } } -/// Hold a sync step's declared `dirs:` and `files:` to the bundle, by the -/// runtime's own rule — each is written relative to the canister directory, -/// may rise out of it with `..` but not above the bundle root, and may not -/// be absolute — and to what is there: a `dirs:` entry names a directory, a -/// `files:` entry a file the runtime will read into memory, or a directory -/// it will mount. The files, counted as often as they are declared, are held -/// to `budget` bytes in all. -/// -/// The runtime refuses the same paths at sync, after the canisters have been -/// created; this is before. Which of the two settings the plugin's interface -/// accepts, and whether an entry carries a name, is settled by the plugin -/// itself and stays the runtime's to tell. +/// Hold the bytes a sync step's `files:` declare to `budget`, counting a +/// file each time it is declared: the runtime reads every declared file +/// into memory before the plugin runs. That each `dirs:` and `files:` entry +/// is in the bundle — under the root, through no symlink, a directory or a +/// regular file — the vet settled at the load; which of the two settings +/// the plugin's interface accepts, and whether an entry carries a name, is +/// the plugin's to say and stays the runtime's to tell. fn check_declared<'a>( fs: &BundleFs, dir: &Utf8Path, - dirs: impl IntoIterator, files: impl IntoIterator, budget: u64, ) -> Result<(), Unsupported> { - let resolve = |path: &str| -> Result { - let declared = Utf8Path::new(path); - if declared - .components() - .any(|c| matches!(c, Utf8Component::RootDir | Utf8Component::Prefix(_))) - { - return Err(Unsupported(format!( - "its sync step declares '{path}', an absolute path; a plugin's `dirs:` and \ - `files:` are written relative to the canister directory" - ))); - } - fs.resolve(&dir.join(declared)).map_err(|_| { - Unsupported(format!( - "its sync step declares '{path}', which lies outside the bundle; a plugin's \ - `dirs:` and `files:` stay within the project" - )) - }) - }; - for path in dirs { - if !resolve(path)?.is_dir() { - return Err(Unsupported(format!( - "its sync step declares '{path}' under `dirs:`, which is not a directory in the \ - bundle" - ))); - } - } let mut left = budget; for path in files { - let on_disk = resolve(path)?; - let meta = std::fs::metadata(&on_disk).map_err(|_| { + // Where the vet saw it; a path the vet did not pass never gets here. + let not_there = || { Unsupported(format!( "its sync step declares '{path}' under `files:`, which is not in the bundle" )) - })?; + }; + let on_disk = fs.resolve(&dir.join(path)).map_err(|_| not_there())?; + let meta = std::fs::metadata(&on_disk).map_err(|_| not_there())?; if meta.is_file() { if meta.len() > left { return Err(Unsupported(format!( @@ -734,10 +685,10 @@ fn host_within(fs: BundleFs, project_bytes: u64) -> Host { let files: Arc = Arc::new(fs.clone()); let wasm: Arc = Arc::new(BundledWasm(fs.clone())); // The untrusted rule, composed as `icp --untrusted` composes it: the - // project is vetted as it loads, each module's path resolved through - // the bundle's own filesystem. That no recipe is fetched is this - // crate's rule, not the library's: a bundle has none. The loader is the - // one gate on the project's shape, so the builder is the library's own. + // project is read through the bundle's own filesystem, no recipe is + // fetched, and the project is vetted as it loads, each path a step + // names asked of that filesystem. The loader is the one gate on the + // project's shape, so the builder is the library's own. let project = Lazy::<_, Project>::new(Vetted::new( ProjectLoadImpl { project_root_locate: locate.clone(), @@ -792,22 +743,20 @@ fn extract_bundle_within( MAX_BUNDLE_BYTES )); } - // The root as the disk knows it. The vet resolves each module's path - // through this filesystem and then asks it whether a regular file is at - // the resolved spelling, which the filesystem holds to the root by - // spelling alone; a root named through a link (macOS keeps its temporary - // directories under `/var`, a link to `/private/var`) would put every - // resolved path outside it. So the root is resolved once, here, before - // the filesystem is held to it. - let into = dunce::canonicalize(into) - .map_err(|e| format!("the extraction directory '{into}' cannot be resolved: {e}"))?; - let into = Utf8PathBuf::from_path_buf(into) - .map_err(|p| format!("the extraction directory '{}' is not UTF-8", p.display()))?; // A link is refused on the way in, so nothing under the root leads out - // of it. Should one appear later, the filesystem follows it as the host - // does, and the vet sees a module named through it for where it really - // is, refusing one that resolves outside the root. - let fs = Confined::new(&into); + // of it; should one appear later, the filesystem refuses any path + // through it. The root is held as spelled: it and everything above it + // are this crate's own (macOS keeps its temporary directories under + // `/var`, a link to `/private/var`), and only what lies below is held + // to shape. + let fs = Confined::new(into).map_err(|e| { + let mut why = e.to_string(); + if let Some(cause) = std::error::Error::source(&e) { + why.push_str(": "); + why.push_str(&cause.to_string()); + } + format!("the extraction directory cannot be confined to: {why}") + })?; let unreadable = |e: std::io::Error| format!("the bundle is not a readable .tar.gz archive: {e}"); let decoder = Limited { inner: flate2::read::GzDecoder::new(bytes), left: MAX_EXTRACTED_BYTES }; @@ -1074,8 +1023,8 @@ mod tests { // A symlink is how an archive reaches past its directory; a bundle // carries none, so one is refused rather than written. Should one appear - // later, the vet refuses a module that resolves through it to outside - // the root (the test after this), so neither gate stands alone. + // later, the filesystem refuses any path through it (the test after + // this), so neither gate stands alone. #[test] fn an_archive_with_a_symlink_is_refused() { let (_dir, into) = scratch(); @@ -1096,13 +1045,13 @@ mod tests { } // Should a link appear under the root after extraction, the filesystem - // follows it as the host does, and the vet sees a module named through - // it for where it really is: one that resolves outside the root fails - // the load, naming where it leads, and one that resolves within is let - // be. The untrusted rule's own test, run through this host. + // refuses every path through it, wherever it leads: the vet sees a + // module named through one as not a regular file of the project, and a + // read through one is refused naming the link. The untrusted rule's own + // test, run through this host. #[cfg(unix)] #[tokio::test] - async fn a_module_linked_out_of_the_bundle_fails_the_load() { + async fn a_module_named_through_a_link_fails_the_load() { use std::os::unix::fs::symlink; let manifest = |path: &str| { format!( @@ -1114,32 +1063,37 @@ mod tests { let secret = elsewhere.path().join("secret.wasm"); std::fs::write(&secret, b"\0asm").unwrap(); - // Out: refused, naming the link, where it leads and the root. + // Out: refused at the load, naming the path. let (_dir, into) = scratch(); let bytes = archive(&[("icp.yaml", manifest("out.wasm").as_bytes())]); let fs = extract_bundle(&bytes, &into).unwrap(); symlink(&secret, into.join("out.wasm")).unwrap(); - let err = chain(&host_for(fs).project.load().await.expect_err("a link out")); - assert!(err.contains("outside the project"), "{err}"); - assert!(err.contains("out.wasm") && err.contains("secret.wasm"), "{err}"); - - // Within: let be, and the module is served through the link. + let err = chain(&host_for(fs.clone()).project.load().await.expect_err("a link out")); + assert!(err.contains("not a regular file in the project"), "{err}"); + assert!(err.contains("out.wasm"), "{err}"); + // And a read through it is refused naming the link, not followed. + let err = chain(&fs.read(Utf8Path::new("out.wasm")).await.unwrap_err()); + assert!(err.contains("passes through") && err.contains("out.wasm"), "{err}"); + + // Within: refused all the same; the rule is no link, not no link out. let (_dir, into) = scratch(); let bytes = archive(&[("icp.yaml", manifest("in.wasm").as_bytes()), ("backend.wasm", b"\0asm")]); let fs = extract_bundle(&bytes, &into).unwrap(); symlink("backend.wasm", into.join("in.wasm")).unwrap(); - let project = host_for(fs.clone()).project.load().await.unwrap(); - assert_eq!(project.canisters.len(), 1); - assert_eq!(fs.read(Utf8Path::new("in.wasm")).await.unwrap(), b"\0asm"); + let err = chain(&host_for(fs.clone()).project.load().await.expect_err("a link within")); + assert!(err.contains("not a regular file in the project"), "{err}"); + assert!(err.contains("in.wasm"), "{err}"); + assert!(fs.read(Utf8Path::new("in.wasm")).await.is_err()); + assert_eq!(fs.read(Utf8Path::new("backend.wasm")).await.unwrap(), b"\0asm"); - // Dangling: nothing can be said about where it leads, so refused. + // Dangling: a link is a link, wherever it leads or fails to. let (_dir, into) = scratch(); let bytes = archive(&[("icp.yaml", manifest("gone.wasm").as_bytes())]); let fs = extract_bundle(&bytes, &into).unwrap(); symlink(into.join("nowhere.wasm"), into.join("gone.wasm")).unwrap(); let err = chain(&host_for(fs).project.load().await.expect_err("a dangling link")); - assert!(err.contains("does not resolve to anything"), "{err}"); + assert!(err.contains("not a regular file in the project"), "{err}"); assert!(err.contains("gone.wasm"), "{err}"); } @@ -1159,12 +1113,14 @@ mod tests { assert!(err.contains("assets"), "{err}"); } - // The root is taken as the disk knows it: named through a link, the - // extraction lands in the directory the link leads to, and the project - // in it loads — the vet's resolved paths lie under the root it was given. + // The root is held as spelled: named through a link, the extraction + // lands in the directory the link leads to, the filesystem answers to + // the spelling it was given, and the project in it loads — the root and + // everything above it are the caller's, and only what lies below is + // held to shape. #[cfg(unix)] #[tokio::test] - async fn a_root_named_through_a_link_is_the_directory_it_leads_to() { + async fn a_root_named_through_a_link_holds_the_project() { let manifest = "canisters:\n - name: backend\n build:\n steps:\n \ - type: pre-built\n path: backend.wasm\n"; let (dir, into) = scratch(); @@ -1172,10 +1128,11 @@ mod tests { std::os::unix::fs::symlink(&into, &alias).unwrap(); let bytes = archive(&[("icp.yaml", manifest.as_bytes()), ("backend.wasm", b"\0asm")]); let fs = extract_bundle(&bytes, &alias).unwrap(); - assert_eq!(fs.root(), dunce::canonicalize(&into).unwrap()); + assert_eq!(fs.root(), alias); assert!(into.join("backend.wasm").is_file()); - let project = host_for(fs).project.load().await.unwrap(); + let project = host_for(fs.clone()).project.load().await.unwrap(); assert_eq!(project.canisters.len(), 1); + assert_eq!(fs.read(Utf8Path::new("backend.wasm")).await.unwrap(), b"\0asm"); std::fs::remove_file(&alias).unwrap(); } @@ -1193,8 +1150,9 @@ mod tests { // The rule for a project someone else handed over is the library's own, // and it holds at the load: a script step or a module fetched from a URL - // fails the load, before any phase could act on it. So does a recipe, - // by this crate's rule: the library would render it, a bundle has none. + // fails the load, before any phase could act on it. So does a recipe: + // the library would read or fetch it while loading, and an untrusted + // project may name none. #[tokio::test] async fn a_project_asking_more_than_an_untrusted_one_may_fails_to_load() { for (manifest, refused) in [ @@ -1208,7 +1166,7 @@ mod tests { ), ( "canisters:\n - name: backend\n recipe:\n type: \"@dfinity/rust@v1\"\n", - "recipe '@dfinity/rust@v1', which a bundle never does", + "recipe '@dfinity/rust@v1' is refused: an untrusted project may not use a recipe", ), ] { let (_dir, into) = scratch(); @@ -1608,10 +1566,10 @@ mod tests { check(&fs, &env, 8192, MAX_PLUGIN_INPUT_BYTES).unwrap(); } - // What a plugin declares is held to the bundle before any phase, by the - // runtime's rule — relative to the canister directory, within the root, - // present, of the kind declared — and the files it will read are held - // to a sum, counting a file each time it is declared. + // What a sync step hands its plugin is held to the bundle at the load, + // by the vet — each entry under the root, through no link, present, of + // the kind declared — and the files the runtime will read are held to a + // sum here, counting a file each time it is declared. #[tokio::test] async fn a_sync_step_declares_what_the_bundle_holds() { let wasm = wat::parse_str("(module)").unwrap(); @@ -1643,17 +1601,21 @@ mod tests { check(&fs, &env, all.0, all.1).unwrap_or_else(|e| panic!("{declared}: {e}")); } + // A file that is not there, a file where a directory is declared, an + // absolute path, a path climbing out: each fails the load, before + // the check could read anything. let refused = [ - (" files:\n config: missing.txt\n", "not in the bundle"), - (" dirs:\n - config.txt\n", "not a directory"), - (" files:\n etc: /etc/passwd\n", "an absolute path"), - (" files:\n up: ../../../x\n", "outside the bundle"), + (" files:\n config: missing.txt\n", "missing.txt"), + (" dirs:\n - config.txt\n", "config.txt"), + (" files:\n etc: /etc/passwd\n", "/etc/passwd"), + (" files:\n up: ../../../x\n", "x"), ]; - for (declared, reason) in refused { - let (_dir, fs, env) = environment(&bundle(declared)).await; - let err = check(&fs, &env, all.0, all.1).unwrap_err(); + for (declared, named) in refused { + let (_dir, into) = scratch(); + let fs = extract_bundle(&bundle(declared), &into).unwrap(); + let err = chain(&host_for(fs).project.load().await.expect_err(declared)); assert!( - err.contains("canister 'backend'") && err.contains(reason), + err.contains("gives its sync plugin") && err.contains(named), "{declared}: {err}" ); } diff --git a/crates/imcp2-deploy/src/lib.rs b/crates/imcp2-deploy/src/lib.rs index 2bb2b52..8e0fffc 100644 --- a/crates/imcp2-deploy/src/lib.rs +++ b/crates/imcp2-deploy/src/lib.rs @@ -10,11 +10,10 @@ //! [`host`] is the `icp_project::Host` for a bundle: the archive `icp project //! bundle` writes, treated as untrusted input. The rule it is held to is the //! library's own, `icp_project::untrusted`, the one `icp --untrusted` -//! applies (dfinity/icp-cli#871, on #866 for the confined filesystem this -//! crate reads it through and #868 for what `icp project bundle` writes; -//! issue #865): this crate pins -//! that stack, composes the rule as the CLI does, and adds the bounds on the -//! archive itself and the checks on the bundle's shape. The deploy pipeline -//! over it and the MCP tools that expose it follow in their own changes. +//! applies (dfinity/icp-cli#873, on #868 for what `icp project bundle` +//! writes; issue #865): this crate pins that stack, composes the rule as +//! the CLI does, and adds the bounds on the archive itself and the checks +//! on the bundle's shape. The deploy pipeline over it and the MCP tools +//! that expose it follow in their own changes. pub mod host; From a820722642041fa742ff85d4f34651ad3379d39c Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Wed, 11 Nov 2026 21:04:36 +0000 Subject: [PATCH 12/12] Pin icp-cli's main at the merge of #873 dfinity/icp-cli#873 is merged (8f98455, on #868's merge e097f7f), as one squash of the two heads this crate followed. The code is the same as the head it was pinned at; the merge adds a doc note on `untrusted::vet` and `Vetted::new` that the filesystem handed in must be the one that confines the project, which the host's composition already satisfies. The pin moves to main, and the manifest and docs stop saying it awaits that. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 8 ++++---- crates/imcp2-deploy/Cargo.toml | 17 ++++++++--------- crates/imcp2-deploy/src/host.rs | 3 +-- crates/imcp2-deploy/src/lib.rs | 8 ++++---- 4 files changed, 17 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 54c016f..b9c2778 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2184,7 +2184,7 @@ dependencies = [ [[package]] name = "icp-canister-interfaces" version = "1.8.0" -source = "git+https://github.com/dfinity/icp-cli?rev=4b0656f6722e2ef78941d6b10ce9f71a389fd414#4b0656f6722e2ef78941d6b10ce9f71a389fd414" +source = "git+https://github.com/dfinity/icp-cli?rev=8f98455d021f1f7dacf91f3244844f324a1c503b#8f98455d021f1f7dacf91f3244844f324a1c503b" dependencies = [ "bigdecimal", "candid", @@ -2195,7 +2195,7 @@ dependencies = [ [[package]] name = "icp-events" version = "1.8.0" -source = "git+https://github.com/dfinity/icp-cli?rev=4b0656f6722e2ef78941d6b10ce9f71a389fd414#4b0656f6722e2ef78941d6b10ce9f71a389fd414" +source = "git+https://github.com/dfinity/icp-cli?rev=8f98455d021f1f7dacf91f3244844f324a1c503b#8f98455d021f1f7dacf91f3244844f324a1c503b" dependencies = [ "serde", "tokio", @@ -2204,7 +2204,7 @@ dependencies = [ [[package]] name = "icp-project" version = "1.8.0" -source = "git+https://github.com/dfinity/icp-cli?rev=4b0656f6722e2ef78941d6b10ce9f71a389fd414#4b0656f6722e2ef78941d6b10ce9f71a389fd414" +source = "git+https://github.com/dfinity/icp-cli?rev=8f98455d021f1f7dacf91f3244844f324a1c503b#8f98455d021f1f7dacf91f3244844f324a1c503b" dependencies = [ "async-trait", "bigdecimal", @@ -2253,7 +2253,7 @@ dependencies = [ [[package]] name = "icp-sync-plugin" version = "1.8.0" -source = "git+https://github.com/dfinity/icp-cli?rev=4b0656f6722e2ef78941d6b10ce9f71a389fd414#4b0656f6722e2ef78941d6b10ce9f71a389fd414" +source = "git+https://github.com/dfinity/icp-cli?rev=8f98455d021f1f7dacf91f3244844f324a1c503b#8f98455d021f1f7dacf91f3244844f324a1c503b" dependencies = [ "async-trait", "bytes", diff --git a/crates/imcp2-deploy/Cargo.toml b/crates/imcp2-deploy/Cargo.toml index a65d274..c260a6d 100644 --- a/crates/imcp2-deploy/Cargo.toml +++ b/crates/imcp2-deploy/Cargo.toml @@ -44,15 +44,14 @@ tempfile = "3" # one commit so the four stay in lockstep (they share a workspace version and # are not released independently). Bump the `rev` on all four together. # -# The pinned commit is the head of dfinity/icp-cli#873, on #868: #868 has -# `icp project bundle` write an archive of regular files and directories -# alone, and #873 states the rule for an untrusted project +# The pinned commit is icp-cli's main at the merge of dfinity/icp-cli#873 +# (on #868): #868 has `icp project bundle` write an archive of regular files +# and directories alone, and #873 states the rule for an untrusted project # (`icp_project::untrusted`, what `--untrusted` on `icp build`, `deploy` and # `sync` applies): the project is read through a filesystem confined to its # root by a directory handle and through no symlink, names no recipe, and # what it runs is held to a list of regular files there. The host composes -# the rule as the CLI does. Once the stack is on icp-cli's main, re-pin to -# main. +# the rule as the CLI does. # # * icp-project: manifests, the deploy operation, the id/artifact stores. # * icp-events: the progress event stream the operations report on. @@ -61,10 +60,10 @@ tempfile = "3" # brings wasmtime into this workspace. # * icp-canister-interfaces: the engine-canister registry id a created # canister's subnet is looked up in. -icp-project = { git = "https://github.com/dfinity/icp-cli", rev = "4b0656f6722e2ef78941d6b10ce9f71a389fd414" } -icp-events = { git = "https://github.com/dfinity/icp-cli", rev = "4b0656f6722e2ef78941d6b10ce9f71a389fd414" } -icp-sync-plugin = { git = "https://github.com/dfinity/icp-cli", rev = "4b0656f6722e2ef78941d6b10ce9f71a389fd414" } -icp-canister-interfaces = { git = "https://github.com/dfinity/icp-cli", rev = "4b0656f6722e2ef78941d6b10ce9f71a389fd414" } +icp-project = { git = "https://github.com/dfinity/icp-cli", rev = "8f98455d021f1f7dacf91f3244844f324a1c503b" } +icp-events = { git = "https://github.com/dfinity/icp-cli", rev = "8f98455d021f1f7dacf91f3244844f324a1c503b" } +icp-sync-plugin = { git = "https://github.com/dfinity/icp-cli", rev = "8f98455d021f1f7dacf91f3244844f324a1c503b" } +icp-canister-interfaces = { git = "https://github.com/dfinity/icp-cli", rev = "8f98455d021f1f7dacf91f3244844f324a1c503b" } [dev-dependencies] # The tests hand-build small bundles (a manifest plus a wasm) and load them diff --git a/crates/imcp2-deploy/src/host.rs b/crates/imcp2-deploy/src/host.rs index 0aa43b8..f75f2cd 100644 --- a/crates/imcp2-deploy/src/host.rs +++ b/crates/imcp2-deploy/src/host.rs @@ -59,8 +59,7 @@ //! project bundle` writes an archive of regular files and directories with //! every step spelled out (dfinity/icp-cli#868); and a consumer of a bundle //! it does not trust loads it as untrusted (dfinity/icp-cli#873, on #868). -//! This crate is that consumer, and pins the stack; until it is on icp-cli's -//! main, the pin is its head. +//! This crate is that consumer, and pins icp-cli's main at the merge of #873. //! //! Everything the project library reads or writes — the manifest, the wasms, //! the `.icp` id and artifact stores, the build scratch — lives under the diff --git a/crates/imcp2-deploy/src/lib.rs b/crates/imcp2-deploy/src/lib.rs index 8e0fffc..0eb4390 100644 --- a/crates/imcp2-deploy/src/lib.rs +++ b/crates/imcp2-deploy/src/lib.rs @@ -11,9 +11,9 @@ //! bundle` writes, treated as untrusted input. The rule it is held to is the //! library's own, `icp_project::untrusted`, the one `icp --untrusted` //! applies (dfinity/icp-cli#873, on #868 for what `icp project bundle` -//! writes; issue #865): this crate pins that stack, composes the rule as -//! the CLI does, and adds the bounds on the archive itself and the checks -//! on the bundle's shape. The deploy pipeline over it and the MCP tools -//! that expose it follow in their own changes. +//! writes; issue #865): this crate pins icp-cli's main at their merge, +//! composes the rule as the CLI does, and adds the bounds on the archive +//! itself and the checks on the bundle's shape. The deploy pipeline over it +//! and the MCP tools that expose it follow in their own changes. pub mod host;