diff --git a/Cargo.lock b/Cargo.lock index 9b12116..8dcaea8 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" @@ -1847,6 +2463,34 @@ dependencies = [ "wat", ] +[[package]] +name = "imcp2-deploy" +version = "0.6.0" +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.0" @@ -1858,6 +2502,7 @@ dependencies = [ "dirs", "hex", "imcp2-core", + "imcp2-deploy", "open", "pocket-ic", "reqwest 0.13.4", @@ -1884,6 +2529,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 +2547,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 +2603,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 +2665,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.117", ] [[package]] @@ -1979,7 +2684,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2035,7 +2740,7 @@ dependencies = [ "ascii-canvas", "bit-set", "ena", - "itertools", + "itertools 0.11.0", "lalrpop-util", "petgraph", "pico-args", @@ -2081,6 +2786,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 +2855,7 @@ dependencies = [ "proc-macro2", "quote", "regex-syntax", - "syn", + "syn 2.0.117", ] [[package]] @@ -2162,6 +2873,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 +2894,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 +2943,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 +2984,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 +3053,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2306,6 +3065,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 +3173,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 +3237,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2511,6 +3291,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 +3309,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 +3319,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", @@ -2545,10 +3331,22 @@ dependencies = [ ] [[package]] -name = "potential_utf" -version = "0.1.5" +name = "postcard" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -2592,7 +3390,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -2682,6 +3480,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 +3643,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 +3711,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 +3909,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 +3959,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 +4095,12 @@ checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "chrono", "dyn-clone", + "indexmap", "ref-cast", "schemars_derive 1.2.1", "serde", "serde_json", + "url", ] [[package]] @@ -3238,7 +4112,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn", + "syn 2.0.117", ] [[package]] @@ -3250,7 +4124,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn", + "syn 2.0.117", ] [[package]] @@ -3353,7 +4227,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3364,7 +4238,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3399,7 +4273,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3411,6 +4285,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 +4306,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 +4363,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 +4445,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 +4572,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 +4594,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 +4627,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 +4666,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 +4745,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3768,7 +4756,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3869,7 +4857,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3913,11 +4901,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 +4939,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 +4956,7 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", "serde", - "serde_spanned", + "serde_spanned 0.6.9", "toml_datetime 0.6.11", "winnow 0.7.15", ] @@ -3964,18 +4976,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 +5083,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4187,6 +5199,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 +5221,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -4325,7 +5344,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -4338,6 +5357,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 +5386,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 = "e974fe6821a8cf64575d51ea2194e2c8f77e7b66e9afe7419ce8a97f9ee0d251" +checksum = "f64765e43c000fae008a48db8b288e175e237fc9ce20a26586836878c1bee89f" dependencies = [ "leb128fmt", - "wasmparser 0.258.0", + "wasmparser 0.258.3", +] + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser 0.262.0", ] [[package]] @@ -4370,6 +5416,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 +5468,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 +5858,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 +5950,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4542,7 +5961,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4740,6 +6159,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 +6202,7 @@ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", "heck", - "wit-parser", + "wit-parser 0.244.0", ] [[package]] @@ -4776,10 +6215,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 +6231,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -4811,9 +6250,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 +6292,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 +6335,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 +6364,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -4886,7 +6385,7 @@ checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4906,7 +6405,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -4927,7 +6426,7 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4960,11 +6459,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 50e0a3b..bcdeeb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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). diff --git a/crates/imcp2-core/src/identities.rs b/crates/imcp2-core/src/identities.rs index b676c76..257e62d 100644 --- a/crates/imcp2-core/src/identities.rs +++ b/crates/imcp2-core/src/identities.rs @@ -472,7 +472,9 @@ impl Identities { /// transport, route provider, and root key, so calls signed as `identity` /// go through the embedding application's boundary-node routing — never a /// second, hard-coded endpoint. - pub(crate) fn agent_as(&self, identity: I) -> Agent { + /// A clone of the injected base agent signing as `identity`: the same + /// endpoint, root key and boundary-node routing as every other call. + pub fn agent_as(&self, identity: I) -> Agent { let mut agent = self.agent.clone(); agent.set_identity(identity); agent diff --git a/crates/imcp2-core/src/lib.rs b/crates/imcp2-core/src/lib.rs index e7e26d6..76c6099 100644 --- a/crates/imcp2-core/src/lib.rs +++ b/crates/imcp2-core/src/lib.rs @@ -54,6 +54,10 @@ mod e2e_canister_tools; /// crate links. pub use ic_agent::{self, Agent}; pub use identities::{IiInstance, SessionGauges}; +/// The cycles-ledger balance of a session's management principal: the +/// `icp_cycles_balance` tool's logic, for an embedder that serves that tool +/// on its own surface (the local server's deploy tools do). +pub use management::{cycles_balance, cycles_balance_of, CyclesBalance}; pub use tools::{IcCanisterTools, IcProtocolTools, IcTools, SessionResolver}; /// A sensible default IC API boundary node (the public mainnet endpoint) for diff --git a/crates/imcp2-core/src/management.rs b/crates/imcp2-core/src/management.rs index b4142a1..ee66ae9 100644 --- a/crates/imcp2-core/src/management.rs +++ b/crates/imcp2-core/src/management.rs @@ -152,6 +152,17 @@ fn default_init_arg() -> String { /// here can operate a canister the user created. pub async fn cycles_balance(ids: &Identities, session_id: &str) -> Result { let (agent, principal) = management_agent(ids, session_id).await?; + cycles_balance_of(&agent, principal).await +} + +/// The cycles-ledger balance of `principal`, read through `agent`. The query +/// is anonymous-readable, so the agent's identity only matters for routing; +/// callers acting as a principal other than the management identity (the +/// deploy tools) pair this with their own agent. +pub async fn cycles_balance_of( + agent: &Agent, + principal: Principal, +) -> Result { let ledger = parse_principal(CYCLES_LEDGER)?; let account = Account { owner: principal, subaccount: None }; let arg = Encode!(&account).map_err(|e| format!("encode account: {e}"))?; diff --git a/crates/imcp2-deploy/Cargo.toml b/crates/imcp2-deploy/Cargo.toml new file mode 100644 index 0000000..83a653d --- /dev/null +++ b/crates/imcp2-deploy/Cargo.toml @@ -0,0 +1,62 @@ +[package] +name = "imcp2-deploy" +version = "0.6.0" +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/calls.rs b/crates/imcp2-deploy/src/calls.rs new file mode 100644 index 0000000..42b560c --- /dev/null +++ b/crates/imcp2-deploy/src/calls.rs @@ -0,0 +1,224 @@ +//! The project library's one seam to the network, over this connection's +//! `ic-agent`: [`CanisterCalls`] signed as the user's management identity. +//! +//! Modelled on the icp CLI's own implementation (`icp_app::calls::AgentCalls`), +//! minus proxy-canister routing: this server never names a proxy, so every call +//! is made directly by the identity the agent carries. The certified reads are +//! what the deploy operation decides on — whether a canister exists, what is +//! installed on it, which subnet it sits on — so they are answered from +//! certificates rather than from anything a canister says about itself. + +use async_trait::async_trait; +use candid::Principal; +use ic_agent::{ + agent::{CallResponse, EffectiveId, SubnetType}, + hash_tree::{Label, LookupResult}, + Agent, AgentError, +}; +use icp_project::calls::{Authority, Call, CallError, CanisterCalls, RouteTo}; + +/// [`CanisterCalls`] over an `ic-agent`. +pub struct AgentCalls { + agent: Agent, + /// The agent's principal, read once here. [`CanisterCalls::caller`] is + /// infallible while [`Agent::get_principal`] is not (an identity may + /// decline to name its sender), so the one place the failure can be + /// reported is construction; the agent's identity cannot change after it. + caller: Principal, +} + +impl AgentCalls { + pub fn new(agent: Agent) -> Result { + let caller = agent.get_principal()?; + Ok(Self { agent, caller }) + } + + /// Turn an agent error into the shape the trait speaks in: a rejection is + /// a verdict and keeps its code, a call that ran out of time or never got + /// down the wire went unanswered, and anything else — a reply that would + /// not parse, a certificate that would not verify — failed for good. + fn wrap(canister: Principal, method: &str, err: AgentError) -> CallError { + match &err { + AgentError::CertifiedReject { reject, .. } + | AgentError::UncertifiedReject { reject, .. } => CallError::Rejected { + canister, + method: method.to_owned(), + code: reject.error_code.clone(), + message: reject.reject_message.clone(), + }, + AgentError::TimeoutWaitingForResponse() | AgentError::TransportError(_) => { + CallError::unanswered(canister, method, err) + } + _ => CallError::failed(canister, method, err), + } + } + + /// Whether `canister` exists, which its controllers answer — see + /// [`CanisterCalls::controllers`]. A check that could not be made reads as + /// "does not exist": every caller asks this while deciding whether to + /// report a read failure of its own, and an existence check that failed + /// too is no reason to suppress it. + async fn exists(&self, canister: Principal) -> bool { + self.controllers(canister).await.is_ok_and(|controllers| controllers.is_some()) + } + + /// Ask the target's subnet to certify a metadata section, reporting only + /// what the certificate proves. + /// + /// The section path is requested together with `controllers`, because a + /// metadata path proven absent is equally what a canister that was never + /// created looks like — `controllers` is written at creation, so its + /// presence is what separates the two. A canister with no module installed + /// has no sections at all, which the certificate reports as an absent path + /// under a canister that exists, and so as `Ok(None)`. + async fn certified_metadata_section( + &self, + canister: Principal, + path: &str, + ) -> Result>, CallError> { + let metadata_path: Vec>> = vec![ + "canister".into(), + Label::from_bytes(canister.as_slice()), + "metadata".into(), + path.into(), + ]; + let controllers_path: Vec>> = + vec!["canister".into(), Label::from_bytes(canister.as_slice()), "controllers".into()]; + let method = "read_state(metadata)"; + let cert = self + .agent + .read_state_raw(vec![metadata_path.clone(), controllers_path.clone()], canister) + .await + .map_err(|err| Self::wrap(canister, method, err))?; + + let unproven = |about: String| { + Err(CallError::Rejected { + canister, + method: method.to_owned(), + code: None, + message: format!("the certificate proves nothing about {about}"), + }) + }; + match cert.tree.lookup_path(&metadata_path) { + LookupResult::Found(bytes) => Ok(Some(bytes.to_vec())), + LookupResult::Absent => match cert.tree.lookup_path(&controllers_path) { + LookupResult::Found(_) => Ok(None), + LookupResult::Absent => Err(CallError::Rejected { + canister, + method: method.to_owned(), + code: None, + message: format!("canister {canister} does not exist"), + }), + _ => unproven(format!("canister {canister}")), + }, + // Not proof of absence, just a certificate that says nothing about + // the path — reporting the section missing off this would be a + // guess, and a private section is exactly what it looks like. + _ => unproven(format!("section `{path}` of canister {canister}")), + } + } + + /// A subnet-scoped update: routed to a subnet rather than to any canister + /// on it, which the agent only exposes through a signed submission. + async fn to_subnet(&self, subnet: Principal, call: &Call) -> Result, CallError> { + let effective = EffectiveId::Subnet(subnet); + let fail = |e: AgentError| Self::wrap(call.canister, &call.method, e); + + let signed = self + .agent + .update(&call.canister, &call.method) + .with_arg(call.arg.clone()) + .sign() + .map_err(fail)?; + let response = + self.agent.update_signed(effective, signed.signed_update).await.map_err(fail)?; + match response { + CallResponse::Response(bytes) => Ok(bytes), + CallResponse::Poll(request_id) => { + let status = self.agent.sign_request_status(effective, request_id).map_err(fail)?; + Ok(self + .agent + .wait_signed(&request_id, effective, status.signed_request_status) + .await + .map_err(fail)? + .0) + } + } + } +} + +#[async_trait] +impl CanisterCalls for AgentCalls { + fn caller(&self) -> Principal { + self.caller + } + + async fn update(&self, call: Call) -> Result, CallError> { + if let RouteTo::Subnet(subnet) = call.route { + return self.to_subnet(subnet, &call).await; + } + let mut builder = + self.agent.update(&call.canister, &call.method).with_arg(call.arg.clone()); + if let RouteTo::Canister(effective) = call.route { + builder = builder.with_effective_canister_id(effective); + } + builder.await.map_err(|e| Self::wrap(call.canister, &call.method, e)) + } + + async fn query(&self, call: Call) -> Result, CallError> { + let mut builder = self.agent.query(&call.canister, &call.method).with_arg(call.arg.clone()); + if let RouteTo::Canister(effective) = call.route { + builder = builder.with_effective_canister_id(effective); + } + builder.call().await.map_err(|e| Self::wrap(call.canister, &call.method, e)) + } + + async fn metadata_section( + &self, + canister: Principal, + path: &str, + // With no proxy there is one authority, and both values mean it. + _authority: Authority, + ) -> Result>, CallError> { + self.certified_metadata_section(canister, path).await + } + + async fn controllers(&self, canister: Principal) -> Result>, CallError> { + match self.agent.read_state_canister_controllers(canister).await { + Ok(controllers) => Ok(Some(controllers)), + Err(AgentError::LookupPathAbsent(_)) => Ok(None), + Err(err) => Err(Self::wrap(canister, "read_state(controllers)", err)), + } + } + + async fn module_hash(&self, canister: Principal) -> Result>, CallError> { + let err = match self.agent.read_state_canister_module_hash(canister).await { + Ok(hash) => return Ok(Some(hash)), + Err(err) => err, + }; + // No module-hash path means nothing is installed — or that there is no + // canister to install into, which is not an answer this can give. + if matches!(err, AgentError::LookupPathAbsent(_)) && self.exists(canister).await { + return Ok(None); + } + Err(Self::wrap(canister, "read_state(module_hash)", err)) + } + + async fn subnet_of(&self, canister: Principal) -> Result { + Ok(self + .agent + .get_subnet_by_canister(&canister) + .await + .map_err(|e| Self::wrap(canister, "subnet_of", e))? + .id()) + } + + async fn subnet_uses_engine_operator(&self, subnet: Principal) -> Result { + let info = self + .agent + .get_subnet_by_id(&subnet) + .await + .map_err(|e| Self::wrap(subnet, "subnet_type", e))?; + Ok(matches!(info.subnet_type(), Some(SubnetType::CloudEngine))) + } +} diff --git a/crates/imcp2-deploy/src/deploy.rs b/crates/imcp2-deploy/src/deploy.rs new file mode 100644 index 0000000..9d11f95 --- /dev/null +++ b/crates/imcp2-deploy/src/deploy.rs @@ -0,0 +1,654 @@ +//! One deploy, start to finish: extract the bundle, load its project, seed the +//! id store, run the project library's `deploy` operation as the user, and +//! read the resulting ids back — reporting what happened either way. +//! +//! The operation is the icp CLI's own (`icp deploy`): build (here: verify the +//! bundled wasms), create what does not exist, wire canister ids into each +//! other's environment variables, apply settings, check Candid compatibility +//! on upgrades, install, sync. Nothing is re-implemented; this module only +//! supplies what the CLI's command layer would — the host, the signing +//! identity, the parameters — and turns the progress stream into a log. + +use std::{ + collections::{BTreeMap, HashMap}, + fmt, + sync::Arc, +}; + +use camino::Utf8PathBuf; +use candid::Principal; +use ic_agent::{Agent, Identity}; +use icp_canister_interfaces::engine_canister::engine_canister_id; +use icp_events::{EventKind, TaskId, TaskOutcome}; +use icp_project::{ + calls::CanisterCalls, + defer::Deferred, + host::EnvironmentSelection, + operations::{ + deploy::{deploy, resolve_targets, DeployParams, DeployReport}, + task::Task, + }, +}; + +use crate::{calls::AgentCalls, host}; + +/// Where the bundle bytes come from. +pub enum BundleSource { + /// The archive itself. + Bytes(Vec), + /// An absolute path to the archive on this machine. + Path(Utf8PathBuf), +} + +// By hand so a request never prints megabytes of archive. +impl fmt::Debug for BundleSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + BundleSource::Bytes(bytes) => write!(f, "Bytes({} bytes)", bytes.len()), + BundleSource::Path(path) => write!(f, "Path({path})"), + } + } +} + +/// Everything one deploy is given. +#[derive(Debug)] +pub struct DeployRequest { + pub bundle: BundleSource, + /// The environment, by name, among those the bundle's manifest declares. + pub environment: String, + /// Canister ids to start from: a canister named here is upgraded in place + /// instead of created. Each name must be a canister of the environment. + pub canister_ids: BTreeMap, + /// `auto`, `install`, `reinstall` or `upgrade` — validated upstream by the + /// install-mode resolution, as on the command line. + pub mode: String, + /// Cycles to fund each canister this deploy creates. + pub cycles: u128, + /// Subnet to create canisters on; the registry picks one otherwise. + pub subnet: Option, + /// Refuse, rather than create, when a canister does not exist yet. + pub no_create: bool, + /// Skip the Candid interface compatibility check on upgrades. + pub skip_candid_check: bool, +} + +/// How a deploy ended. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Status { + /// Every phase succeeded. + Deployed, + /// A phase failed; `created` and `canister_ids` still hold whatever landed + /// before it did. + Failed, + /// The environment lists no canisters, so there was nothing to do. + NothingToDeploy, +} + +impl Status { + pub fn as_str(self) -> &'static str { + match self { + Status::Deployed => "deployed", + Status::Failed => "failed", + Status::NothingToDeploy => "nothing_to_deploy", + } + } +} + +/// What a deploy did. Filled in as the run progresses rather than returned at +/// the end, so a canister created before a later phase failed is still +/// reported — its id is what the caller needs to pick the run back up. +#[derive(Debug)] +pub struct DeployOutcome { + pub status: Status, + pub environment: String, + /// The network the environment targets. + pub network: String, + /// The canisters the deploy targeted, in manifest order. + pub canisters: Vec, + /// Canisters created by this deploy, in creation order. + pub created: Vec<(String, Principal)>, + /// Every canister of the environment that has an id after the deploy — + /// the ids passed in, the ids created, both. + pub canister_ids: BTreeMap, + /// Why the deploy failed, with its cause chain, when it did. + pub error: Option, + /// The phases and per-canister tasks the operation reported, with their + /// outcomes, in completion order. + pub log: Vec, +} + +/// An error made before the deploy could begin: the request itself was +/// unusable, so there is no outcome to report, only the reason. +#[derive(Debug)] +pub struct RequestError(pub String); + +impl fmt::Display for RequestError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for RequestError {} + +/// Render an error with its `source()` chain, outermost first: the project +/// library's errors say *what* failed at each layer and leave the *why* to +/// the cause beneath, so only the whole chain reads as an explanation. +pub fn error_chain(error: &(dyn std::error::Error + 'static)) -> String { + let mut text = error.to_string(); + let mut source = error.source(); + while let Some(cause) = source { + text.push_str(": "); + text.push_str(&cause.to_string()); + source = cause.source(); + } + text +} + +/// Run one deploy as `identity`. +/// +/// `Err` means the request never got as far as a deploy (an unreadable +/// bundle, an environment the manifest does not declare, a canister name the +/// id mapping has that the environment does not); `Ok` carries the outcome of +/// one that ran, failed or not. +pub async fn deploy_bundle( + identity: Arc, + request: DeployRequest, +) -> Result { + let bytes = match request.bundle { + BundleSource::Bytes(bytes) => bytes, + BundleSource::Path(path) => { + if !path.is_absolute() { + return Err(RequestError(format!( + "bundle_path must be an absolute path on this machine, got '{path}'" + ))); + } + let meta = tokio::fs::metadata(path.as_std_path()) + .await + .map_err(|e| RequestError(format!("cannot read bundle '{path}': {e}")))?; + // A device or a pipe reports a length that says nothing about + // what a read of it produces (`/dev/zero` is 0 bytes long and + // endless; a FIFO blocks), so the size check below only means + // something for a regular file. + if !meta.is_file() { + return Err(RequestError(format!("bundle '{path}' is not a regular file"))); + } + if meta.len() > host::MAX_BUNDLE_BYTES as u64 { + return Err(RequestError(format!( + "bundle '{path}' is {} bytes, above the {} byte limit", + meta.len(), + host::MAX_BUNDLE_BYTES + ))); + } + tokio::fs::read(path.as_std_path()) + .await + .map_err(|e| RequestError(format!("cannot read bundle '{path}': {e}")))? + } + }; + + // The scratch directory lives exactly as long as this function: the + // bundle's files, the ids the deploy records, and the build artifacts all + // go with it, whichever way the deploy ends. + let scratch = tempfile::Builder::new() + .prefix("imcp2-deploy-") + .tempdir() + .map_err(|e| RequestError(format!("cannot create a scratch directory: {e}")))?; + let dir = Utf8PathBuf::from_path_buf(scratch.path().to_path_buf()).map_err(|p| { + RequestError(format!("scratch directory path is not UTF-8: {}", p.display())) + })?; + let fs = { + let dir = dir.clone(); + tokio::task::spawn_blocking(move || host::extract_bundle(&bytes, &dir)) + .await + .map_err(|e| RequestError(format!("bundle extraction panicked: {e}")))? + .map_err(RequestError)? + }; + + let host = host::host_for(fs.clone()); + let selection = EnvironmentSelection::Named(request.environment.clone()); + let env = host.get_environment(&selection).await.map_err(|e| RequestError(error_chain(&e)))?; + // Before any phase runs: a bundle that is not one — a script step, a wasm + // that is missing or does not hash to its checksum, a network other than + // the IC — is refused here, not at the phase that would reach it after + // canisters have been created. + host::check_bundle( + &fs, + &env.network, + env.canisters.values().map(|(dir, canister)| (dir.as_path(), canister)), + ) + .map_err(RequestError)?; + + // Seed the id store with the mapping the caller passed: from here on the + // operation sees those canisters as existing, exactly as it would see the + // ids a previous `icp deploy` recorded in the project's `.icp` directory. + for (name, id) in &request.canister_ids { + host.set_canister_id_for_env(name, *id, &selection) + .await + .map_err(|e| RequestError(error_chain(&e)))?; + } + + let canisters = + resolve_targets(&host, &selection, &[]).await.map_err(|e| RequestError(error_chain(&e)))?; + + let mut outcome = DeployOutcome { + status: Status::NothingToDeploy, + environment: request.environment.clone(), + network: env.network.name.clone(), + canisters: canisters.clone(), + created: Vec::new(), + canister_ids: request.canister_ids.clone(), + error: None, + log: Vec::new(), + }; + if canisters.is_empty() { + return Ok(outcome); + } + + let engine_registry = engine_canister_id().map_err(RequestError)?; + let params = DeployParams { + environment: selection.clone(), + canisters, + mode: request.mode, + subnet: request.subnet, + proxy: None, + cycles: request.cycles, + engine_registry, + no_create: request.no_create, + yes: request.skip_candid_check, + args: None, + }; + + // The agent is made by the first phase that reaches the network, as the + // CLI does: a deploy that fails to load its wasms never resolves a root + // key. The identity is already unlocked — it is the session's — so what + // is deferred here is only the network resolution. + let network = env.network.clone(); + let host_for_agent = host.clone(); + let calls = Deferred::new(move || { + let host = host_for_agent.clone(); + let network = network.clone(); + let identity = identity.clone(); + async move { + let access = + host.network.access(&network).await.map_err(|e| RequestError(error_chain(&e)))?; + let agent = Agent::builder() + .with_url(access.api_url.as_str()) + .with_arc_identity(identity) + .build() + .map_err(|e| { + RequestError(format!("cannot build an agent for {}: {e}", access.api_url)) + })?; + agent.set_root_key(access.root_key); + let calls: Arc = + Arc::new(AgentCalls::new(agent).map_err(RequestError)?); + Ok::<_, RequestError>(calls) + } + }); + + // One reporter for the whole deploy; its events are turned into log lines + // as they arrive, so a run that fails still reports every phase before it. + let (reporter, receiver) = icp_events::channel::(); + let collector = tokio::spawn(collect_log(receiver)); + + let mut report = DeployReport::default(); + let result = deploy(&host, &calls, ¶ms, &reporter, &mut report).await; + // The receiver ends only once every reporter handle is gone. + drop(reporter); + outcome.log = collector.await.unwrap_or_default(); + outcome.created = report.created; + + match result { + Ok(()) => outcome.status = Status::Deployed, + Err(e) => { + outcome.status = Status::Failed; + outcome.error = Some(error_chain(&e)); + } + } + + // Whatever the store holds now: the seeded ids plus every id created before + // a failure, which is what a retry needs to pass back in. + match host.ids_by_environment(&selection).await { + Ok(ids) => outcome.canister_ids = ids.into_iter().collect(), + Err(e) => { + outcome.log.push(format!("reading the canister ids back failed: {}", error_chain(&e))); + } + } + // A created id is reported before it is recorded, so the created list is + // the authority: it fills in whatever the store failed to record — or to + // read back — because a mapping missing a canister that exists would have + // the retry create it again. + for (name, id) in &outcome.created { + outcome.canister_ids.insert(name.clone(), *id); + } + + drop(scratch); + Ok(outcome) +} + +/// Turn the operation's event stream into one line per finished task: +/// `: ok` / `failed: ` / `skipped: `, with a task nested under +/// a phase indented beneath it. Output lines are not kept — a sync plugin's +/// retained stderr is the exception, since the operation asked for it to +/// survive — so the log stays a summary, not a transcript. +async fn collect_log( + mut receiver: tokio::sync::mpsc::UnboundedReceiver>, +) -> Vec { + let mut log = Vec::new(); + let mut started: HashMap = HashMap::new(); + while let Some(event) = receiver.recv().await { + match event.kind { + EventKind::TaskStarted { parent, task } => { + started.insert(event.task_id, (describe(&task), parent.is_some())); + } + EventKind::TaskCompleted { outcome } => { + let (label, nested) = started + .remove(&event.task_id) + .unwrap_or_else(|| (format!("task {:?}", event.task_id), false)); + let indent = if nested { " " } else { "" }; + match outcome { + TaskOutcome::Succeeded { retained_output } => { + log.push(format!("{indent}{label}: ok")); + for line in retained_output { + log.push(format!("{indent} {line}")); + } + } + TaskOutcome::Failed { message, causes } => { + let mut line = format!("{indent}{label}: failed: {message}"); + for cause in causes { + line.push_str(": "); + line.push_str(&cause); + } + log.push(line); + } + TaskOutcome::Skipped { reason } => { + log.push(format!("{indent}{label}: skipped: {reason}")); + } + } + } + _ => {} + } + } + log +} + +/// A task's one-line description, read off its serialized form so this stays +/// independent of the task vocabulary: a phase is its title, anything else is +/// its kind and the canister it acts on. +fn describe(task: &Task) -> String { + let value = serde_json::to_value(task).unwrap_or_default(); + if let Some(title) = value.get("title").and_then(|v| v.as_str()) { + return title.trim_end_matches(':').to_string(); + } + let kind = value.get("kind").and_then(|v| v.as_str()).unwrap_or("task"); + let canister = value.get("canister").and_then(|v| v.as_str()); + let id = value.get("canister_id").and_then(|v| v.as_str()); + match (canister, id) { + (Some(c), Some(id)) => format!("{kind} {c} ({id})"), + (Some(c), None) => format!("{kind} {c}"), + _ => kind.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ic_agent::identity::AnonymousIdentity; + use sha2::{Digest, Sha256}; + use std::io::Write; + + /// A bundle as `icp project bundle` writes one: the rewritten manifest + /// (every canister a pre-built step naming a bundled wasm by checksum) at + /// the archive root, the wasm under `canisters/`. The `ic` and `local` + /// networks and environments are implicit, as in every manifest. + fn bundle(sha256: &str, wasm: &[u8]) -> Vec { + let manifest = format!( + "canisters:\n - name: backend\n build:\n steps:\n - type: pre-built\n \ + path: canisters/backend.wasm\n sha256: {sha256}\n" + ); + archive(&[("icp.yaml", manifest.as_bytes()), ("canisters/backend.wasm", wasm)]) + } + + 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(); + } + let mut encoder = archive.into_inner().unwrap(); + encoder.flush().unwrap(); + encoder.finish().unwrap() + } + + fn request(bytes: Vec, environment: &str) -> DeployRequest { + DeployRequest { + bundle: BundleSource::Bytes(bytes), + environment: environment.into(), + canister_ids: BTreeMap::new(), + mode: "auto".into(), + cycles: 1, + subnet: None, + // Every test here stays off the network: with nothing created, + // the deploy stops at the create phase, after the build phase has + // verified the bundled wasm. + no_create: true, + skip_candid_check: false, + } + } + + fn wasm() -> (Vec, String) { + let wasm = wat::parse_str("(module)").unwrap(); + let sha = hex::encode(Sha256::digest(&wasm)); + (wasm, sha) + } + + // The whole pipeline up to the network: extract, load the bundled + // manifest through the project library, resolve the implicit `ic` + // environment, verify the wasm against its recorded checksum (the build + // phase of a bundle), then stop where `--no-create` stops `icp deploy`. + // The outcome reports the failure, the targets, and the log of the phase + // that succeeded before it. + #[tokio::test(flavor = "multi_thread")] + async fn a_bundle_deploys_up_to_the_create_phase_offline() { + let (wasm, sha) = wasm(); + let outcome = + deploy_bundle(Arc::new(AnonymousIdentity), request(bundle(&sha, &wasm), "ic")) + .await + .expect("the request is valid"); + assert_eq!(outcome.status, Status::Failed); + assert_eq!(outcome.network, "ic"); + assert_eq!(outcome.canisters, ["backend"]); + assert!(outcome.created.is_empty()); + assert!(outcome.canister_ids.is_empty()); + let error = outcome.error.as_deref().unwrap(); + assert!(error.contains("--no-create") && error.contains("backend"), "{error}"); + assert!(outcome.log.iter().any(|l| l == "Building canisters: ok"), "{:?}", outcome.log); + assert!(outcome.log.iter().any(|l| l == " build backend: ok"), "{:?}", outcome.log); + } + + // The manifest's checksum is the bundle's integrity check: a wasm that + // does not hash to it is refused before any phase, as a request error + // that names the canister and both digests. + #[tokio::test(flavor = "multi_thread")] + async fn a_wasm_that_does_not_match_its_checksum_is_refused_up_front() { + let (wasm, _) = wasm(); + let wrong = "0".repeat(64); + let err = deploy_bundle(Arc::new(AnonymousIdentity), request(bundle(&wrong, &wasm), "ic")) + .await + .expect_err("a checksum mismatch is refused"); + assert!(err.0.contains("backend") && err.0.contains("checksum mismatch"), "{err}"); + assert!(err.0.contains(&wrong), "{err}"); + } + + // Only the IC: a deploy to `local` names the icp CLI's managed network, + // and is refused up front with the way out; a connected testnet is not + // the IC either. + #[tokio::test(flavor = "multi_thread")] + async fn environments_off_the_ic_network_are_refused() { + let (wasm, sha) = wasm(); + let mut local = request(bundle(&sha, &wasm), "local"); + local.no_create = false; + let err = deploy_bundle(Arc::new(AnonymousIdentity), local) + .await + .expect_err("a managed network is refused"); + assert!(err.0.contains("not the Internet Computer") && err.0.contains("`ic`"), "{err}"); + + let manifest = format!( + "networks:\n - name: testnet\n mode: connected\n url: https://testnet.example\n \ + root-key: fetch\nenvironments:\n - name: staging\n network: testnet\n\ + canisters:\n - name: backend\n build:\n steps:\n - type: pre-built\n \ + path: canisters/backend.wasm\n sha256: {sha}\n" + ); + let bytes = + archive(&[("icp.yaml", manifest.as_bytes()), ("canisters/backend.wasm", &wasm)]); + let err = deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "staging")) + .await + .expect_err("a testnet is refused"); + assert!(err.0.contains("testnet") && err.0.contains("not the Internet Computer"), "{err}"); + } + + // Before anything is deployed: an environment the bundle does not + // declare, or an id for a canister the environment does not hold, is a + // request error — nothing ran, so there is no outcome to report. + #[tokio::test(flavor = "multi_thread")] + async fn unknown_environments_and_canisters_are_request_errors() { + let (wasm, sha) = wasm(); + let err = + deploy_bundle(Arc::new(AnonymousIdentity), request(bundle(&sha, &wasm), "staging")) + .await + .expect_err("no such environment"); + assert!(err.0.contains("staging"), "{err}"); + + let mut request = request(bundle(&sha, &wasm), "ic"); + request.canister_ids.insert("frontend".into(), Principal::management_canister()); + let err = deploy_bundle(Arc::new(AnonymousIdentity), request) + .await + .expect_err("no such canister"); + assert!(err.0.contains("frontend"), "{err}"); + } + + // A bundle never carries a script step, and this server never runs one: + // a manifest that declares one is refused as a request error, before any + // phase — so no canister is created ahead of a sync step that would then + // be refused. + #[tokio::test(flavor = "multi_thread")] + async fn a_script_step_is_refused_before_any_phase_runs() { + let manifest = "canisters:\n - name: backend\n build:\n steps:\n \ + - type: script\n command: touch /tmp/pwned\n"; + let bytes = archive(&[("icp.yaml", manifest.as_bytes())]); + let err = deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "ic")) + .await + .expect_err("a script step is refused"); + assert!(err.0.contains("backend") && err.0.contains("script build step"), "{err}"); + } + + // The manifest can name only what the archive carried: a variable file + // the bundle does not hold is refused at load — before any phase, since + // the value would otherwise be sent to the canister as a setting — and so + // A `bundle_path` is read only when it is a regular file: a directory + // here, and by the same check a device or a pipe, whose length says + // nothing about what reading it would produce. + #[tokio::test(flavor = "multi_thread")] + async fn a_bundle_path_that_is_not_a_regular_file_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let path = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap(); + let mut local = request(Vec::new(), "ic"); + local.bundle = BundleSource::Path(path); + let err = deploy_bundle(Arc::new(AnonymousIdentity), local).await.unwrap_err(); + assert!(err.0.contains("not a regular file"), "{}", err.0); + } + + // is a wasm, whether the path is absolute or climbing. Nothing on the + // machine is reached for, whatever exists at that spelling. + #[tokio::test(flavor = "multi_thread")] + async fn paths_the_bundle_does_not_hold_are_refused() { + let (wasm, sha) = wasm(); + let manifest = format!( + "canisters:\n - name: backend\n settings:\n environment_variables:\n \ + SECRET:\n path: /etc/hostname\n build:\n steps:\n \ + - type: pre-built\n path: canisters/backend.wasm\n sha256: {sha}\n" + ); + let bytes = + archive(&[("icp.yaml", manifest.as_bytes()), ("canisters/backend.wasm", &wasm)]); + let err = deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "ic")) + .await + .expect_err("a variable file outside the bundle is refused"); + assert!(err.0.contains("'etc/hostname' is not in the bundle"), "{err}"); + + for path in ["/etc/hostname", "../../../../etc/hostname"] { + let manifest = format!( + "canisters:\n - name: backend\n build:\n steps:\n - type: pre-built\n \ + path: {path}\n sha256: {sha}\n" + ); + let bytes = archive(&[("icp.yaml", manifest.as_bytes())]); + let err = deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "ic")) + .await + .expect_err("a wasm outside the bundle is refused"); + assert!(err.0.contains("'etc/hostname' is not in the bundle"), "{path}: {err}"); + } + } + + // The log is what the agent (and the user) read to learn how far a deploy + // got: phases by title, canister tasks by kind and name, nested tasks + // indented, every outcome spelled out. + #[tokio::test] + async fn the_event_stream_becomes_a_readable_log() { + let (reporter, receiver) = icp_events::channel::(); + let collector = tokio::spawn(collect_log(receiver)); + let phase = reporter.task(Task::phase("Creating canisters:")); + let create = phase.reporter().task(Task::create("backend")); + create.finish(TaskOutcome::succeeded()); + let install = + phase.reporter().task(Task::install("frontend", Principal::management_canister())); + install.finish(TaskOutcome::Failed { + message: "install_code rejected".into(), + causes: vec!["canister is out of cycles".into()], + }); + phase.finish(TaskOutcome::failed("one canister failed")); + let check = reporter.task(Task::candid_check("backend", Principal::management_canister())); + check.finish(TaskOutcome::Skipped { reason: "not an upgrade".into() }); + drop((reporter, phase, create, install, check)); + let log = collector.await.unwrap(); + assert_eq!( + log, + [ + " create backend: ok", + " install frontend (aaaaa-aa): failed: install_code rejected: canister is out of cycles", + "Creating canisters: failed: one canister failed", + "candid_check backend (aaaaa-aa): skipped: not an upgrade", + ] + ); + } + + #[test] + fn error_chains_read_outermost_first() { + #[derive(Debug)] + struct Outer(Inner); + #[derive(Debug)] + struct Inner; + impl fmt::Display for Outer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("failed to install canister 'backend'") + } + } + impl fmt::Display for Inner { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("canister rejected the call") + } + } + impl std::error::Error for Inner {} + impl std::error::Error for Outer { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.0) + } + } + assert_eq!( + error_chain(&Outer(Inner)), + "failed to install canister 'backend': canister rejected the call" + ); + } +} 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/identity.rs b/crates/imcp2-deploy/src/identity.rs new file mode 100644 index 0000000..a303a9c --- /dev/null +++ b/crates/imcp2-deploy/src/identity.rs @@ -0,0 +1,159 @@ +//! An identity that stays signed in for as long as a deploy runs. +//! +//! The per-app delegations `imcp2_core::identities` mints are capped at five +//! minutes by Internet Identity, and the store re-derives one on request +//! when the cached one is near expiry. An `ic-agent` holds one `Identity` +//! for its lifetime, though, and `Identity::sign` is synchronous, so a deploy +//! that built its agent over a fixed `DelegatedIdentity` would start failing +//! signatures partway through an asset sync. [`RefreshingIdentity`] signs +//! with whatever delegation is current, and a task alongside it asks the +//! store for a fresh one on a short cadence until the identity is dropped. + +use std::{ + sync::{Arc, RwLock, Weak}, + time::Duration, +}; + +use candid::Principal; +use ic_agent::{ + agent::EnvelopeContent, + identity::{Delegation, SenderInfo, SignedDelegation}, + Identity, Signature, +}; +use imcp2_core::identities::Identities; + +/// How often the refresher asks the store for the session's delegation. The +/// store answers from its cache while that is fresh (no network), and +/// re-derives inside its 30-second expiry margin, so this cadence decides how +/// much of that margin is left when the new delegation lands. +const REFRESH_EVERY: Duration = Duration::from_secs(10); + +/// An `Identity` whose signing delegation is swapped for a fresh one while a +/// deploy runs. Its `sender` is fixed at construction: a refresh is accepted +/// only from an identity for the same principal, so nothing signed by the +/// agent can change hands midway. +pub struct RefreshingIdentity { + sender: Principal, + current: RwLock>, +} + +impl RefreshingIdentity { + /// Derive the session's identity at `origin` now, and keep it fresh until + /// the returned handle is dropped. + pub async fn start( + identities: Identities, + session_id: String, + origin: &'static str, + ) -> Result, String> { + let initial = identities.delegated_identity(&session_id, origin, None).await?; + let this = Arc::new(Self::over(Arc::new(initial))?); + let weak = Arc::downgrade(&this); + tokio::spawn(refresh_loop(weak, identities, session_id, origin)); + Ok(this) + } + + /// Wrap `initial`; refreshes come through [`Self::refresh_with`]. + pub fn over(initial: Arc) -> Result { + let sender = initial.sender()?; + Ok(Self { sender, current: RwLock::new(initial) }) + } + + /// Replace the signing identity. Refused when `fresh` is for another + /// principal: a deploy must finish as the principal it started as. + pub fn refresh_with(&self, fresh: Arc) -> Result<(), String> { + let sender = fresh.sender()?; + if sender != self.sender { + return Err(format!( + "refreshed identity is {sender}, not {} the deploy started as", + self.sender + )); + } + *self.current.write().unwrap_or_else(|e| e.into_inner()) = fresh; + Ok(()) + } + + fn current(&self) -> Arc { + self.current.read().unwrap_or_else(|e| e.into_inner()).clone() + } +} + +/// Runs until the identity it refreshes is gone: the deploy drops its agent, +/// the agent drops the identity, and the next tick finds nothing to refresh. +async fn refresh_loop( + identity: Weak, + identities: Identities, + session_id: String, + origin: &'static str, +) { + loop { + tokio::time::sleep(REFRESH_EVERY).await; + let Some(identity) = identity.upgrade() else { return }; + match identities.delegated_identity(&session_id, origin, None).await { + Ok(fresh) => { + if let Err(e) = identity.refresh_with(Arc::new(fresh)) { + tracing::warn!("deploy identity not refreshed: {e}"); + } + } + // Transient: the current delegation stays in use, and the next + // tick tries again. If the session grant itself has lapsed the + // deploy's calls fail with II's own reason. + Err(e) => tracing::warn!("deploy identity not refreshed: {e}"), + } + } +} + +impl Identity for RefreshingIdentity { + fn sender(&self) -> Result { + Ok(self.sender) + } + fn public_key(&self) -> Option> { + self.current().public_key() + } + fn sign(&self, content: &EnvelopeContent) -> Result { + self.current().sign(content) + } + fn sign_delegation(&self, content: &Delegation) -> Result { + self.current().sign_delegation(content) + } + fn sign_arbitrary(&self, content: &[u8]) -> Result { + self.current().sign_arbitrary(content) + } + fn delegation_chain(&self) -> Vec { + self.current().delegation_chain() + } + fn sender_info(&self) -> Option { + self.current().sender_info() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ic_agent::identity::BasicIdentity; + + fn key(seed: u8) -> Arc { + Arc::new(BasicIdentity::from_raw_key(&[seed; 32])) + } + + #[test] + fn a_refresh_keeps_the_principal_and_takes_the_new_key() { + let first = key(1); + let identity = RefreshingIdentity::over(first.clone()).unwrap(); + assert_eq!(identity.sender().unwrap(), first.sender().unwrap()); + + // Same key again: accepted, and the signer is the new object. + let again = key(1); + identity.refresh_with(again.clone()).unwrap(); + assert_eq!( + identity.sign_arbitrary(b"x").unwrap().signature, + again.sign_arbitrary(b"x").unwrap().signature + ); + + // A different principal is refused and leaves the current one in place. + let other = key(2); + let err = identity.refresh_with(other.clone()).unwrap_err(); + assert!(err.contains("not"), "{err}"); + assert_eq!(identity.public_key(), first.public_key()); + assert_ne!(identity.public_key(), other.public_key()); + } +} diff --git a/crates/imcp2-deploy/src/lib.rs b/crates/imcp2-deploy/src/lib.rs new file mode 100644 index 0000000..a789f00 --- /dev/null +++ b/crates/imcp2-deploy/src/lib.rs @@ -0,0 +1,604 @@ +//! imcp2-deploy — the `icp_deploy_bundle` MCP tool: deploy an icp project +//! bundle to the Internet Computer as the user, through the icp CLI's own +//! project library (`icp-project`, the code behind `icp deploy`). Next to it, +//! `icp_cycles_balance`: the balance those deploys create canisters from. +//! +//! The tool takes a bundle (the `.tar.gz` that `icp project bundle` writes), +//! the name of an environment the bundle declares, and optionally the canister +//! ids an earlier deploy produced; it answers with the deploy's status and the +//! resulting canister-id mapping. Canisters the mapping does not name are +//! created (funded from the user's cycles-ledger balance), the rest are +//! upgraded in place; every phase of `icp deploy` runs — including the sync +//! steps that upload a frontend canister's assets. +//! +//! Calls are signed as the user's **CLI identity**: their default Internet +//! Identity account at [`CLI_ORIGIN`] (`https://cli.id.ai`), the origin +//! `icp identity link web` derives at. It is the principal the icp CLI already +//! deploys as on the user's machine, so canisters created here are the ones +//! `icp` lists and controls, and the cycles that fund them are the CLI's. This +//! is deliberately NOT the management identity the canister lifecycle tools +//! act as (the default account at the server's own origin): a canister a +//! deploy creates is controlled by the CLI principal, and reaching it through +//! the lifecycle tools means adding their principal as a controller first. +//! +//! 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`). + +use std::{collections::BTreeMap, sync::Arc}; + +use base64::Engine; +use camino::Utf8PathBuf; +use candid::Principal; +use ic_agent::{identity::DelegatedIdentity, Identity}; +use imcp2_core::{cycles_balance_of, identities::Identities, CyclesBalance, SessionResolver}; +use rmcp::{ + handler::server::wrapper::Parameters, + model::{CallToolResult, ContentBlock}, + schemars, + service::RequestContext, + tool, tool_router, ErrorData as McpError, RoleServer, +}; +use serde::{Deserialize, Serialize}; + +mod calls; +mod deploy; +mod host; +mod identity; + +pub use deploy::{deploy_bundle, BundleSource, DeployOutcome, DeployRequest, RequestError, Status}; +use identity::RefreshingIdentity; + +/// The icp CLI's default funding for a canister it creates (`icp deploy +/// --cycles`): 2 trillion cycles. +pub const DEFAULT_CYCLES: u64 = 2_000_000_000_000; + +/// The skill that teaches an agent the deploy loop around this tool — bundle +/// the project, call `icp_deploy_bundle` with the environment's canister-id +/// mapping, write the returned mapping back — served as the MCP resource +/// `skill://deploy-with-mcp` by the server that mounts the tool, next to +/// core's bundled IC skills. Compiled in, like those: what a client reads is +/// what was reviewed at build time. +pub const DEPLOY_SKILL_NAME: &str = "deploy-with-mcp"; +/// Its title, for the resource listing. +pub const DEPLOY_SKILL_TITLE: &str = "Deploy with this MCP server"; +/// The document itself (frontmatter + body), in the registry's SKILL.md shape. +pub const DEPLOY_SKILL_MD: &str = include_str!("../static/skills/deploy-with-mcp.md"); + +/// The skill document behind `skill://`, when `path` names this crate's +/// skill — matched like core's bundled skills (trimmed, ASCII +/// case-insensitive), so the two lookups compose into one `skill://` space. +pub fn skill_document(path: &str) -> Option<&'static str> { + path.trim().eq_ignore_ascii_case(DEPLOY_SKILL_NAME).then_some(DEPLOY_SKILL_MD) +} + +/// Arguments of `icp_deploy_bundle`. +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct DeployBundleArgs { + /// Absolute path, on this machine, of the bundle archive — the `.tar.gz` + /// that `icp project bundle --output ` writes. Either this or + /// `bundle_base64`, not both. + #[serde(default)] + pub bundle_path: Option, + /// The bundle archive itself, base64-encoded. Either this or + /// `bundle_path`, not both. + #[serde(default)] + pub bundle_base64: Option, + /// The environment to deploy, by name, among those the bundle's manifest + /// declares — `ic` for a bundle built with the default settings. It must + /// be on the `ic` network: this tool deploys to the Internet Computer only. + pub environment: String, + /// Canister ids to start from: canister name → canister id, the contents of + /// the `.ids.json` file the icp CLI keeps (or the + /// `canister_ids` a previous call of this tool returned). A canister named + /// here is upgraded in place; one that is not is created. Omit it on the + /// first deploy of a project. + #[serde(default)] + pub canister_ids: Option>, + /// Install mode: "auto" (default — install on an empty canister, upgrade + /// otherwise), "install", "reinstall" (wipes the canister's state) or + /// "upgrade". + #[serde(default = "default_mode")] + pub mode: String, + /// Cycles to fund each canister this deploy creates, taken from the user's + /// cycles-ledger balance (see icp_cycles_balance). Default 2 trillion. + #[serde(default)] + pub cycles: Option, + /// Subnet to create canisters on. Default: picked by the network. + #[serde(default)] + pub subnet: Option, + /// Fail instead of creating any canister that does not exist yet. + #[serde(default)] + pub no_create: bool, + /// Skip the Candid interface compatibility check that an upgrade otherwise + /// runs, and fails on, when the new interface breaks the old one. + #[serde(default)] + pub skip_candid_check: bool, +} + +fn default_mode() -> String { + "auto".into() +} + +/// A canister created by the deploy. +#[derive(Debug, Serialize, schemars::JsonSchema)] +pub struct CreatedCanister { + pub name: String, + pub canister_id: String, +} + +/// Structured result of `icp_deploy_bundle`. +#[derive(Debug, Serialize, schemars::JsonSchema)] +pub struct DeployBundleOutput { + /// `deployed`, `failed` (see `error`; `created` and `canister_ids` hold + /// what landed before the failure), or `nothing_to_deploy` (the environment + /// lists no canisters). + pub status: String, + /// The environment deployed. + pub environment: String, + /// The network that environment targets. + pub network: String, + /// The canisters the deploy targeted, in manifest order. + pub canisters: Vec, + /// Canisters this deploy created, in creation order. + pub created: Vec, + /// The resulting mapping, canister name → canister id: the ids passed in + /// plus the ids created. Pass it back as `canister_ids` to upgrade these + /// canisters next time. + pub canister_ids: BTreeMap, + /// Why the deploy failed, when it did. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + /// The deploy's phases and per-canister steps with their outcomes. + pub log: Vec, +} + +impl DeployBundleOutput { + fn from_outcome(outcome: &DeployOutcome) -> Self { + Self { + status: outcome.status.as_str().into(), + environment: outcome.environment.clone(), + network: outcome.network.clone(), + canisters: outcome.canisters.clone(), + created: outcome + .created + .iter() + .map(|(name, id)| CreatedCanister { name: name.clone(), canister_id: id.to_text() }) + .collect(), + canister_ids: outcome + .canister_ids + .iter() + .map(|(n, id)| (n.clone(), id.to_text())) + .collect(), + error: outcome.error.clone(), + log: outcome.log.clone(), + } + } + + /// The human-readable rendering: one summary line, the mapping, the log. + fn human(&self) -> String { + let mut text = match self.status.as_str() { + "deployed" => format!( + "Deployed environment '{}' to network '{}': {} canister(s), {} created.", + self.environment, + self.network, + self.canisters.len(), + self.created.len() + ), + "nothing_to_deploy" => format!( + "Environment '{}' declares no canisters, so there was nothing to deploy.", + self.environment + ), + _ => format!( + "Deploying environment '{}' to network '{}' FAILED: {}", + self.environment, + self.network, + self.error.as_deref().unwrap_or("unknown error") + ), + }; + if !self.canister_ids.is_empty() { + text.push_str("\n\nCanister ids (pass these back as `canister_ids` to upgrade):"); + for (name, id) in &self.canister_ids { + text.push_str(&format!("\n- {name}: {id}")); + } + } + if !self.log.is_empty() { + text.push_str("\n\nLog:"); + for line in &self.log { + text.push('\n'); + text.push_str(line); + } + } + text + } +} + +/// The longest `bundle_base64` that can decode to [`host::MAX_BUNDLE_BYTES`] +/// bytes: four characters per three bytes, the last group padded. +const MAX_BUNDLE_BASE64_CHARS: usize = host::MAX_BUNDLE_BYTES.div_ceil(3) * 4; + +/// Turn the tool's arguments into a request, rejecting what cannot be deployed +/// before anything is read or written. +pub fn request_from_args(args: DeployBundleArgs) -> Result { + let bundle = match (args.bundle_path, args.bundle_base64) { + (Some(_), Some(_)) => { + return Err("pass either bundle_path or bundle_base64, not both".into()) + } + (None, None) => return Err("pass the bundle as bundle_path or bundle_base64".into()), + (Some(path), None) => BundleSource::Path(Utf8PathBuf::from(path)), + (None, Some(b64)) => { + let b64 = b64.trim(); + // Bound the encoded text before allocating the decoded copy: + // base64 spends four characters per three bytes, so anything + // longer than this encodes more than the bundle limit. + if b64.len() > MAX_BUNDLE_BASE64_CHARS { + return Err(format!( + "bundle_base64 encodes more than the {} byte bundle limit", + host::MAX_BUNDLE_BYTES + )); + } + let bytes = base64::engine::general_purpose::STANDARD + .decode(b64) + .map_err(|e| format!("bundle_base64 is not valid base64: {e}"))?; + if bytes.len() > host::MAX_BUNDLE_BYTES { + return Err(format!( + "the bundle is {} bytes, above the {} byte limit", + bytes.len(), + host::MAX_BUNDLE_BYTES + )); + } + BundleSource::Bytes(bytes) + } + }; + let environment = args.environment.trim().to_string(); + if environment.is_empty() { + return Err("environment must name an environment of the bundle, e.g. \"ic\"".into()); + } + let mode = args.mode.trim().to_ascii_lowercase(); + if !matches!(mode.as_str(), "auto" | "install" | "reinstall" | "upgrade") { + return Err(format!( + "mode must be \"auto\", \"install\", \"reinstall\" or \"upgrade\", got \"{}\"", + args.mode + )); + } + let mut canister_ids = BTreeMap::new(); + for (name, id) in args.canister_ids.unwrap_or_default() { + let principal = Principal::from_text(id.trim()) + .map_err(|e| format!("canister_ids[\"{name}\"] is not a canister id: {e}"))?; + canister_ids.insert(name, principal); + } + let subnet = match args.subnet { + Some(s) => Some( + Principal::from_text(s.trim()) + .map_err(|e| format!("subnet is not a principal: {e}"))?, + ), + None => None, + }; + Ok(DeployRequest { + bundle, + environment, + canister_ids, + mode, + cycles: u128::from(args.cycles.unwrap_or(DEFAULT_CYCLES)), + subnet, + no_create: args.no_create, + skip_candid_check: args.skip_candid_check, + }) +} + +/// The deploy tool group, served alongside the core surface by the binary +/// that composes it: `icp_deploy_bundle`, and `icp_cycles_balance` — the +/// check a deploy that will create canisters starts with. The core surface +/// has a tool of that name too, in its protocol/meta half, which the local +/// server does not serve; this one is the same logic on the surface the +/// deploy loop runs on. Should the core half be served alongside one day, +/// drop this copy rather than list the name twice. +#[derive(Clone)] +pub struct IcDeployTools { + identities: Identities, + /// The authentication seam (see [`SessionResolver`]): asks the embedding + /// binary which already-validated session this call acts as. + session: SessionResolver, +} + +/// The derivation origin of the identity the deploy tools act as: the one the +/// icp CLI's `icp identity link web` (with no `--app`) signs in at. Internet +/// Identity derives one principal per anchor per origin, so a delegation at +/// this origin IS the user's CLI identity. +pub const CLI_ORIGIN: &str = "https://cli.id.ai"; + +#[tool_router(vis = "pub")] +impl IcDeployTools { + pub fn new(identities: Identities, session: SessionResolver) -> Self { + Self { identities, session } + } + + /// The session's CLI identity: its default account at [`CLI_ORIGIN`], + /// derived on demand like any other per-app delegation. Good for the + /// next few minutes; a deploy uses [`RefreshingIdentity`] instead. + async fn cli_identity(&self, sid: &str) -> Result { + self.identities.delegated_identity(sid, CLI_ORIGIN, None).await + } + + #[tool( + description = "Deploy an icp project bundle (the .tar.gz that `icp project bundle` writes) to the Internet Computer as the user, running every phase of `icp deploy`: create the canisters the bundle names that do not exist yet (funded from the user's cycles balance), wire their ids into each other, apply settings, install or upgrade the code, and run the sync steps (a frontend's asset upload). Pass the bundle by absolute path or base64, the environment name it was built for (usually \"ic\"), and, to upgrade rather than create, the canister_ids a previous deploy returned. Returns the status, the resulting canister-id mapping, and a log of each phase; on failure the mapping still holds every canister that was created.", + annotations(title = "Deploy a project bundle", read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = true), + output_schema = schema_for_output::(), + )] + async fn icp_deploy_bundle( + &self, + Parameters(args): Parameters, + ctx: RequestContext, + ) -> Result { + // Who is asking comes before what they sent: an inline bundle is + // decoded only for a session allowed to deploy. + let Some(sid) = (self.session)(&ctx) else { + return Ok(err("deploying a bundle needs an authenticated session".into())); + }; + if let Err(e) = self.identities.require_write(&sid).await { + return Ok(err(e)); + } + let request = match request_from_args(args) { + Ok(r) => r, + Err(e) => return Ok(err(e)), + }; + // A per-app delegation lives five minutes and a deploy can outlast + // it (an asset sync, several installs), so the identity the agent + // signs with is kept fresh for as long as the deploy runs. + let identity = + match RefreshingIdentity::start(self.identities.clone(), sid, CLI_ORIGIN).await { + Ok(id) => id, + Err(e) => return Ok(err(e)), + }; + match deploy_bundle(identity, request).await { + Err(RequestError(e)) => Ok(err(e)), + Ok(outcome) => { + let output = DeployBundleOutput::from_outcome(&outcome); + let text = output.human(); + Ok(match outcome.status { + Status::Failed => err_structured(text, &output), + Status::Deployed | Status::NothingToDeploy => ok_structured(text, &output), + }) + } + } + } + + #[tool( + description = "Return the cycles-ledger balance of the principal icp_deploy_bundle deploys as, and that principal. It is your CLI identity: the same principal `icp identity link web` signs in as, so it matches what `icp identity principal` prints for that identity. Call it before a deploy that will create canisters, to check the balance covers them (2T cycles each by default), and to get the principal to add as a controller of a canister created elsewhere.", + annotations(title = "Check your cycles balance", read_only_hint = true, destructive_hint = false, open_world_hint = true), + output_schema = schema_for_output::(), + )] + async fn icp_cycles_balance( + &self, + Parameters(_args): Parameters, + ctx: RequestContext, + ) -> Result { + let Some(sid) = (self.session)(&ctx) else { + return Ok(err("checking your cycles balance needs an authenticated session".into())); + }; + let identity = match self.cli_identity(&sid).await { + Ok(id) => id, + Err(e) => return Ok(err(e)), + }; + let principal = match identity.sender() { + Ok(p) => p, + Err(e) => return Ok(err(format!("could not derive your principal: {e}"))), + }; + let agent = self.identities.agent_as(identity); + match cycles_balance_of(&agent, principal).await { + Ok(balance) => Ok(ok_structured(balance.human(), &balance)), + Err(e) => Ok(err(e)), + } + } +} + +/// `icp_cycles_balance` takes no arguments. +#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] +pub struct NoArgs {} + +/// Same contract as the core surface's result builder: the human-readable +/// text plus `structuredContent` conforming to the declared `outputSchema`. +fn ok_structured(text: String, value: &T) -> CallToolResult { + let mut result = CallToolResult::success(vec![ContentBlock::text(text)]); + result.structured_content = structured(value); + result +} + +/// An error result that still carries the structured output: a failed deploy +/// has created canisters the caller must learn about, and `isError` alone +/// would hide them from a client that reads structured content. +fn err_structured(text: String, value: &T) -> CallToolResult { + let mut result = CallToolResult::error(vec![ContentBlock::text(text)]); + result.structured_content = structured(value); + result +} + +fn structured(value: &T) -> Option { + match serde_json::to_value(value) { + Ok(v @ serde_json::Value::Object(_)) => Some(v), + _ => None, + } +} + +fn err(text: String) -> CallToolResult { + CallToolResult::error(vec![ContentBlock::text(text)]) +} + +/// Same panic-at-router-construction posture as the core surface: a +/// non-object output schema is a programming error. +fn schema_for_output() -> Arc { + rmcp::handler::server::tool::schema_for_output::().unwrap_or_else(|e| { + panic!("output schema for `{}` must be object-rooted: {e}", std::any::type_name::()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(json: serde_json::Value) -> DeployBundleArgs { + serde_json::from_value(json).expect("valid args") + } + + // The skill is in the registry's SKILL.md shape (frontmatter naming it as + // the URI does, a description for the agent's trigger decision), teaches + // the three steps around the tool by name, and resolves under the same + // lookup rules as core's bundled skills. + #[test] + fn the_deploy_skill_is_a_well_formed_skill_document() { + let md = DEPLOY_SKILL_MD; + assert!(md.starts_with("---\n"), "frontmatter first"); + let (front, body) = md[4..].split_once("\n---\n").expect("closed frontmatter"); + assert!(front.lines().any(|l| l == format!("name: {DEPLOY_SKILL_NAME}")), "{front}"); + assert!(front.lines().any(|l| l.starts_with("description: ")), "{front}"); + assert!(front.contains(&format!("title: {DEPLOY_SKILL_TITLE}")), "{front}"); + for needle in [ + "icp project bundle", + "icp_deploy_bundle", + "canister_ids", + ".icp/data/mappings/.ids.json", + "bundle_path", + "bundle_base64", + "authenticate", + "icp_cycles_balance", + ] { + assert!(body.contains(needle), "the skill must mention `{needle}`"); + } + // The steps are named in the order an agent performs them. + let step = |n: &str| body.find(n).unwrap_or_else(|| panic!("missing heading {n}")); + assert!( + step("## Step 1: Bundle") < step("## Step 2: Request") + && step("## Step 2: Request") < step("## Step 3: Update"), + "steps out of order" + ); + + assert_eq!(skill_document("deploy-with-mcp"), Some(md)); + assert_eq!(skill_document(" Deploy-With-MCP "), Some(md)); + assert_eq!(skill_document("deploy-with-mcp/references/x.md"), None); + assert_eq!(skill_document("icp-cli"), None); + } + + // The group carries the deploy tool, with honest annotations (it creates + // canisters and can wipe state) and an object-rooted outputSchema like + // every tool on the core surface, and the read-only balance check beside + // it. + #[test] + fn the_router_carries_the_deploy_tool_and_the_balance_check() { + let tools = IcDeployTools::tool_router().list_all(); + assert_eq!(tools.len(), 2); + let balance = tools.iter().find(|t| &*t.name == "icp_cycles_balance").expect("balance"); + let ann = balance.annotations.clone().expect("annotations"); + assert_eq!(ann.read_only_hint, Some(true)); + let output = + serde_json::to_value(balance.output_schema.as_ref().expect("outputSchema")).unwrap(); + assert!(output["properties"]["balance"].is_object(), "{output}"); + assert!(output["properties"]["principal"].is_object(), "{output}"); + + let tool = tools.iter().find(|t| &*t.name == "icp_deploy_bundle").expect("deploy"); + let ann = tool.annotations.clone().expect("annotations"); + assert_eq!(ann.read_only_hint, Some(false)); + assert_eq!(ann.destructive_hint, Some(true)); + let schema = serde_json::to_value(&tool.input_schema).unwrap(); + for field in ["bundle_path", "bundle_base64", "environment", "canister_ids", "mode"] { + assert!(schema["properties"][field].is_object(), "missing {field}: {schema}"); + } + assert_eq!(schema["required"], serde_json::json!(["environment"])); + let output = + serde_json::to_value(tool.output_schema.as_ref().expect("outputSchema")).unwrap(); + assert_eq!(output["type"], "object"); + for field in ["status", "canister_ids", "created", "log"] { + assert!(output["properties"][field].is_object(), "missing {field}: {output}"); + } + } + + #[test] + fn exactly_one_bundle_source_is_accepted() { + let both = args(serde_json::json!({ + "bundle_path": "/tmp/b.tar.gz", "bundle_base64": "AAAA", "environment": "ic" + })); + assert!(request_from_args(both).unwrap_err().contains("not both")); + let neither = args(serde_json::json!({ "environment": "ic" })); + assert!(request_from_args(neither).unwrap_err().contains("bundle_path or bundle_base64")); + let bad = args(serde_json::json!({ "bundle_base64": "***", "environment": "ic" })); + assert!(request_from_args(bad).unwrap_err().contains("base64")); + } + + // Four base64 characters past the bound is refused on length alone, before + // any decoding: the bundle limit is measured on the text, not after a copy + // of it has been allocated. + #[test] + fn an_oversized_inline_bundle_is_refused_before_decoding() { + let text = "A".repeat(MAX_BUNDLE_BASE64_CHARS + 4); + let big = args(serde_json::json!({ "bundle_base64": text, "environment": "ic" })); + let e = request_from_args(big).unwrap_err(); + assert!(e.contains("encodes more than"), "{e}"); + } + + #[test] + fn the_request_takes_the_defaults_and_parses_the_ids() { + let request = request_from_args(args(serde_json::json!({ + "bundle_path": "/tmp/b.tar.gz", + "environment": " ic ", + "canister_ids": { "backend": "aaaaa-aa" }, + "mode": "Upgrade" + }))) + .unwrap(); + assert_eq!(request.environment, "ic"); + assert_eq!(request.mode, "upgrade"); + assert_eq!(request.cycles, u128::from(DEFAULT_CYCLES)); + assert_eq!(request.canister_ids["backend"], Principal::management_canister()); + assert!(request.subnet.is_none()); + assert!(!request.no_create && !request.skip_candid_check); + assert!(matches!(request.bundle, BundleSource::Path(ref p) if p == "/tmp/b.tar.gz")); + } + + #[test] + fn bad_modes_ids_and_subnets_are_named_in_the_rejection() { + let e = request_from_args(args(serde_json::json!({ + "bundle_path": "/b", "environment": "ic", "mode": "yolo" + }))) + .unwrap_err(); + assert!(e.contains("mode") && e.contains("yolo"), "{e}"); + let e = request_from_args(args(serde_json::json!({ + "bundle_path": "/b", "environment": "ic", "canister_ids": { "backend": "nope" } + }))) + .unwrap_err(); + assert!(e.contains("canister_ids[\"backend\"]"), "{e}"); + let e = request_from_args(args(serde_json::json!({ + "bundle_path": "/b", "environment": "ic", "subnet": "nope" + }))) + .unwrap_err(); + assert!(e.contains("subnet"), "{e}"); + let e = + request_from_args(args(serde_json::json!({ "bundle_path": "/b", "environment": " " }))) + .unwrap_err(); + assert!(e.contains("environment"), "{e}"); + } + + // The text a client without structured-output support reads carries the + // same facts: the status, the mapping to pass back, and the log. + #[test] + fn the_human_text_carries_the_mapping_and_the_log() { + let outcome = DeployOutcome { + status: Status::Failed, + environment: "ic".into(), + network: "ic".into(), + canisters: vec!["backend".into(), "frontend".into()], + created: vec![("backend".into(), Principal::management_canister())], + canister_ids: [("backend".to_string(), Principal::management_canister())].into(), + error: Some("install failed".into()), + log: vec!["Creating canisters: ok".into()], + }; + let output = DeployBundleOutput::from_outcome(&outcome); + assert_eq!(output.status, "failed"); + assert_eq!(output.created[0].canister_id, "aaaaa-aa"); + let text = output.human(); + assert!(text.contains("FAILED: install failed"), "{text}"); + assert!(text.contains("- backend: aaaaa-aa"), "{text}"); + assert!(text.contains("Creating canisters: ok"), "{text}"); + let result = err_structured(text, &output); + assert_eq!(result.is_error, Some(true)); + assert_eq!(result.structured_content.unwrap()["canister_ids"]["backend"], "aaaaa-aa"); + } +} diff --git a/crates/imcp2-deploy/static/skills/deploy-with-mcp.md b/crates/imcp2-deploy/static/skills/deploy-with-mcp.md new file mode 100644 index 0000000..9467169 --- /dev/null +++ b/crates/imcp2-deploy/static/skills/deploy-with-mcp.md @@ -0,0 +1,181 @@ +--- +name: deploy-with-mcp +description: "Deploy an icp project to the Internet Computer through this MCP server's icp_deploy_bundle tool instead of running icp deploy yourself. Covers the three steps an agent performs: bundle the project with icp project bundle, call icp_deploy_bundle with the bundle and the environment's canister-id mapping, and write the mapping the tool returns back into the project. Use when the user asks you to deploy, ship, publish, upgrade or redeploy an icp project, or asks what icp_deploy_bundle needs. Do NOT use for projects targeting a local network (run icp deploy there) or for building the project (see the icp-cli skill)." +license: Apache-2.0 +metadata: + title: Deploy with this MCP server + category: Infrastructure +--- + +# Deploy with this MCP server + +## What This Is + +This server has one tool that deploys: `icp_deploy_bundle`. It takes a +project bundle, an environment name and the environment's canister-id +mapping, runs every phase of `icp deploy` on the server's side (create, +wire ids, apply settings, install or upgrade, sync), and returns the status +and the resulting mapping. The project itself stays on your side: you build +it, bundle it, and keep its canister ids. + +A deploy is three steps, in order: + +1. Bundle the project with the icp CLI. +2. Call `icp_deploy_bundle` with the bundle, the environment, and the + environment's current canister-id mapping. +3. Write the mapping the tool returns back into the project. + +Skipping step 3 is the mistake that costs the most: the next deploy would +not know the canisters already exist and would create a second set. + +## Prerequisites + +- The icp CLI (0.3.0 or later) on the machine that builds the project. + Installation and project configuration are in the `icp-cli` skill. +- A signed-in session with the "Actions & questions" access level. If a + tool answers that it needs an authenticated session, call `authenticate`, + have the user open the returned id.ai link, then retry. Check with + `auth_status`. +- Cycles. Every canister the bundle names that does not exist yet is created + from the user's cycles balance, 2T cycles each unless `cycles` says + otherwise. `icp_cycles_balance` reports the balance and the principal + it belongs to: the user's CLI identity, the same principal `icp identity + link web` signs in as, so a canister deployed here is one `icp` controls + too. Before a deploy that will create canisters, tell the user how + many and confirm; creation is not undone by a later failure. +- An environment on the `ic` network (mainnet), which every manifest has. + The tool deploys to the Internet Computer only: an environment on any other + network — the default `local`, a testnet — is refused. Deploy those with + `icp deploy` in the project directory instead. + +## Step 1: Bundle the project + +From the project root (the directory holding `icp.yaml`): + +```bash +icp project bundle --output /absolute/path/to/myapp.tar.gz +``` + +This builds every canister, then writes a gzipped tar archive holding the +built wasms, the plugins the sync steps need, and a rewritten `icp.yaml` +whose build steps reference the bundled wasms, pinned by sha256. The bundle +is self-contained: the server needs no toolchain to deploy it. + +Rules that matter here: + +- Use an absolute output path outside any directory a canister syncs (a + frontend's `dist/`, for example), or the archive would include itself. +- Only `plugin` sync steps can be replayed from a bundle. A canister with a + `script` sync step cannot be bundled; the CLI says so. +- The bundle is built for the environments the manifest declares. Pick the + one you will deploy in step 2 now, and build with the matching settings. + +If the build fails, fix the project and rebuild. Nothing has been sent yet. + +## Step 2: Request the deployment + +### Read the environment's canister-id mapping + +The icp CLI keeps one mapping file per environment: + +``` +.icp/data/mappings/.ids.json +``` + +It is a JSON object from canister name to principal: + +```json +{ + "backend": "rrkah-fqaaa-aaaaa-aaaaq-cai", + "frontend": "ryjl3-tyaaa-aaaaa-aaaba-cai" +} +``` + +Read it if it exists. If it does not, this is the first deploy of that +environment and every canister will be created. + +### Call the tool + +Pass the bundle, the environment name, and the mapping as `canister_ids`: + +```json +{ + "bundle_path": "/absolute/path/to/myapp.tar.gz", + "environment": "ic", + "canister_ids": { + "backend": "rrkah-fqaaa-aaaaa-aaaaq-cai", + "frontend": "ryjl3-tyaaa-aaaaa-aaaba-cai" + } +} +``` + +- `bundle_path` must be absolute. When the server cannot read your + filesystem, send the archive inline as `bundle_base64` instead; exactly one + of the two is accepted. +- `environment` is the name as the manifest declares it, `ic` for a default + project. +- `canister_ids` is the file's content. Omit it, or send `{}`, on a first + deploy. Every canister named in it is upgraded in place; every canister the + bundle names that is missing from it is created. + +Canisters named in `canister_ids` are upgraded with the Candid compatibility +check; an incompatible interface change fails the deploy before any code is +replaced. Pass `skip_candid_check: true` only when the user has accepted the +break. `mode` (default `auto`; `install`, `reinstall`, `upgrade`), +`cycles`, `subnet` and `no_create` mirror the flags of `icp deploy`. + +### Read the answer + +The tool returns: + +- `status`: `deployed`, `failed`, or `nothing_to_deploy`. +- `canister_ids`: the mapping after this run, the ids you passed in plus + every id created. Step 3 uses this. +- `created`: the canisters created by this run, in order. +- `error`: what failed, when the status is `failed`. +- `log`: one line per phase and per canister task, with the cause of each + failure. + +A deploy that fails still returns every canister it created before failing, +in both `canister_ids` and `created`. Do step 3 on that answer too, then +fix the cause the log names and run step 2 again with the updated mapping: +the retry upgrades what exists and creates only what is still missing. + +## Step 3: Update the project's mapping file + +Write the returned `canister_ids` into the environment's mapping file, +replacing the entries it names and keeping any others: + +``` +.icp/data/mappings/.ids.json +``` + +Create the `.icp/data/mappings/` directory if it does not exist. The file is +the project's only record of which on-chain canisters belong to it; the +`icp-cli` skill lists committing `.icp/data/` to version control as the +rule, and losing the file as the pitfall. Tell the user which canisters were +created and their ids. + +After this step, `icp deploy -e ` from the CLI and +`icp_deploy_bundle` from here see the same canisters and can be used +interchangeably for that environment. + +## Common Pitfalls + +1. **Deploying twice without writing the mapping back.** The second deploy + creates new canisters next to the first ones, spending cycles on + duplicates. Always do step 3, including after a failure. +2. **Passing a relative `bundle_path`.** The tool refuses it; the server's + working directory is not the project's. +3. **Deploying `local`.** Refused: this tool deploys to the `ic` network + only. Use `icp deploy` in the project directory for local work and for + other networks. +4. **Bundling a stale build.** `icp project bundle` builds first, so the + bundle is current, but a bundle written earlier is not. Rebundle after + every change. +5. **Mapping entries that are not canisters of the environment.** A name in + `canister_ids` that the bundle's manifest does not declare for that + environment is rejected before anything runs. Remove canisters from the + file when they are removed from the project. +6. **Expecting the server to keep state.** It keeps nothing between calls: + no bundle, no mapping. The project's mapping file is the record. diff --git a/crates/imcp2-local/Cargo.toml b/crates/imcp2-local/Cargo.toml index 21662db..3d97823 100644 --- a/crates/imcp2-local/Cargo.toml +++ b/crates/imcp2-local/Cargo.toml @@ -23,6 +23,11 @@ dist = true # primitives the login listener wraps. The IC agent is constructed from # core's `ic_agent` re-export, so the process links exactly one ic-agent. imcp2-core = { workspace = true } +# The `icp_deploy_bundle` tool: deploying an icp project bundle through the +# icp CLI's project library. Its own crate because it pulls `icp-project` from +# git, which the published crates cannot; this binary is dist-released, so it +# can. (It is what brings wasmtime — the sync-plugin runtime — into the build.) +imcp2-deploy = { path = "../imcp2-deploy" } rmcp = { workspace = true, features = ["server", "macros", "transport-io"] } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time"] } # The transient loopback login listener (component 5 of the design): three diff --git a/crates/imcp2-local/README.md b/crates/imcp2-local/README.md index deedb86..909d7cf 100644 --- a/crates/imcp2-local/README.md +++ b/crates/imcp2-local/README.md @@ -7,7 +7,10 @@ It serves the same tools as the hosted server at [mcp.internetcomputer.org](https://mcp.internetcomputer.org) — canister reads and writes in textual Candid, app discovery, OQL, canister management — against the same **IC mainnet** and the same **production Internet -Identity**. What it drops is the hosted server's entire OAuth 2.1 layer: a +Identity**, plus two tools of its own: `icp_deploy_bundle`, which deploys an +icp project bundle (see [Deploying a project bundle](#deploying-a-project-bundle)), +and `icp_cycles_balance`, which reports the balance those deploys draw on. +What it drops is the hosted server's entire OAuth 2.1 layer: a single-user process reached over a pipe needs no bearer tokens, so your II login never passes through a third-party server. @@ -136,6 +139,62 @@ Sessions are **in memory only**: you sign in again after a restart, when the grant expires, or if you revoke it at id.ai (Manage access). Signing in again is the same one-step browser round-trip — no client restart needed. +## Deploying a project bundle + +`icp_deploy_bundle` deploys an [icp CLI](https://github.com/dfinity/icp-cli) +project to the Internet Computer as you, running the same phases as +`icp deploy` through the CLI's own project library: it creates the canisters +that do not exist yet (funded from your cycles-ledger balance, 2T cycles each +by default), wires their ids into each other's environment variables, applies +the manifest's settings, installs or upgrades the code (with the Candid +compatibility check on upgrades), and runs the sync steps that upload a +frontend's assets. + +It takes: + +- the **bundle** — the self-contained archive `icp project bundle --output + ` writes, by absolute path (`bundle_path`) or inline (`bundle_base64`); +- the **environment** to deploy, by name, among those the bundle declares + (`ic` for a bundle built with the defaults). It must be on the `ic` + network: the tool deploys to the Internet Computer only, so a locally + launched network or a testnet is refused; +- optionally the **canister ids** from an earlier deploy (`canister_ids`, a + name → id map: the contents of the CLI's `.ids.json`, or what a + previous call returned). Canisters named there are upgraded in place; the + rest are created. + +It answers with the status (`deployed`, `failed`, `nothing_to_deploy`), the +resulting canister-id mapping — pass it back next time to upgrade — the +canisters created, and a log of each phase. A failed deploy still reports every +canister it created before failing, so the run can be picked up rather than +repeated. + +Calls are signed as your **CLI identity**: your default II account at +`https://cli.id.ai`, the origin `icp identity link web` signs in at. That is +the principal the icp CLI deploys as on your machine, so canisters deployed +here are the ones `icp` lists and controls, and they are funded from the same +cycles balance. `icp_cycles_balance` reports that balance and principal. It is +not the management identity the canister lifecycle tools act as (see +`IMCP2_MANAGEMENT_ORIGIN`); to reach a deployed canister through those tools, +add their principal as a controller. The session must have been granted +"Actions & questions" at sign-in. The bundle is extracted into a +scratch directory for the duration of the deploy and removed afterwards; +nothing is written to the project it came from. + +The server also serves a skill for the agent, `skill://deploy-with-mcp`, +next to the IC skills the core surface bundles. It walks through the loop +above from the agent's side: bundle the project with `icp project bundle`, +call `icp_deploy_bundle` with the environment's +`.icp/data/mappings/.ids.json` as `canister_ids`, and write the +mapping the tool returns back into that file so the next deploy upgrades +rather than creates. The document lives at +`crates/imcp2-deploy/static/skills/deploy-with-mcp.md`. + +Why only here: the tool is built on `icp-project`, a crate the icp CLI does not +publish, so it is pulled from git — which the crates.io-published `imcp2-core` +and `imcp2` cannot do. It lives in its own `imcp2-deploy` crate, composed into +this binary alone. + ## Upgrading Client registrations point at a stable installed path, so an upgrade is a @@ -153,7 +212,7 @@ Internet Identity (`https://id.ai`). Environment overrides, mainly for tests: | `IMCP2_IC_URL` | IC API endpoint. | | `IMCP2_FETCH_ROOT_KEY` | Truthy: trust the endpoint's fetched root key — honoured **only** when `IMCP2_IC_URL` targets loopback (a local replica / PocketIC); startup refuses otherwise. | | `II_URL_PROD` / `II_CANISTER_ID_PROD` | Override the Internet Identity instance (e.g. beta II, or an II canister in PocketIC). | -| `IMCP2_MANAGEMENT_ORIGIN` | Derivation origin of the canister-management identity. Defaults to the hosted server's origin so the same anchor keeps the same controller principal locally and hosted. | +| `IMCP2_MANAGEMENT_ORIGIN` | Derivation origin of the canister-management identity. Defaults to the hosted server's origin so the same anchor keeps the same controller principal locally and hosted. The deploy tools do not use it; they act at `https://cli.id.ai`. | | `IMCP2_NO_OPEN` | Truthy: never auto-open the browser on sign-in (the link is still returned in-band). | All diagnostics go to stderr (`RUST_LOG` filters them); stdout is the MCP diff --git a/crates/imcp2-local/src/main.rs b/crates/imcp2-local/src/main.rs index 5f4526a..4accf23 100644 --- a/crates/imcp2-local/src/main.rs +++ b/crates/imcp2-local/src/main.rs @@ -6,6 +6,13 @@ //! authorization server: a single-user process reached over a pipe needs no //! bearer tokens — the OS process boundary is the auth gate. //! +//! On top of the core surface it serves `icp_deploy_bundle` (from +//! `imcp2-deploy`): deploying an icp project bundle through the icp CLI's own +//! project library, as the user's CLI identity (their default II account at +//! `https://cli.id.ai`, the principal `icp identity link web` signs in as, so +//! `icp` and this tool deploy as the same controller). Local-only because +//! that library is a git dependency the published crates cannot carry. +//! //! Signing in is a built-in browser handshake (see `login`): the //! `authenticate` tool returns an id.ai link, a transient loopback listener //! receives II's callback, and the redeemed session lives in memory only. @@ -26,7 +33,8 @@ //! management identity (default `https://mcp.internetcomputer.org`, the //! hosted server's origin, so the same anchor gets the SAME controller/ //! funder principal locally and hosted — canisters created through one -//! stay manageable through the other). +//! stay manageable through the other). Not used by the deploy tools, +//! which act at `https://cli.id.ai`. //! * `IMCP2_NO_OPEN` — truthy disables the best-effort browser auto-open on //! sign-in (the link is always returned in-band regardless). @@ -40,6 +48,7 @@ mod setup; mod e2e_local_login; use imcp2_core::{identities::Identities, IcTools, IiInstance}; +use imcp2_deploy::IcDeployTools; use login::SessionSlot; use rmcp::ServiceExt; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -167,11 +176,13 @@ async fn serve() -> anyhow::Result<()> { // hands core answers every tool call's "which session?" from it. let slot = SessionSlot::new(); let tools = IcTools::new(agent, identities.clone(), slot.resolver()); + let deploy = IcDeployTools::new(identities.clone(), slot.resolver()); let auto_open = !truthy("IMCP2_NO_OPEN"); let login = login::LoginDriver::new(identities, slot, auto_open); - let service = - server::LocalServer::new(tools, login, auto_open).serve(rmcp::transport::stdio()).await?; + let service = server::LocalServer::new(tools, deploy, login, auto_open) + .serve(rmcp::transport::stdio()) + .await?; tracing::info!("imcp2-local serving MCP over stdio"); // Runs until the client closes the pipe (or the service errors). service.waiting().await?; diff --git a/crates/imcp2-local/src/server.rs b/crates/imcp2-local/src/server.rs index 4dbe029..1cfe69b 100644 --- a/crates/imcp2-local/src/server.rs +++ b/crates/imcp2-local/src/server.rs @@ -1,27 +1,34 @@ //! The local MCP server: [`IcTools`] wrapped in a thin handler that adds the -//! two **local-only login tools**. They cannot live in `imcp2-core` — they -//! drive this binary's loopback listener and browser opener, which core cannot -//! call back into — so this wrapper carries them in its own `#[tool_router]`, -//! answers `tools/list` with the merged list, dispatches `tools/call` to its -//! own router first, and forwards everything else to the inner [`IcTools`] -//! handler verbatim. The hosted server contains none of this code, so its tool -//! surface stays login-free by construction (a core-side test guards the +//! two **local-only login tools** and the **deploy tool**. The login tools +//! cannot live in `imcp2-core` — they drive this binary's loopback listener +//! and browser opener, which core cannot call back into — so this wrapper +//! carries them in its own `#[tool_router]`. The deploy tool +//! (`icp_deploy_bundle` and `icp_cycles_balance`, [`IcDeployTools`]) cannot either, for a different +//! reason: it runs on the icp CLI's project library, a git dependency the +//! published crates cannot carry. The wrapper answers `tools/list` with the +//! merged list, dispatches `tools/call` to its own router, then the deploy +//! router, and forwards everything else to the inner [`IcTools`] handler +//! verbatim. The hosted server contains none of this code, so its tool surface +//! stays login- and deploy-free by construction (a core-side test guards the //! boundary too). use rmcp::handler::server::tool::ToolCallContext; use rmcp::handler::server::wrapper::Parameters; use rmcp::model::{ CallToolRequestParams, CallToolResult, ContentBlock, ListResourcesResult, ListToolsResult, - PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResult, Tool, + PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResult, Resource, + ResourceContents, Tool, }; use rmcp::service::RequestContext; use rmcp::{schemars, tool, tool_router, ErrorData as McpError, RoleServer, ServerHandler}; use crate::login::{BeginOutcome, Grant, LoginDriver, LoginStatus}; use imcp2_core::IcTools; +use imcp2_deploy::IcDeployTools; pub struct LocalServer { tools: IcTools, + deploy: IcDeployTools, login: LoginDriver, /// Whether the login flow will also try to open the browser itself — /// only phrasing: the link is always returned in-band. @@ -159,8 +166,8 @@ fn human_minutes(mins: u64) -> String { #[tool_router] impl LocalServer { - pub fn new(tools: IcTools, login: LoginDriver, auto_open: bool) -> Self { - Self { tools, login, auto_open } + pub fn new(tools: IcTools, deploy: IcDeployTools, login: LoginDriver, auto_open: bool) -> Self { + Self { tools, deploy, login, auto_open } } #[tool( @@ -266,7 +273,18 @@ impl ServerHandler for LocalServer { SIGNING IN. On this local server the user signs in through `authenticate`, which \ returns an id.ai link for them to open. Sessions are held in memory, so the user \ signs in again after a restart, when the grant expires, or if they revoke it at \ - id.ai." + id.ai.\n\n\ + DEPLOYING. This local server adds `icp_deploy_bundle`, the one exception to the \ + rule above that no tool deploys: it deploys an icp project bundle (`icp project \ + bundle --output `, built in the user's environment) to the environment it \ + names, creating the canisters that do not exist yet from the user's cycles \ + balance (`icp_cycles_balance` reports it) and upgrading those passed in \ + `canister_ids`. It deploys as the user's CLI identity (the principal `icp \ + identity link web` signs in as), not the management identity the canister \ + tools act as. It needs an \"Actions & questions\" session, and it returns the \ + canister-id mapping to pass back next time. \ + The `skill://deploy-with-mcp` resource walks through the loop: bundle, deploy, \ + write the mapping back into the project." )); info } @@ -278,6 +296,7 @@ impl ServerHandler for LocalServer { ) -> Result { let mut result = self.tools.list_tools(request, context).await?; result.tools.extend(Self::tool_router().list_all()); + result.tools.extend(IcDeployTools::tool_router().list_all()); Ok(result) } @@ -289,13 +308,20 @@ impl ServerHandler for LocalServer { if Self::tool_router().has_route(&request.name) { let tcc = ToolCallContext::new(self, request, context); Self::tool_router().call(tcc).await + } else if IcDeployTools::tool_router().has_route(&request.name) { + let tcc = ToolCallContext::new(&self.deploy, request, context); + IcDeployTools::tool_router().call(tcc).await } else { self.tools.call_tool(request, context).await } } fn get_tool(&self, name: &str) -> Option { - Self::tool_router().get(name).cloned().or_else(|| self.tools.get_tool(name)) + Self::tool_router() + .get(name) + .cloned() + .or_else(|| IcDeployTools::tool_router().get(name).cloned()) + .or_else(|| self.tools.get_tool(name)) } async fn list_resources( @@ -303,7 +329,14 @@ impl ServerHandler for LocalServer { request: Option, context: RequestContext, ) -> Result { - self.tools.list_resources(request, context).await + let mut result = self.tools.list_resources(request, context).await?; + // The one skill this server adds, next to core's bundled IC skills: the + // deploy loop around `icp_deploy_bundle`, which only exists here. + result.resources.push(Resource::new( + format!("skill://{}", imcp2_deploy::DEPLOY_SKILL_NAME), + format!("IC skill: {}", imcp2_deploy::DEPLOY_SKILL_TITLE), + )); + Ok(result) } async fn read_resource( @@ -311,6 +344,11 @@ impl ServerHandler for LocalServer { request: ReadResourceRequestParams, context: RequestContext, ) -> Result { + if let Some(md) = + request.uri.strip_prefix("skill://").and_then(imcp2_deploy::skill_document) + { + return Ok(ReadResourceResult::new(vec![ResourceContents::text(md, request.uri)])); + } self.tools.read_resource(request, context).await } } @@ -332,8 +370,9 @@ mod tests { ); let slot = SessionSlot::new(); let tools = IcTools::new(agent, identities.clone(), slot.resolver()); + let deploy = IcDeployTools::new(identities.clone(), slot.resolver()); let login = crate::login::LoginDriver::new(identities, slot, /* auto_open */ false); - LocalServer::new(tools, login, /* auto_open */ false) + LocalServer::new(tools, deploy, login, /* auto_open */ false) } // The wrapper's own router carries EXACTLY the two login tools — the rest @@ -389,8 +428,9 @@ mod tests { // A REAL MCP round-trip over an in-process duplex pipe (the same // byte-stream shape stdio serves): initialize; tools/list is the merged - // surface (the 11 served core tools + the 2 login tools); tools/call dispatches - // login tools to the wrapper and everything else to IcTools; the login + // surface (the 10 served core tools + the 2 login tools + the deploy tool); + // tools/call dispatches login tools to the wrapper, the deploy tool to its + // router, and everything else to IcTools; the login // lifecycle (signed out → link → pending) runs through the MCP layer — // all with no network and no browser. #[tokio::test] @@ -411,8 +451,20 @@ mod tests { assert!(instructions.contains("textual Candid"), "core guidance must survive the merge"); let tools = client.list_all_tools().await.expect("tools/list"); - assert_eq!(tools.len(), 12, "10 served core tools + authenticate + auth_status"); - for expected in ["get_canister_candid", "canister_query", "authenticate", "auth_status"] { + assert_eq!( + tools.len(), + 14, + "10 served core tools + authenticate + auth_status + icp_deploy_bundle + \ + icp_cycles_balance" + ); + for expected in [ + "get_canister_candid", + "canister_query", + "authenticate", + "auth_status", + "icp_deploy_bundle", + "icp_cycles_balance", + ] { assert!(tools.iter().any(|t| &*t.name == expected), "missing {expected}"); } @@ -465,6 +517,20 @@ mod tests { assert!(is_error, "an invalid canister id is a tool error"); assert!(text.contains("invalid canister id"), "{text}"); + // The deploy tool dispatches to its own router through the wrapper: + // signed out, it refuses before reading the bundle (the path named here + // does not exist, and must not be reached for) or touching the network. + let (is_error, text, _) = call( + "icp_deploy_bundle", + Some(serde_json::json!({ + "bundle_path": "/nonexistent/bundle.tar.gz", + "environment": "ic" + })), + ) + .await; + assert!(is_error, "a deploy without a session is a tool error"); + assert!(text.contains("authenticated session"), "{text}"); + // Deferral: a protocol/meta tool is not just unlisted — calling it // fails with the router's standard "tool not found" error (the // invalid-params shape any unknown tool name gets), rather than @@ -508,8 +574,13 @@ mod tests { assert_eq!( skills.len(), imcp2_core::skills::BUNDLED_SKILLS.len() - + imcp2_core::skills::BUNDLED_SKILL_REFERENCES.len(), - "every bundled document must be advertised: {skills:?}" + + imcp2_core::skills::BUNDLED_SKILL_REFERENCES.len() + + 1, + "every bundled document, plus this server's deploy skill, must be advertised: {skills:?}" + ); + assert!( + skills.iter().any(|u| u == "skill://deploy-with-mcp"), + "the deploy skill is advertised: {skills:?}" ); for uri in &skills { @@ -557,6 +628,12 @@ mod tests { let api_reference = one("skill://writing-motoko/references/api-reference.md").await; assert!(!api_reference.trim().is_empty()); + // This server's own skill arrives whole through the same surface, and + // names the tool it teaches — which only this server lists. + let deploy = one("skill://deploy-with-mcp").await; + assert_eq!(deploy, imcp2_deploy::DEPLOY_SKILL_MD); + assert!(deploy.contains("icp_deploy_bundle"), "{deploy:.400}"); + // An unknown skill, and an unknown companion of a real skill, are both // resource-not-found — not a panic, a disconnect, or a silent empty. for missing in ["skill://no-such-skill", "skill://writing-motoko/references/no-such.md"] {