From f0c8cf976db9373193af19ec9bc38fa8f162974d Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Tue, 3 Nov 2026 18:18:25 +0000 Subject: [PATCH 1/7] Deploy a bundle through icp-project: the pipeline and its signed calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second of three changes bringing `icp deploy` to the local MCP server. On top of the bundle host, this adds what runs a deploy: - `deploy_bundle(identity, request)`: read the bundle (a regular file by absolute path, or bytes), extract it, load the project, check the bundle whole before any phase runs, seed the id store from the caller's canister-id mapping, then `resolve_targets` and the library's `deploy` — the same entry point `icp deploy` uses, so creation, id wiring, settings, install/upgrade with the Candid check and the sync steps behave as the CLI does. The outcome carries the status, the canisters created, the resulting mapping (the id store read back, always merged with what was created, so a failed run still reports every canister it made) and the event stream rendered as a log. - `AgentCalls`: the library's `CanisterCalls` over `ic-agent`, modelled on icp-app's own implementation minus proxy routing, with certified reads via `read_state`. The agent is built by the first phase that reaches the network, as the CLI does. The tests run the real pipeline offline: a hand-built bundle is extracted, loaded, checked and stopped at the create phase with `no_create`, exactly where `icp deploy --no-create` stops. A checksum mismatch, a script step, a managed network or a testnet, a path the bundle does not hold, an unknown environment or canister, and a bundle path that is not a regular file are each refused as request errors before any phase. Co-Authored-By: Claude Fable 5.1 --- crates/imcp2-deploy/src/calls.rs | 224 ++++++++++ crates/imcp2-deploy/src/deploy.rs | 654 ++++++++++++++++++++++++++++++ crates/imcp2-deploy/src/lib.rs | 15 +- 3 files changed, 889 insertions(+), 4 deletions(-) create mode 100644 crates/imcp2-deploy/src/calls.rs create mode 100644 crates/imcp2-deploy/src/deploy.rs 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/lib.rs b/crates/imcp2-deploy/src/lib.rs index 0eb4390..162a0cb 100644 --- a/crates/imcp2-deploy/src/lib.rs +++ b/crates/imcp2-deploy/src/lib.rs @@ -11,9 +11,16 @@ //! bundle` writes, treated as untrusted input. The rule it is held to is the //! library's own, `icp_project::untrusted`, the one `icp --untrusted` //! applies (dfinity/icp-cli#873, on #868 for what `icp project bundle` -//! writes; issue #865): this crate pins icp-cli's main at their merge, -//! composes the rule as the CLI does, and adds the bounds on the archive -//! itself and the checks on the bundle's shape. The deploy pipeline over it -//! and the MCP tools that expose it follow in their own changes. +//! writes; issue #865): this crate pins icp-cli's main at +//! their merge, composes the rule as the CLI does, and adds the bounds on the +//! archive itself and the checks on the bundle's shape. [`deploy_bundle`] +//! runs the library's deploy operation over it as a given identity: create +//! the canisters the bundle names that do not exist yet, wire their ids, +//! install or upgrade, run the sync steps. The MCP tools that expose it +//! follow in their own change. +mod calls; +mod deploy; pub mod host; + +pub use deploy::{deploy_bundle, BundleSource, DeployOutcome, DeployRequest, RequestError, Status}; From b4e05a45738fda1adb6db05e5b644c2d2331c06a Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Tue, 3 Nov 2026 19:37:29 +0000 Subject: [PATCH 2/7] Test the canister-directory and network bounds end to end, and mend a split comment Two review findings on the host (#230) are now also covered through the real pipeline: a manifest that spells a canister's directory as `/etc`, with the `canister.yaml` the loader looks for carried in the archive at that spelling, and a manifest that declares a connected network claiming the mainnet root key over a loopback endpoint. Both are refused before any phase. The comment above the manifest-path test had been split in two by an insertion; it is whole again. Co-Authored-By: Claude Fable 5.1 --- crates/imcp2-deploy/src/deploy.rs | 49 +++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/crates/imcp2-deploy/src/deploy.rs b/crates/imcp2-deploy/src/deploy.rs index 9d11f95..d1916b5 100644 --- a/crates/imcp2-deploy/src/deploy.rs +++ b/crates/imcp2-deploy/src/deploy.rs @@ -546,9 +546,49 @@ mod tests { 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 canister directory the manifest spells absolutely resolves inside the + // bundle for every reader that goes through the bundle filesystem — the + // loader finds the `canister.yaml` it looks for there — but the + // sync-plugin runtime takes the directory as a host path and would make + // `/etc` its sandbox root. Refused before any phase. + #[tokio::test(flavor = "multi_thread")] + async fn a_canister_directory_outside_the_root_is_refused() { + let (wasm, sha) = wasm(); + let canister = format!( + "name: backend\nbuild:\n steps:\n - type: pre-built\n path: backend.wasm\n \ + sha256: {sha}\n" + ); + let bytes = archive(&[ + ("icp.yaml", b"canisters:\n - /etc\n"), + ("etc/canister.yaml", canister.as_bytes()), + ("etc/backend.wasm", &wasm), + ]); + let err = deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "ic")) + .await + .expect_err("a canister directory outside the root is refused"); + assert!(err.0.contains("backend") && err.0.contains("not under the bundle root"), "{err}"); + } + + // A manifest may declare a connected network that claims the mainnet root + // key over an endpoint of its own; an environment on it is refused like + // every network that is not the reserved `ic`. + #[tokio::test(flavor = "multi_thread")] + async fn a_network_claiming_mainnet_elsewhere_is_refused() { + let (wasm, sha) = wasm(); + let manifest = format!( + "networks:\n - name: mainnet2\n mode: connected\n url: http://127.0.0.1:4943\n \ + root-key: mainnet\nenvironments:\n - name: elsewhere\n network: mainnet2\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, "elsewhere")) + .await + .expect_err("a network that is not `ic` is refused"); + assert!(err.0.contains("mainnet2") && err.0.contains("not the Internet Computer"), "{err}"); + } + // 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. @@ -562,6 +602,9 @@ mod tests { assert!(err.0.contains("not a regular file"), "{}", err.0); } + // 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 // 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")] From 490e6fc31e9e9688ac3013d4d5105b54c8e1df48 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Tue, 3 Nov 2026 20:29:21 +0000 Subject: [PATCH 3/7] Test the module bound and the name-case bound end to end Two of the host bounds from #230 through the real pipeline: a gzip-compressed module that hashes correctly but expands past the module limit, and two canisters whose names differ only in letter case. Both refused before any phase. Co-Authored-By: Claude Fable 5.1 --- crates/imcp2-deploy/src/deploy.rs | 35 +++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/crates/imcp2-deploy/src/deploy.rs b/crates/imcp2-deploy/src/deploy.rs index d1916b5..22c7ea1 100644 --- a/crates/imcp2-deploy/src/deploy.rs +++ b/crates/imcp2-deploy/src/deploy.rs @@ -589,6 +589,41 @@ mod tests { assert!(err.0.contains("mainnet2") && err.0.contains("not the Internet Computer"), "{err}"); } + // A gzip-compressed module is held to what it expands to, since the + // library gunzips it in memory to read its interface: one that hashes + // correctly but expands past the module limit is refused before any + // phase. + #[tokio::test(flavor = "multi_thread")] + async fn a_gzipped_wasm_that_expands_past_the_module_limit_is_refused() { + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + encoder.write_all(&vec![0u8; host::MAX_WASM_BYTES as usize + 1]).unwrap(); + let gz = encoder.finish().unwrap(); + let sha = hex::encode(Sha256::digest(&gz)); + let err = deploy_bundle(Arc::new(AnonymousIdentity), request(bundle(&sha, &gz), "ic")) + .await + .expect_err("an over-large gzipped module is refused"); + assert!(err.0.contains("backend") && err.0.contains("expands past"), "{err}"); + } + + // Two canisters whose names differ only in case would share build + // output on a case-insensitive disk; refused before any phase. + #[tokio::test(flavor = "multi_thread")] + async fn canister_names_that_differ_only_in_case_are_refused() { + let (wasm, sha) = wasm(); + let manifest = format!( + "canisters:\n - name: backend\n build:\n steps:\n - type: pre-built\n \ + path: canisters/backend.wasm\n sha256: {sha}\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, "ic")) + .await + .expect_err("case-colliding names are refused"); + assert!(err.0.contains("differ only in letter case"), "{err}"); + } + // 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. From 3df8be8772b5822e9402ee2bf06b50abc244fa61 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Tue, 3 Nov 2026 23:03:18 +0000 Subject: [PATCH 4/7] Refuse the mode, the oversized file and the deep Candid at entry, and check off the workers Four more things a request could do to the deploy before any phase ran, each now refused at the door. The library resolves the install mode only when it installs, after the canisters exist, and panics on a spelling it does not know; a typo would create funded canisters and unwind without reporting them. The mode is checked against the four the library takes before anything is read. A bundle file was sized by its path and then read to its end, so a file replaced or grown in between was read whole. The open file is sized again and read to the limit plus one byte, which is enough to tell it outgrew the limit; the path is still checked first, so a pipe is never opened. The library parses a canister's Candid arguments at install and its module's `candid:service` interface at the upgrade check, with no depth guard and on the worker thread, where nesting past the stack aborts the process. The bundle's arguments and interfaces are held to the bound the call tools apply to caller-supplied Candid (`guard_candid_text`, now public in imcp2-core) in check_bundle, and the interface the deployed canister answers with is held to it in the calls adapter, so neither side of the comparison can be deeper than the parser can take. check_bundle reads, hashes and gunzips every module, up to a gigabyte of them, so like extraction it now runs off the runtime's workers. Co-Authored-By: Claude Fable 5.1 --- crates/imcp2-core/src/calls.rs | 2 +- crates/imcp2-core/src/lib.rs | 6 + crates/imcp2-deploy/Cargo.toml | 2 +- crates/imcp2-deploy/src/calls.rs | 28 ++++- crates/imcp2-deploy/src/deploy.rs | 189 ++++++++++++++++++++++++------ crates/imcp2-deploy/src/host.rs | 47 ++++++-- 6 files changed, 227 insertions(+), 47 deletions(-) diff --git a/crates/imcp2-core/src/calls.rs b/crates/imcp2-core/src/calls.rs index 796eaca..7776b24 100644 --- a/crates/imcp2-core/src/calls.rs +++ b/crates/imcp2-core/src/calls.rs @@ -643,7 +643,7 @@ pub(crate) fn on_deep_stack(f: impl FnOnce() -> T + Send) -> Option /// — Candid lexes `type` as a keyword, never an identifier, so it cannot appear as /// a field name (`record { type : nat }` is a syntax error); the quoted form /// `record { "type" : nat }` is a string literal, which the scan already skips. -pub(crate) fn guard_candid_text(what: &str, text: &str) -> Result<(), String> { +pub fn guard_candid_text(what: &str, text: &str) -> Result<(), String> { if text.len() > MAX_CANDID_TEXT_BYTES { return Err(format!( "{what} is too large to parse ({} bytes; limit {MAX_CANDID_TEXT_BYTES})", diff --git a/crates/imcp2-core/src/lib.rs b/crates/imcp2-core/src/lib.rs index e7e26d6..c56e2e8 100644 --- a/crates/imcp2-core/src/lib.rs +++ b/crates/imcp2-core/src/lib.rs @@ -49,6 +49,12 @@ mod management; #[cfg(all(test, feature = "e2e"))] mod e2e_canister_tools; +/// The bound on caller-supplied textual Candid (size, nesting depth, type +/// declarations) that the call tools apply before parsing, for an embedder +/// that hands Candid text from an untrusted source to a parser of its own +/// (the local server's deploy tools, with a bundle's arguments and +/// interfaces). +pub use calls::guard_candid_text; /// The IC [`Agent`] type the components are built around, re-exported so /// callers construct the injected agent from the exact `ic-agent` version this /// crate links. diff --git a/crates/imcp2-deploy/Cargo.toml b/crates/imcp2-deploy/Cargo.toml index c260a6d..fe5bfc3 100644 --- a/crates/imcp2-deploy/Cargo.toml +++ b/crates/imcp2-deploy/Cargo.toml @@ -22,7 +22,7 @@ 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"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "fs", "io-util"] } serde = { workspace = true } serde_json = { workspace = true } tracing = { workspace = true } diff --git a/crates/imcp2-deploy/src/calls.rs b/crates/imcp2-deploy/src/calls.rs index 42b560c..6e35950 100644 --- a/crates/imcp2-deploy/src/calls.rs +++ b/crates/imcp2-deploy/src/calls.rs @@ -16,6 +16,11 @@ use ic_agent::{ Agent, AgentError, }; use icp_project::calls::{Authority, Call, CallError, CanisterCalls, RouteTo}; +use imcp2_core::guard_candid_text; + +/// The metadata section the upgrade compatibility check reads from the +/// deployed canister and parses as Candid. +const CANDID_SERVICE: &str = "candid:service"; /// [`CanisterCalls`] over an `ic-agent`. pub struct AgentCalls { @@ -173,6 +178,13 @@ impl CanisterCalls for AgentCalls { builder.call().await.map_err(|e| Self::wrap(call.canister, &call.method, e)) } + /// A `candid:service` section is parsed by the library's compatibility + /// check, on the worker thread and without a depth guard, and the + /// canister it comes from is whichever the caller's mapping named: so + /// the interface is held to the same bound as the bundle's own before + /// it is handed back ([`guard_candid_text`]). The check treats a + /// failed read as "cannot tell" and skips, which is the right outcome + /// for an interface too deep to compare. async fn metadata_section( &self, canister: Principal, @@ -180,7 +192,21 @@ impl CanisterCalls for AgentCalls { // With no proxy there is one authority, and both values mean it. _authority: Authority, ) -> Result>, CallError> { - self.certified_metadata_section(canister, path).await + let section = self.certified_metadata_section(canister, path).await?; + if path == CANDID_SERVICE { + if let Some(bytes) = §ion { + let text = String::from_utf8_lossy(bytes); + guard_candid_text(&format!("the `{path}` section of {canister}"), &text).map_err( + |why| CallError::Rejected { + canister, + method: "read_state(metadata)".to_owned(), + code: None, + message: why, + }, + )?; + } + } + Ok(section) } async fn controllers(&self, canister: Principal) -> Result>, CallError> { diff --git a/crates/imcp2-deploy/src/deploy.rs b/crates/imcp2-deploy/src/deploy.rs index 22c7ea1..a2f19eb 100644 --- a/crates/imcp2-deploy/src/deploy.rs +++ b/crates/imcp2-deploy/src/deploy.rs @@ -15,7 +15,7 @@ use std::{ sync::Arc, }; -use camino::Utf8PathBuf; +use camino::{Utf8Path, Utf8PathBuf}; use candid::Principal; use ic_agent::{Agent, Identity}; use icp_canister_interfaces::engine_canister::engine_canister_id; @@ -50,6 +50,9 @@ impl fmt::Debug for BundleSource { } } +/// The install modes `icp deploy --mode` takes, as the library spells them. +pub const INSTALL_MODES: [&str; 4] = ["auto", "install", "reinstall", "upgrade"]; + /// Everything one deploy is given. #[derive(Debug)] pub struct DeployRequest { @@ -59,8 +62,10 @@ pub struct DeployRequest { /// 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. + /// One of [`INSTALL_MODES`]: `auto`, `install`, `reinstall` or `upgrade`. + /// Checked at entry: the library resolves the mode only once it installs, + /// after the canisters have been created, and it panics on any other + /// spelling rather than failing the deploy. pub mode: String, /// Cycles to fund each canister this deploy creates. pub cycles: u128, @@ -154,35 +159,16 @@ pub async fn deploy_bundle( identity: Arc, request: DeployRequest, ) -> Result { + if !INSTALL_MODES.contains(&request.mode.as_str()) { + return Err(RequestError(format!( + "mode must be one of {}, got '{}'", + INSTALL_MODES.map(|m| format!("'{m}'")).join(", "), + request.mode + ))); + } 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}")))? - } + BundleSource::Path(path) => read_bundle_file(&path, host::MAX_BUNDLE_BYTES).await?, }; // The scratch directory lives exactly as long as this function: the @@ -209,13 +195,24 @@ pub async fn deploy_bundle( // 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)?; + // canisters have been created. The check reads, hashes and gunzips every + // module, up to a gigabyte of them, so like extraction it runs off the + // runtime's workers. + { + let fs = fs.clone(); + let network = env.network.clone(); + let canisters: Vec<_> = env.canisters.values().cloned().collect(); + tokio::task::spawn_blocking(move || { + host::check_bundle( + &fs, + &network, + canisters.iter().map(|(dir, canister)| (dir.as_path(), canister)), + ) + }) + .await + .map_err(|e| RequestError(format!("bundle check panicked: {e}")))? + .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 @@ -324,6 +321,50 @@ pub async fn deploy_bundle( Ok(outcome) } +/// Read the bundle at `path`, which must be an absolute path to a regular +/// file of at most `limit` bytes, and nothing else: 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 on open), so the size check +/// means something for a regular file only. The path is checked before it +/// is opened, so a pipe is never opened; the open file is checked again, so +/// a file replaced or grown between the two is still read to the limit and +/// no further. +async fn read_bundle_file(path: &Utf8Path, limit: usize) -> Result, RequestError> { + use tokio::io::AsyncReadExt; + if !path.is_absolute() { + return Err(RequestError(format!( + "bundle_path must be an absolute path on this machine, got '{path}'" + ))); + } + let cannot = |e: std::io::Error| RequestError(format!("cannot read bundle '{path}': {e}")); + let too_large = |len: u64| { + RequestError(format!("bundle '{path}' is {len} bytes, above the {limit} byte limit")) + }; + let not_regular = || RequestError(format!("bundle '{path}' is not a regular file")); + let meta = tokio::fs::metadata(path.as_std_path()).await.map_err(cannot)?; + if !meta.is_file() { + return Err(not_regular()); + } + if meta.len() > limit as u64 { + return Err(too_large(meta.len())); + } + let file = tokio::fs::File::open(path.as_std_path()).await.map_err(cannot)?; + let meta = file.metadata().await.map_err(cannot)?; + if !meta.is_file() { + return Err(not_regular()); + } + if meta.len() > limit as u64 { + return Err(too_large(meta.len())); + } + // One byte past the limit is enough to tell that the file outgrew it. + let mut bytes = Vec::with_capacity(meta.len() as usize); + file.take(limit as u64 + 1).read_to_end(&mut bytes).await.map_err(cannot)?; + if bytes.len() > limit { + return Err(too_large(bytes.len() as u64)); + } + Ok(bytes) +} + /// 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 @@ -627,6 +668,80 @@ mod tests { // 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. + // The library resolves the mode at install, after creation, and panics on + // a spelling it does not know: refused at entry instead, before anything + // is read. + #[tokio::test(flavor = "multi_thread")] + async fn an_unknown_mode_is_refused_at_entry() { + let mut bad = request(Vec::new(), "ic"); + bad.mode = "yolo".into(); + let err = deploy_bundle(Arc::new(AnonymousIdentity), bad).await.unwrap_err(); + assert!(err.0.contains("mode must be one of"), "{}", err.0); + assert!(err.0.contains("'yolo'"), "{}", err.0); + } + + // The file is read to the limit and one byte past it, never to its end: + // a file that grows between the size check and the read is still bounded. + #[tokio::test] + async fn a_bundle_file_is_read_to_its_limit_and_no_further() { + let dir = tempfile::tempdir().unwrap(); + let path = Utf8PathBuf::from_path_buf(dir.path().join("b.tar.gz")).unwrap(); + std::fs::write(&path, [7u8; 32]).unwrap(); + assert_eq!(read_bundle_file(&path, 32).await.unwrap().len(), 32); + let err = read_bundle_file(&path, 16).await.unwrap_err(); + assert!(err.0.contains("above the 16 byte limit"), "{}", err.0); + let err = read_bundle_file(Utf8Path::new("relative/b.tar.gz"), 32).await.unwrap_err(); + assert!(err.0.contains("absolute"), "{}", err.0); + let err = read_bundle_file(&path.with_file_name("missing"), 32).await.unwrap_err(); + assert!(err.0.contains("cannot read bundle"), "{}", err.0); + } + + // The Candid a bundle carries is parsed by the library without a depth + // guard, on the worker thread: an argument or an interface nested past + // the bound is refused before any phase, not left to overflow the stack + // at install or at the upgrade check. + #[tokio::test(flavor = "multi_thread")] + async fn candid_nested_past_the_bound_is_refused_up_front() { + let deep = format!("({}null)", "opt ".repeat(200)); + let (wasm, sha) = wasm(); + let manifest = format!( + "canisters:\n - name: backend\n init_args:\n value: '{deep}'\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 deeply nested init_args value is refused"); + assert!(err.0.contains("`init_args` is nested too deeply"), "{err}"); + + let interface = format!("service : {{ f : ({}null) -> () }}", "opt ".repeat(200)); + let wasm = wat::parse_str(format!( + "(module (@custom \"icp:public candid:service\" \"{interface}\"))" + )) + .unwrap(); + let sha = hex::encode(Sha256::digest(&wasm)); + let bytes = bundle(&sha, &wasm); + let err = deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "ic")) + .await + .expect_err("a deeply nested interface is refused"); + assert!(err.0.contains("`candid:service` interface"), "{err}"); + assert!(err.0.contains("nested too deeply"), "{err}"); + + // A module with an ordinary interface deploys as before. + let wasm = wat::parse_str( + "(module (@custom \"icp:public candid:service\" \"service : { f : () -> () }\"))", + ) + .unwrap(); + let sha = hex::encode(Sha256::digest(&wasm)); + let outcome = + deploy_bundle(Arc::new(AnonymousIdentity), request(bundle(&sha, &wasm), "ic")) + .await + .unwrap(); + assert!(outcome.log.iter().any(|l| l == "Building canisters: ok"), "{:?}", outcome.log); + } + #[tokio::test(flavor = "multi_thread")] async fn a_bundle_path_that_is_not_a_regular_file_is_refused() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/imcp2-deploy/src/host.rs b/crates/imcp2-deploy/src/host.rs index f75f2cd..b8b3bcf 100644 --- a/crates/imcp2-deploy/src/host.rs +++ b/crates/imcp2-deploy/src/host.rs @@ -94,13 +94,15 @@ use icp_project::{ Access, AccessError, CollectFriendlyDomains, Configuration, NetworkAccess, NetworkUrls, RootKeySource, RootKeySpec, }, + operations::wasm::extract_candid_service, prelude::{IC, IC_MAINNET_NETWORK_API_URL, IC_ROOT_KEY}, random::HostRandom, store_artifact::ArtifactStore, store_id::AccessImpl, untrusted::{NoRecipes, Vetted}, - Canister, Lazy, Network, Project, ProjectLoadImpl, + Canister, CanisterArgs, Lazy, Network, Project, ProjectLoadImpl, }; +use imcp2_core::guard_candid_text; use sha2::{Digest, Sha256}; /// Archives above this are refused before anything is written: a bundle rides @@ -487,8 +489,19 @@ impl ScriptRunner for NoScripts { /// runtime will read of them ([`check_declared`]). /// /// The work the check does is bounded by the archive, not by the manifest: -/// a file is read, hashed and expanded once however many canisters name it -/// ([`Verified`]), and the expansions are summed ([`MAX_WASM_BYTES_IN_ALL`]). +/// a file is read, hashed, expanded and its interface guarded once however +/// many canisters name it ([`Verified`]), and the expansions are summed +/// ([`MAX_WASM_BYTES_IN_ALL`]). +/// +/// The textual Candid the bundle carries is held to what the library can +/// parse safely: its parser recurses without a depth guard, on the worker +/// thread the deploy runs on, so a canister's `init_args`/`upgrade_args` +/// value and the `candid:service` interface in its module are checked for +/// size and nesting first ([`guard_candid_text`], the bound the call tools +/// apply to caller-supplied Candid). The library parses the arguments at +/// install and the interface at the upgrade compatibility check; a value it +/// cannot parse fails there, but one that would overflow the stack aborts +/// the process, so the bound comes first. /// /// Where each canister lives is the loader's business: it finds a canister /// directory through the confined filesystem, so one outside the bundle root @@ -553,6 +566,19 @@ fn check_bundle_within<'a>( )))); }; checksum_matches(&local.path, expected, &actual).map_err(at)?; + for (field, args) in + [("init_args", &canister.init_args), ("upgrade_args", &canister.upgrade_args)] + { + // The format type is not nameable from here (the library exposes + // it through this field alone); its default is `candid`, the one + // format that is parsed rather than decoded. + if let Some(CanisterArgs::Text { content, format }) = args { + if *format == Default::default() { + guard_candid_text(&format!("`{field}`"), content) + .map_err(|e| at(Unsupported(e)))?; + } + } + } for step in &canister.sync.steps { let SyncStep::Plugin(plugin) = step else { continue }; @@ -576,9 +602,9 @@ fn check_bundle_within<'a>( /// What [`check_bundle`] has read so far, by where each file lies on disk: /// a module or plugin that several canisters name — or one canister names -/// under several spellings — is read, hashed and expanded once, so the work -/// the check does is bounded by what the archive holds, not by what the -/// manifest repeats. +/// under several spellings — is read, hashed, expanded and its interface +/// guarded once, so the work the check does is bounded by what the archive +/// holds, not by what the manifest repeats. struct Verified { /// Each file's sha256, in hex, by its on-disk path. hashes: BTreeMap, @@ -589,7 +615,9 @@ struct Verified { impl Verified { /// The sha256 of the file at `path` under `dir`, read and hashed on first /// sight — when a gzip-compressed one is also held to [`MAX_WASM_BYTES`] - /// expanded, and its expansion counted against the sum. + /// expanded, its expansion counted against the sum, and the + /// `candid:service` interface a module carries held to what the library + /// can parse. fn sha256( &mut self, fs: &BundleFs, @@ -610,6 +638,11 @@ impl Verified { ))); } self.wasm_left -= expanded; + // Bounded above, so the extractor's own gunzip is bounded too. + if let Some(interface) = extract_candid_service(&bytes) { + guard_candid_text(&format!("the `candid:service` interface of '{path}'"), &interface) + .map_err(Unsupported)?; + } let hash = hex::encode(Sha256::digest(&bytes)); self.hashes.insert(on_disk, hash.clone()); Ok(hash) From 7475edda24e1d83ded8d8b84a891207e2fc8f4a1 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Wed, 4 Nov 2026 00:41:02 +0000 Subject: [PATCH 5/7] Read the confined filesystem's refusals in the end-to-end bound tests A canister directory outside the root is now something the loader does not find, and a variable file outside it is refused by the filesystem as outside, so the two tests read those messages rather than the host's old ones. A wasm outside the root is refused earlier still, by the untrusted rule's vet at the load: it asks the bundle's filesystem where the module really is, and nothing outside the root resolves through it, so the test reads that. Co-Authored-By: Claude Fable 5.1 --- crates/imcp2-deploy/src/deploy.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/imcp2-deploy/src/deploy.rs b/crates/imcp2-deploy/src/deploy.rs index a2f19eb..2095a74 100644 --- a/crates/imcp2-deploy/src/deploy.rs +++ b/crates/imcp2-deploy/src/deploy.rs @@ -607,7 +607,9 @@ mod tests { let err = deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "ic")) .await .expect_err("a canister directory outside the root is refused"); - assert!(err.0.contains("backend") && err.0.contains("not under the bundle root"), "{err}"); + // The confined filesystem finds nothing at `/etc`, so the loader does + // not find the canister: the spelling itself is the refusal. + assert!(err.0.contains("canister manifest") && err.0.contains("'/etc'"), "{err}"); } // A manifest may declare a connected network that claims the mainnet root @@ -755,8 +757,10 @@ mod tests { // 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 - // is a wasm, whether the path is absolute or climbing. Nothing on the - // machine is reached for, whatever exists at that spelling. + // is a wasm, whether the path is absolute or climbing: the vet asks the + // bundle's filesystem whether a regular file is there, and nothing + // outside the root is. 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(); @@ -770,7 +774,8 @@ mod tests { 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}"); + assert!(err.0.contains("SECRET"), "{err}"); + assert!(err.0.contains("'/etc/hostname' is outside"), "{err}"); for path in ["/etc/hostname", "../../../../etc/hostname"] { let manifest = format!( @@ -781,7 +786,11 @@ mod tests { 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}"); + assert!( + err.0.contains("etc/hostname") + && err.0.contains("not a regular file in the project"), + "{path}: {err}" + ); } } From a057a3249ae81380ce6cd17690157bb460d4ccce Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Thu, 5 Nov 2026 22:07:48 +0000 Subject: [PATCH 6/7] Read the load's refusal of a script step in the end-to-end test The untrusted rule now holds at the load, naming the step it refuses, so the test reads that refusal, and the preflight comment no longer counts a script step among what the check catches. Co-Authored-By: Claude Fable 5.1 --- crates/imcp2-deploy/src/deploy.rs | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/crates/imcp2-deploy/src/deploy.rs b/crates/imcp2-deploy/src/deploy.rs index 2095a74..d56a422 100644 --- a/crates/imcp2-deploy/src/deploy.rs +++ b/crates/imcp2-deploy/src/deploy.rs @@ -191,10 +191,14 @@ pub async fn deploy_bundle( let host = host::host_for(fs.clone()); let selection = EnvironmentSelection::Named(request.environment.clone()); + // The load is where the untrusted rule holds (a script step, a module or + // plugin fetched from a URL or resolving outside the root, a recipe: the + // project does not load), so the environment is the first thing a bundle + // outside it fails to give. 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 + // Before any phase runs: a bundle that is not one — 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. The check reads, hashes and gunzips every // module, up to a gigabyte of them, so like extraction it runs off the // runtime's workers. @@ -573,9 +577,9 @@ mod tests { } // 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. + // the untrusted rule the project is loaded under refuses it at the load, + // as a request error naming the step, 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 \ @@ -584,7 +588,11 @@ mod tests { 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}"); + assert!( + err.0.contains("canister 'backend' has a build step that is not a pre-built module") + && err.0.contains("touch /tmp/pwned"), + "{err}" + ); } // A canister directory the manifest spells absolutely resolves inside the From 1e9a2389a890d29f119764eb6f781c511a9bbbca Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Mon, 9 Nov 2026 15:54:38 +0000 Subject: [PATCH 7/7] Read the certificate's branches offline, and load the project off the workers What a certificate proves about a metadata section is decided in one function over the tree alone, so each branch is read from a fixture with no network: a section the tree holds, a canister proven to exist without it (no module, or a module without that section), a canister proven absent, a pruned section and a pruned controllers entry, which each prove nothing and are reported as such rather than guessed at. The project load reads the manifest and every file a canister names with the filesystem's own blocking reads, up to the metered budget, and ran on the runtime's worker that took the call; it now runs on a blocking thread as extraction and the bundle check already do, and the lazy loader keeps what it loaded for every ask after. Co-Authored-By: Claude Fable 5.1 --- crates/imcp2-deploy/src/calls.rs | 207 +++++++++++++++++++++++------- crates/imcp2-deploy/src/deploy.rs | 19 ++- 2 files changed, 176 insertions(+), 50 deletions(-) diff --git a/crates/imcp2-deploy/src/calls.rs b/crates/imcp2-deploy/src/calls.rs index 6e35950..840f3d5 100644 --- a/crates/imcp2-deploy/src/calls.rs +++ b/crates/imcp2-deploy/src/calls.rs @@ -12,7 +12,7 @@ use async_trait::async_trait; use candid::Principal; use ic_agent::{ agent::{CallResponse, EffectiveId, SubnetType}, - hash_tree::{Label, LookupResult}, + hash_tree::{HashTree, Label, LookupResult}, Agent, AgentError, }; use icp_project::calls::{Authority, Call, CallError, CanisterCalls, RouteTo}; @@ -68,59 +68,20 @@ impl AgentCalls { } /// 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)`. + /// what the certificate proves — see [`certified_section`] for what that + /// is. 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 (metadata_path, controllers_path) = section_paths(canister, path); let cert = self .agent - .read_state_raw(vec![metadata_path.clone(), controllers_path.clone()], canister) + .read_state_raw(vec![metadata_path, controllers_path], 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}")), - } + .map_err(|err| Self::wrap(canister, READ_STATE_METADATA, err))?; + certified_section(canister, path, &cert.tree) } /// A subnet-scoped update: routed to a subnet rather than to any canister @@ -152,6 +113,62 @@ impl AgentCalls { } } +/// The method name a certified metadata read reports itself under. +const READ_STATE_METADATA: &str = "read_state(metadata)"; + +/// A path into the state tree, label by label. +type StatePath = Vec>>; + +/// The two state-tree paths a metadata read asks for: the section, and the +/// canister's controllers (see [`certified_section`] for why both). +fn section_paths(canister: Principal, path: &str) -> (StatePath, StatePath) { + let canister_path = || vec![Label::from("canister"), Label::from_bytes(canister.as_slice())]; + let mut metadata = canister_path(); + metadata.extend(["metadata".into(), path.into()]); + let mut controllers = canister_path(); + controllers.push("controllers".into()); + (metadata, controllers) +} + +/// What a certificate proves about section `path` of `canister`, and nothing +/// more: `Some` for a section it holds, `None` for a canister it proves has +/// no such section, and an error for a canister it proves absent or a path +/// it says nothing about. +/// +/// The section is read 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)`. +fn certified_section( + canister: Principal, + path: &str, + tree: &HashTree>, +) -> Result>, CallError> { + let (metadata_path, controllers_path) = section_paths(canister, path); + let rejected = |message: String| CallError::Rejected { + canister, + method: READ_STATE_METADATA.to_owned(), + code: None, + message, + }; + let unproven = + |about: String| Err(rejected(format!("the certificate proves nothing about {about}"))); + match tree.lookup_path(&metadata_path) { + LookupResult::Found(bytes) => Ok(Some(bytes.to_vec())), + LookupResult::Absent => match tree.lookup_path(&controllers_path) { + LookupResult::Found(_) => Ok(None), + LookupResult::Absent => Err(rejected(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}")), + } +} + #[async_trait] impl CanisterCalls for AgentCalls { fn caller(&self) -> Principal { @@ -248,3 +265,101 @@ impl CanisterCalls for AgentCalls { Ok(matches!(info.subnet_type(), Some(SubnetType::CloudEngine))) } } + +#[cfg(test)] +mod tests { + use super::*; + use ic_agent::hash_tree::{fork, label, leaf, pruned}; + + const PATH: &str = "candid:service"; + + fn canister() -> Principal { + Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai").unwrap() + } + + /// A state tree holding `node` under this canister's entry. + fn under_canister(node: HashTree>) -> HashTree> { + label("canister", label(Label::from_bytes(canister().as_slice()), node)) + } + + /// The `controllers` entry creation writes; its bytes are not read here. + fn controllers() -> HashTree> { + label("controllers", leaf(b"\x81\x41\x01".to_vec())) + } + + fn rejection(result: Result>, CallError>) -> String { + match result { + Err(CallError::Rejected { message, .. }) => message, + other => panic!("expected a rejection, got {other:?}"), + } + } + + // A section the certificate holds is what the check parses. + #[test] + fn a_section_the_certificate_holds_is_returned() { + let tree = under_canister(fork( + controllers(), + label("metadata", label(PATH, leaf(b"service : {}".to_vec()))), + )); + assert_eq!( + certified_section(canister(), PATH, &tree).unwrap(), + Some(b"service : {}".to_vec()) + ); + } + + // A canister that exists — its controllers are proven — with no such + // section: none installed yet, or a module without one. Either reads as + // "no section", not as "no canister". + #[test] + fn a_canister_proven_to_exist_without_the_section_has_none() { + let no_module = under_canister(controllers()); + assert_eq!(certified_section(canister(), PATH, &no_module).unwrap(), None); + let other_section = under_canister(fork( + controllers(), + label("metadata", label("other", leaf(b"x".to_vec()))), + )); + assert_eq!(certified_section(canister(), PATH, &other_section).unwrap(), None); + } + + // The tree proves another canister's entry and no entry for this one: + // the section is absent because the canister is. + #[test] + fn a_canister_the_certificate_proves_absent_does_not_exist() { + let other = Principal::from_text("ryjl3-tyaaa-aaaaa-aaaba-cai").unwrap(); + let tree = label("canister", label(Label::from_bytes(other.as_slice()), controllers())); + let message = rejection(certified_section(canister(), PATH, &tree)); + assert_eq!(message, format!("canister {} does not exist", canister())); + } + + // A pruned subtree where the section would be proves neither presence + // nor absence — a private section looks exactly like this — so no + // answer is given off it. + #[test] + fn a_pruned_section_proves_nothing() { + let tree = under_canister(fork(controllers(), pruned([0u8; 32]))); + let message = rejection(certified_section(canister(), PATH, &tree)); + assert_eq!( + message, + format!( + "the certificate proves nothing about section `{PATH}` of canister {}", + canister() + ) + ); + } + + // The section is proven absent but the controllers are pruned: whether + // the canister exists is what the certificate does not say, so neither + // "no section" nor "no canister" is reported. + #[test] + fn a_pruned_controllers_entry_proves_nothing_about_the_canister() { + let tree = under_canister(fork( + pruned([0u8; 32]), + label("metadata", label("other", leaf(b"x".to_vec()))), + )); + let message = rejection(certified_section(canister(), PATH, &tree)); + assert_eq!( + message, + format!("the certificate proves nothing about canister {}", canister()) + ); + } +} diff --git a/crates/imcp2-deploy/src/deploy.rs b/crates/imcp2-deploy/src/deploy.rs index d56a422..723f12a 100644 --- a/crates/imcp2-deploy/src/deploy.rs +++ b/crates/imcp2-deploy/src/deploy.rs @@ -190,11 +190,22 @@ pub async fn deploy_bundle( }; let host = host::host_for(fs.clone()); - let selection = EnvironmentSelection::Named(request.environment.clone()); // The load is where the untrusted rule holds (a script step, a module or - // plugin fetched from a URL or resolving outside the root, a recipe: the - // project does not load), so the environment is the first thing a bundle - // outside it fails to give. + // plugin fetched from a URL or not a regular file under the root, a + // recipe: the project does not load), so it is the first thing a bundle outside the + // rule fails. It reads the manifest and, through the metered view, every + // file a canister names, with the filesystem's own blocking reads: like + // extraction it runs off the runtime's workers, and the loader keeps + // what it loaded for every later ask. + { + let project = host.project.clone(); + let handle = tokio::runtime::Handle::current(); + tokio::task::spawn_blocking(move || handle.block_on(project.load())) + .await + .map_err(|e| RequestError(format!("project load panicked: {e}")))? + .map_err(|e| RequestError(error_chain(&e)))?; + } + 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 wasm that is // missing or does not hash to its checksum, a network other than the IC