diff --git a/Cargo.lock b/Cargo.lock index 1ddebafb0..6286d670f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3811,6 +3811,7 @@ dependencies = [ "console 0.16.3", "hex", "ic-agent", + "ic-management-canister-types 0.9.0", "icp-canister-interfaces", "icp-events", "semver", diff --git a/crates/icp-cli/tests/sync_tests.rs b/crates/icp-cli/tests/sync_tests.rs index 1f3cb0104..7b8c9a346 100644 --- a/crates/icp-cli/tests/sync_tests.rs +++ b/crates/icp-cli/tests/sync_tests.rs @@ -442,11 +442,18 @@ async fn sync_plugin_registers_seed_data() { clients::icp(&ctx, &project_dir, Some("random-environment".to_string())) .mint_cycles(10 * TRILLION); + // The plugin also reads the canister's candid:service metadata section and + // reports it. No proxy is configured here, so the read is a direct + // read_state; this manifest builds the wasm with a plain `cp`, skipping the + // example's ic-wasm step, so the section genuinely isn't there — proving the + // host performed the round-trip and mapped a proven-absent section to `none` + // rather than to an error. ctx.icp() .current_dir(&project_dir) .args(["deploy", "--environment", "random-environment"]) .assert() - .success(); + .success() + .stderr(contains("candid:service: absent")); // Query the canister to verify all three fruits were registered ctx.icp() @@ -999,6 +1006,12 @@ async fn sync_plugin_routes_through_proxy() { // Deploy through proxy so the proxy canister becomes a controller of my-canister. // deploy also runs the sync step: the plugin routes set_uploader through the proxy // (direct: false, proxy is controller), then calls register directly with the user identity. + // + // Its metadata read is proxied too, so it reaches the canister as the + // management canister's `canister_metadata` rather than as a read_state. + // This manifest skips the example's ic-wasm step, so the section really is + // missing — and the host must report the resulting rejection as an absent + // section, the same answer a direct read proves from the certificate. ctx.icp() .current_dir(&project_dir) .args([ @@ -1009,7 +1022,8 @@ async fn sync_plugin_routes_through_proxy() { "random-environment", ]) .assert() - .success(); + .success() + .stderr(contains("candid:service: absent")); // Query the canister to verify all three fruits were registered ctx.icp() diff --git a/crates/icp-sync-plugin/Cargo.toml b/crates/icp-sync-plugin/Cargo.toml index 316a5df33..117086abf 100644 --- a/crates/icp-sync-plugin/Cargo.toml +++ b/crates/icp-sync-plugin/Cargo.toml @@ -14,6 +14,7 @@ candid.workspace = true console.workspace = true hex.workspace = true ic-agent.workspace = true +ic-management-canister-types.workspace = true icp-canister-interfaces.workspace = true icp-events.workspace = true semver.workspace = true diff --git a/crates/icp-sync-plugin/DESIGN.md b/crates/icp-sync-plugin/DESIGN.md index ddfd4c117..ea22acb86 100644 --- a/crates/icp-sync-plugin/DESIGN.md +++ b/crates/icp-sync-plugin/DESIGN.md @@ -36,6 +36,13 @@ docs; the *reasons* behind those choices are recorded here. one deployment. (In the earlier `@0.1.0` interface `canister-call` had no target and always reached the canister being synced; see *Interface versioning* below.) +- **`canister-metadata-section` mirrors `canister-call`'s targeting and routing** — it + takes the same `call-target` (enforced against `canisters:` the same way) and + the same `direct` flag, so one mental model covers both imports. Its return is + `result>, string>`: a missing section is an ordinary answer for + a plugin probing for an optional section, not a failure it must recognize by + parsing error text. The host pays for that guarantee on the proxied path — see + *Metadata reads* below. - **`sync-exec-input` carries the canister ID table** — `canister-ids` exposes the project's name→principal map for the environment, so a plugin can resolve canister names it knows about. It is informational only; calling still @@ -71,7 +78,8 @@ crates/icp-sync-plugin/ path.rs — declared-path resolution and safety checks (project bound, symlinks) sync-plugin.wit — current WIT interface, v0.2.0 sync-plugin-v1.wit — frozen WIT interface, v0.1.0 - Cargo.toml — wasmtime, wasmtime-wasi, ic-agent, candid, camino, snafu, tokio, semver + Cargo.toml — wasmtime, wasmtime-wasi, ic-agent, ic-management-canister-types, + candid, camino, snafu, tokio, semver ``` Public function: @@ -186,8 +194,8 @@ struct HostState { ``` `HostState` implements `WasiView` so wasmtime_wasi can access the WASI context. -`canister_call` uses `tokio::runtime::Handle::current().block_on(...)` because -the caller already wraps the synchronous `run_plugin` in +Both imports use `tokio::runtime::Handle::current().block_on(...)` because the +caller already wraps the synchronous `run_plugin` in `tokio::task::block_in_place`. For a v0.2.0 plugin the target is resolved from the request's `call-target` by `resolve_call_target`, which enforces the `callable` set; for a v0.1.0 plugin the target is always `host_canister_id`. @@ -195,6 +203,30 @@ When a proxy is configured and the call is a non-`direct` update, it is encoded as `ProxyArgs` and routed through the proxy's `proxy` method; otherwise it goes straight to the resolved target via `ic-agent`. +### Metadata reads (two routes, one answer) + +`canister-metadata-section` cannot reuse the call path: `read_state` is not a canister +method, so a proxy canister has nothing to forward. The two routes are therefore +different protocols reaching the same data, chosen by the request's `direct` flag +exactly as `canister-call` chooses one: + +- **Direct** — a `read_state` signed by the sync identity, so absence is + *proven* by the certificate rather than asserted. It requests `controllers` + alongside the metadata path, since only that distinguishes a canister with no + such section from one that was never created. +- **Proxied** — `ProxyArgs` aimed at the management canister's + `canister_metadata`, so the controller check runs against the proxy. This is + the same shape the CLI's own management calls take through + `update_or_proxy_raw`; the runtime inlines it rather than depending on the CLI. + +Only a certificate can make a read `none`. The management canister answers a +section that isn't there and one private to someone else with the same +rejection, so the proxied route treats that rejection as a claim to check rather +than an answer, and confirms it with a certified read before reporting absence. +A plugin then sees one answer either way: no section by that name and no module +installed at all are `none`; a private section it may not have, a canister that +does not exist, and any other failure are errors. + ### Interface versioning (parallel v0.1.0 / v0.2.0 support) A component built with wit-bindgen imports the interface it `use`s as a @@ -222,10 +254,11 @@ key or an unmentioned directory would otherwise look like a plugin bug. The compute-time limit is enforced with wasmtime's epoch interruption: a background thread calls `Engine::increment_epoch` once per second, and the store -deadline (`set_epoch_deadline`) bounds pure wasm execution. Because canister -calls block the guest while the host awaits the network, `canister_call` records -the elapsed time and the `epoch_deadline_callback` grants it back via -`epoch_extension` — so network latency is *not* charged against the limit. The +deadline (`set_epoch_deadline`) bounds pure wasm execution. Because a host +call blocks the guest while the host awaits the network, both imports record the +elapsed time (`refund_host_call_time`) and the `epoch_deadline_callback` grants +it back via `epoch_extension` — so network latency is *not* charged against the +limit. The ticker thread stops when its RAII guard drops at the end of `run_plugin`. The deadline in seconds is the `compute_limit_secs` parameter. The CLI resolves diff --git a/crates/icp-sync-plugin/src/runtime.rs b/crates/icp-sync-plugin/src/runtime.rs index b37903c16..9d07f9a99 100644 --- a/crates/icp-sync-plugin/src/runtime.rs +++ b/crates/icp-sync-plugin/src/runtime.rs @@ -25,6 +25,9 @@ use bytes::Bytes; use camino::{Utf8Path, Utf8PathBuf}; use candid::{Encode, Principal}; use ic_agent::Agent; +use ic_agent::hash_tree::{Label, LookupResult}; +use ic_management_canister_types::{CanisterMetadataArgs, CanisterMetadataResult}; +use icp_canister_interfaces::proxy::{ProxyArgs, ProxyResult}; use semver::{Version, VersionReq}; use snafu::prelude::*; // Aliased because wasmtime-wasi also has an `OutputStream` (imported below). @@ -101,6 +104,87 @@ pub struct CallableCanisters { pub by_name: BTreeMap, } +/// What a certificate says about a metadata section. A section the reader may +/// not have is neither of these: the state tree will not certify it, so it +/// reaches the caller as an error like any other failed read. +enum CertifiedSection { + Present(Vec), + Absent, +} + +/// 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 [`CertifiedSection::Absent`]. +async fn certified_metadata_section( + agent: &Agent, + target: Principal, + name: &str, +) -> Result { + let metadata_path: Vec>> = vec![ + "canister".into(), + Label::from_bytes(target.as_slice()), + "metadata".into(), + name.into(), + ]; + let controllers_path: Vec>> = vec![ + "canister".into(), + Label::from_bytes(target.as_slice()), + "controllers".into(), + ]; + let cert = agent + .read_state_raw( + vec![metadata_path.clone(), controllers_path.clone()], + target, + ) + .await + .map_err(|err| format!("metadata read failed: {err}"))?; + + match cert.tree.lookup_path(&metadata_path) { + LookupResult::Found(bytes) => Ok(CertifiedSection::Present(bytes.to_vec())), + LookupResult::Absent => match cert.tree.lookup_path(&controllers_path) { + LookupResult::Found(_) => Ok(CertifiedSection::Absent), + LookupResult::Absent => Err(format!("canister {target} does not exist")), + _ => Err(format!( + "metadata read failed: certificate proves nothing about canister {target}" + )), + }, + // Not proof of absence, just a certificate that says nothing about the + // path — reporting the section missing off this would be a guess. + _ => Err(format!( + "metadata read failed: certificate proves nothing about section `{name}` \ + of canister {target}" + )), + } +} + +/// Whether the management canister rejected a metadata read by claiming the +/// target has no such section, rather than because the read itself failed. +/// +/// The claim is not proof: the same rejection covers a section private to +/// someone other than the proxy, so the caller confirms it against a +/// certificate. A proxied read reaches the plugin as reject text with no code +/// attached, so recognizing the claim at all means matching the replica's +/// wording. Both sentences name the canister and one names the section, so the +/// match is anchored on the values this call supplied rather than on a loose +/// phrase that text relayed from elsewhere might happen to contain. A reword +/// upstream turns the claim into an error rather than into a wrong answer. +fn rejected_as_no_such_section(message: &str, target: Principal, name: &str) -> bool { + // A canister with no module installed has no sections at all, so it reports + // absence in its own words. The certificate says the same thing about it: + // the metadata path is absent while the canister itself is there. + message.contains(&format!( + "The canister {target} has no Wasm module and hence no metadata is available." + )) || message.contains(&format!( + "The canister {target} has no metadata section with the name {name}." + )) +} + /// Resolve a plugin-supplied [`CallTarget`] to a concrete principal, enforcing /// that the plugin listed it in `canisters`. The canister being synced (`host`) /// is always permitted. @@ -127,7 +211,8 @@ struct HostState { /// Canisters the plugin declared in `canisters` and may also call. callable: CallableCanisters, agent: Arc, - /// Proxy canister to route update calls through, if configured. + /// Proxy canister to route update calls and metadata reads through, if + /// configured. proxy: Option, // WASI context. Preopened directories in this context are the only // filesystem locations the plugin can access. @@ -162,8 +247,6 @@ impl HostState { direct: bool, cycles: u64, ) -> Result, String> { - use icp_canister_interfaces::proxy::{ProxyArgs, ProxyResult}; - let agent = Arc::clone(&self.agent); let proxy = if direct { None } else { self.proxy }; @@ -209,12 +292,98 @@ impl HostState { .map_err(|e| format!("canister call failed: {e}")), } }); - // Return the time spent in the host call to the compute budget so - // canister network latency doesn't count against the plugin's limit. + self.refund_host_call_time(start); + result + } + + /// Read a metadata section from an already-resolved target principal. + /// `Ok(None)` means a certificate proved the target has no such section, + /// kept distinct from a failed read so a plugin can probe for an optional + /// section without inspecting error text. A section the reader may not have + /// is a failed read, not an absent one, whichever route asked. + /// + /// A direct read is a certified `read_state` signed by the sync identity — + /// `read_state` is not a canister method, so it cannot be forwarded. A + /// proxied read therefore goes the other way around: the proxy calls the + /// management canister's `canister_metadata` on the plugin's behalf, which + /// checks the *proxy* against the target's controllers and so reaches + /// sections private to it. The management canister does not distinguish + /// absence from privacy, so a proxied read that comes back claiming absence + /// is confirmed against a certificate before it is reported as one. + fn do_canister_metadata_section( + &mut self, + target: Principal, + name: String, + direct: bool, + ) -> Result>, String> { + let agent = Arc::clone(&self.agent); + let proxy = if direct { None } else { self.proxy }; + + let start = Instant::now(); + let result = tokio::runtime::Handle::current().block_on(async move { + let Some(proxy_cid) = proxy else { + return certified_metadata_section(&agent, target, &name) + .await + .map(|section| match section { + CertifiedSection::Present(bytes) => Some(bytes), + CertifiedSection::Absent => None, + }); + }; + + let metadata_args = Encode!(&CanisterMetadataArgs { + canister_id: target, + name: name.clone(), + }) + .map_err(|e| format!("metadata encode failed: {e}"))?; + let proxy_args = ProxyArgs { + canister_id: Principal::management_canister(), + method: "canister_metadata".to_string(), + args: metadata_args, + cycles: candid::Nat::from(0u8), + }; + let encoded = Encode!(&proxy_args).map_err(|e| format!("proxy encode failed: {e}"))?; + let raw = agent + .update(&proxy_cid, "proxy") + .with_arg(encoded) + .await + .map_err(|e| format!("proxy call failed: {e}"))?; + let (result,): (ProxyResult,) = + candid::decode_args(&raw).map_err(|e| format!("proxy decode failed: {e}"))?; + match result { + ProxyResult::Ok(ok) => { + let (metadata,): (CanisterMetadataResult,) = candid::decode_args(&ok.result) + .map_err(|e| format!("metadata decode failed: {e}"))?; + Ok(Some(metadata.value)) + } + ProxyResult::Err(err) => { + let message = err.format_error(); + if !rejected_as_no_such_section(&message, target, &name) { + return Err(format!("metadata read failed: {message}")); + } + // The management canister says the same thing about a + // section that isn't there and one that is private to + // someone else, so its word alone cannot be reported as + // absence. Only a certificate proves the section absent. + match certified_metadata_section(&agent, target, &name).await? { + CertifiedSection::Absent => Ok(None), + CertifiedSection::Present(_) => Err(format!( + "metadata read failed: canister {target} does not let the proxy \ + read section `{name}`" + )), + } + } + } + }); + self.refund_host_call_time(start); + result + } + + /// Return the wall-clock time a host call spent off-wasm to the compute + /// budget, so network latency doesn't count against the plugin's limit. + fn refund_host_call_time(&self, start: Instant) { let elapsed_ticks = start.elapsed().as_secs() + 1; self.epoch_extension .fetch_add(elapsed_ticks, Ordering::Relaxed); - result } } @@ -238,6 +407,14 @@ impl v2::SyncPluginImports for HostState { req.cycles, ) } + + fn canister_metadata_section( + &mut self, + req: v2::icp::sync_plugin::types::MetadataSectionRequest, + ) -> Result>, String> { + let target = resolve_call_target(&req.target, self.host_canister_id, &self.callable)?; + self.do_canister_metadata_section(target, req.name, req.direct) + } } // -- v0.1.0 interface: calls always go to the canister being synced. ----------- @@ -572,7 +749,8 @@ pub struct PluginInvocation { pub host_canister_id: Principal, /// Agent used for canister calls. pub agent: Agent, - /// Proxy canister to route update calls through, if configured. + /// Proxy canister to route update calls and metadata reads through, if + /// configured. pub proxy: Option, /// Signing identity principal, surfaced to the plugin. pub identity_principal: Principal, @@ -1459,6 +1637,65 @@ mod tests { )); } + /// A metadata read names its target the same way a call does, and the host + /// enforces the `canisters` list before going to the network — so an + /// undeclared target is refused without a live canister to read from. + #[test] + fn metadata_read_of_undeclared_canister_is_rejected() { + let Some(wasm_path) = option_env!("TEST_PLUGIN_WASM") else { + return; + }; + let lines = run_plugin(invocation(wasm_path, "metadata-undeclared")) + .expect("plugin should succeed"); + let [refusal] = &lines[..] else { + panic!("expected one refusal line, got: {lines:?}"); + }; + assert!( + refusal.contains("not permitted") && refusal.contains("undeclared"), + "got: {refusal}" + ); + } + + /// The replica's own wording for the two ways a target reports it has no + /// section, copied from `CanisterManagerError` in the IC repo. Both are + /// absence, not failure, so both must reach the plugin as `none`. + #[test] + fn management_canister_absence_rejects_are_recognized() { + let target = Principal::from_text("aaaaa-aa").unwrap(); + let other = Principal::from_text("2vxsx-fae").unwrap(); + + let no_module = format!( + "Proxy call failed: The canister {target} has no Wasm module and hence no metadata is available." + ); + let no_section = format!( + "Proxy call failed: The canister {target} has no metadata section with the name candid:service." + ); + assert!(rejected_as_no_such_section( + &no_module, + target, + "candid:service" + )); + assert!(rejected_as_no_such_section( + &no_section, + target, + "candid:service" + )); + + // A section by another name, a canister other than the one asked about, + // and an unrelated failure are all reads that failed. + assert!(!rejected_as_no_such_section(&no_section, target, "dfx")); + assert!(!rejected_as_no_such_section( + &no_module, + other, + "candid:service" + )); + assert!(!rejected_as_no_such_section( + &format!("Proxy call failed: Canister {target} not found."), + target, + "candid:service" + )); + } + #[test] fn plugin_exceeding_compute_limit_is_trapped() { let Some(wasm_path) = option_env!("TEST_PLUGIN_WASM") else { diff --git a/crates/icp-sync-plugin/sync-plugin.wit b/crates/icp-sync-plugin/sync-plugin.wit index 0a161493c..66e4df491 100644 --- a/crates/icp-sync-plugin/sync-plugin.wit +++ b/crates/icp-sync-plugin/sync-plugin.wit @@ -51,11 +51,19 @@ interface types { /// defined directly in the app root has no subproject prefix and appears /// as its bare local name, e.g. "backend". /// - /// Every canister in the same subproject as the canister being synced is - /// additionally listed under its bare local name (a duplicate entry with - /// the same `id`), so a plugin can address a sibling by the same local - /// name the manifest uses. A bare name always means the sibling, so an - /// app-root canister sharing that local name is not listed. + /// Every canister the subproject being synced can name for itself is + /// additionally listed under that name (a duplicate entry with the same + /// `id`): its own canisters under their bare local names, and the + /// canisters of subprojects nested below it under the + /// `subproject:local` key those would have if it were the app root — + /// e.g. for "services/crm:backend", the canister + /// "services/crm/vendor/ledger:ledger" is also listed as + /// "vendor/ledger:ledger". These are the keys the subproject's own + /// manifest uses, so a plugin addresses the same canister by the same + /// name whether the subproject is deployed standalone or vendored into + /// a workspace. Such a name always means what the subproject means by + /// it, so a canister elsewhere in the workspace whose key is spelled + /// the same way is not listed under it. name: string, /// Textual principal the name resolves to for this environment. id: string, @@ -71,10 +79,10 @@ interface types { /// permitted, whether or not it also appears in `canisters`. host, /// A canister from the `canisters` list, identified by name, spelled - /// exactly as it appears in `sync-exec-input.canister-ids` — a bare - /// local name for a canister in the same subproject, or a - /// `subproject:local` key otherwise. The host resolves it against that - /// mapping table. + /// exactly as it appears in `sync-exec-input.canister-ids` — for a + /// canister the synced canister's own subproject names, the name that + /// subproject uses; otherwise the app-root-relative `subproject:local` + /// key. The host resolves it against that mapping table. name(string), } @@ -136,11 +144,32 @@ interface types { /// for query calls. cycles: u64, } + + /// A request to read a canister's metadata section. + record metadata-section-request { + /// Which canister to read from. The same rule as + /// `canister-call-request.target` applies: `host` is always permitted, + /// a `name` must appear in the sync step's `canisters` list. + target: call-target, + /// Name of the metadata section, as spelled in the wasm module's custom + /// section minus the `icp:public `/`icp:private ` prefix — e.g. + /// `candid:service`. + name: string, + /// When true, the section is read straight from the target canister + /// with a certified `read_state` request signed by the sync identity, + /// which reaches a private section only if that identity controls the + /// target. When false (the default), the read is routed through the + /// proxy canister configured via `--proxy` — as a call to the + /// management canister's `canister_metadata` method, so a private + /// section gated on the proxy's control is readable. With no proxy + /// configured the read goes directly either way. + direct: bool, + } } /// The complete interface of a sync plugin. world sync-plugin { - use types.{sync-exec-input, canister-call-request, call-target, canister-id-entry, dir-input, file-input, field-input}; + use types.{sync-exec-input, canister-call-request, metadata-section-request, call-target, canister-id-entry, dir-input, file-input, field-input}; // ------------------------------------------------------------------------- // Host functions (imports) — provided by icp-cli, called by the plugin @@ -154,6 +183,21 @@ world sync-plugin { /// message on failure. The plugin is responsible for decoding. import canister-call: func(req: canister-call-request) -> result, string>; + /// Read a metadata section from a canister. + /// The `req.target` selects the canister under the same rule as + /// `canister-call`: the canister being synced (`host`), or one listed in + /// the sync step's `canisters` list, by name. + /// A direct read is a certified `read_state` request signed by the sync + /// identity; a proxied read (`direct` false, with `--proxy` configured) is + /// a call to the management canister's `canister_metadata` method made by + /// the proxy, which reaches sections private to the proxy's control. + /// Returns the section's raw bytes on success, or `none` when the target + /// provably has no section by that name — including when it has no module + /// installed at all, and so no sections. A section the reader may not have + /// is an error, as is a canister that does not exist or any other failed + /// read. The plugin is responsible for interpreting the bytes. + import canister-metadata-section: func(req: metadata-section-request) -> result>, string>; + // The plugin's stdout is captured and shown as transient progress in // the rolling step view of icp-cli; it is discarded when the step ends. // diff --git a/crates/icp-sync-plugin/tests/fixtures/test-plugin/src/lib.rs b/crates/icp-sync-plugin/tests/fixtures/test-plugin/src/lib.rs index eeb169744..ca6ec1522 100644 --- a/crates/icp-sync-plugin/tests/fixtures/test-plugin/src/lib.rs +++ b/crates/icp-sync-plugin/tests/fixtures/test-plugin/src/lib.rs @@ -64,6 +64,19 @@ impl Guest for TestPlugin { } Ok(()) } + // Ask for a metadata section from a canister the step did not + // declare. The host must reject the target before it touches the + // network, so this needs no live canister; echo the refusal. + "metadata-undeclared" => { + let err = canister_metadata_section(&MetadataSectionRequest { + target: CallTarget::Name("undeclared".to_string()), + name: "candid:service".to_string(), + direct: true, + }) + .expect_err("host must reject an undeclared target"); + eprintln!("{err}"); + Ok(()) + } "spin" => { // Busy-loop forever to exercise the host's compute-time limit. // The epoch-interruption check at the loop back-edge traps this, diff --git a/crates/icp/src/canister/sync/plugin.rs b/crates/icp/src/canister/sync/plugin.rs index d3745a865..1ff3fc214 100644 --- a/crates/icp/src/canister/sync/plugin.rs +++ b/crates/icp/src/canister/sync/plugin.rs @@ -158,31 +158,60 @@ pub(super) async fn sync( } /// The canister ID table exposed to a sync plugin: every named canister in the -/// project, plus — for canisters in the same subproject as the one being synced -/// — a duplicate entry under the bare local name. A store key is -/// `:` for a canister in a subproject and a bare local name -/// for a canister defined directly in the app root (see the WIT -/// `canister-id-entry` docs), so the syncing canister's namespace is the prefix -/// of its own key. +/// project, plus — for every canister in the subproject the synced canister +/// belongs to, or in a subproject nested below it — a duplicate entry under the +/// name that subproject itself uses. A store key is `:` for a +/// canister in a subproject and a bare local name for a canister defined +/// directly in the app root (see the WIT `canister-id-entry` docs), so the +/// syncing canister's namespace is the prefix of its own key. /// -/// A local name never contains a colon but a subproject directory may, so keys -/// split on their *last* colon. The bare-name aliases take precedence over an -/// app-root canister of the same local name: a plugin resolving a bare name is -/// naming what the syncing canister's own manifest calls it. +/// The aliases exist so a subproject's manifest and plugins keep working when it +/// is vendored into a workspace: both the step's `canisters:` list and the name a +/// plugin passes back as a call target are written where the subproject's own +/// names apply, but store keys are relative to the app root, which moves. See +/// [`member_relative_alias`] for the names produced. +/// +/// The aliases take precedence over a canister elsewhere in the workspace whose +/// store key happens to be spelled the same way: a plugin resolving such a name +/// is naming what its own subproject calls it. fn exposed_canister_ids(params: &Params) -> BTreeMap { - let syncing_namespace = params.name.rsplit_once(':').map(|(namespace, _)| namespace); + // A canister in the app root is in the subproject the store keys are already + // relative to, so its names need no translation. + let Some((syncing_namespace, _)) = params.name.rsplit_once(':') else { + return params.canister_ids.clone(); + }; let mut table = params.canister_ids.clone(); for (key, id) in ¶ms.canister_ids { - if let Some((namespace, local)) = key.rsplit_once(':') - && Some(namespace) == syncing_namespace - { - table.insert(local.to_owned(), *id); + if let Some(alias) = member_relative_alias(syncing_namespace, key) { + table.insert(alias.to_owned(), *id); } } table } +/// The name a canister has *within* the subproject at `namespace`: its store key +/// with that subproject's prefix removed. A canister of the subproject itself +/// comes back under its bare local name; one belonging to a subproject nested +/// below it comes back under the `:` key it would have if that +/// subproject were the app root. `None` for a canister the subproject has no name +/// of its own for. +/// +/// A local name never contains a colon but a subproject directory may, so a key +/// splits on its *last* colon. +fn member_relative_alias<'a>(namespace: &str, key: &'a str) -> Option<&'a str> { + let (key_namespace, _) = key.rsplit_once(':')?; + let rest = key.strip_prefix(namespace)?; + match key_namespace == namespace { + // The colon separating the subproject from a local name of its own. + true => rest.strip_prefix(':'), + // Otherwise the key's subproject must sit *below* this one. Demanding + // the path separator is what keeps `services/crm-legacy:backend` out of + // `services/crm`, which it merely shares a spelling prefix with. + false => rest.strip_prefix('/'), + } +} + /// Resolve the step's `canisters` list into a [`CallableCanisters`] enforcement /// set. Each listed name is looked up in `canister_ids`; a name that does not /// resolve is a manifest error. @@ -293,6 +322,84 @@ mod tests { assert_eq!(table.get("backend"), Some(&backend)); } + /// A canister of a subproject nested below the syncing canister's own is + /// exposed under the key that subproject has relative to it — the very key + /// its manifest and plugins use when it is built standalone, so vendoring it + /// into a workspace leaves both spellings working. + #[test] + fn exposed_ids_add_member_relative_names_for_nested_subprojects() { + let ledger = principal(1); + let deep = principal(2); + let params = params_named( + "services/crm:backend", + &[ + ("services/crm:backend", principal(9)), + ("services/crm/vendor/ledger:ledger", ledger), + ("services/crm/vendor/ledger/vendor/util:util", deep), + ], + ); + + let table = exposed_canister_ids(¶ms); + + assert_eq!(table.get("vendor/ledger:ledger"), Some(&ledger)); + // Nesting is not limited to one level: the whole subtree below the + // syncing canister's subproject is renamed relative to it. + assert_eq!(table.get("vendor/ledger/vendor/util:util"), Some(&deep)); + // The workspace-absolute keys remain. + assert_eq!( + table.get("services/crm/vendor/ledger:ledger"), + Some(&ledger) + ); + } + + /// A subproject whose path merely starts with the same characters is not + /// nested below the syncing canister's, so it contributes no alias. + #[test] + fn exposed_ids_ignore_a_subproject_sharing_a_spelling_prefix() { + let legacy = principal(1); + let params = params_named( + "services/crm:backend", + &[ + ("services/crm:backend", principal(9)), + ("services/crm-legacy:backend", legacy), + ], + ); + + let table = exposed_canister_ids(¶ms); + + assert_eq!(table.get("services/crm-legacy:backend"), Some(&legacy)); + // Its local name belongs to the syncing canister, not to it. + assert_eq!(table.get("backend"), Some(&principal(9))); + assert_eq!(table.get("-legacy:backend"), None); + } + + /// A member-relative alias wins over a workspace canister whose store key is + /// spelled the same way, for the same reason a bare sibling name does: the + /// name is being read where the subproject's own names apply. + #[test] + fn exposed_ids_member_relative_alias_overrides_a_root_dependency_key() { + let root_ledger = principal(1); + let own_ledger = principal(2); + let params = params_named( + "services/crm:backend", + &[ + ("services/crm:backend", principal(9)), + ("services/crm/vendor/ledger:ledger", own_ledger), + ("vendor/ledger:ledger", root_ledger), + ], + ); + + let table = exposed_canister_ids(¶ms); + + assert_eq!(table.get("vendor/ledger:ledger"), Some(&own_ledger)); + // The root's own dependency is still reachable, by its store key. + assert_eq!( + table.get("services/crm/vendor/ledger:ledger"), + Some(&own_ledger) + ); + assert!(!table.values().any(|id| *id == root_ledger)); + } + /// An app-root canister sharing a local name with a sibling of the syncing /// canister does not keep the bare name: the syncing subproject's own /// canister is what that name means to the plugin. @@ -323,11 +430,13 @@ mod tests { fn exposed_ids_split_subproject_prefix_at_the_last_colon() { let backend = principal(1); let frontend = principal(2); + let nested = principal(3); let params = params_named( "services/odd:name:backend", &[ ("services/odd:name:backend", backend), ("services/odd:name:frontend", frontend), + ("services/odd:name/vendor/ledger:ledger", nested), ], ); @@ -335,6 +444,27 @@ mod tests { assert_eq!(table.get("backend"), Some(&backend)); assert_eq!(table.get("frontend"), Some(&frontend)); + assert_eq!(table.get("vendor/ledger:ledger"), Some(&nested)); + } + + /// The mirror image: a directory whose *name* contains a colon is not a + /// subproject nested below the part before it. `services/odd:name` holds one + /// directory named `odd:name`, so from `services/odd` it is nothing at all. + #[test] + fn exposed_ids_do_not_read_a_colon_in_a_directory_name_as_nesting() { + let odd = principal(1); + let params = params_named( + "services/odd:backend", + &[ + ("services/odd:backend", principal(9)), + ("services/odd:name:frontend", odd), + ], + ); + + let table = exposed_canister_ids(¶ms); + + assert_eq!(table.get("services/odd:name:frontend"), Some(&odd)); + assert_eq!(table.get("name:frontend"), None); } /// A single-project layout keys canisters by bare local name already, so no diff --git a/crates/icp/src/manifest/adapter/plugin.rs b/crates/icp/src/manifest/adapter/plugin.rs index fabd03455..87fc5f0a6 100644 --- a/crates/icp/src/manifest/adapter/plugin.rs +++ b/crates/icp/src/manifest/adapter/plugin.rs @@ -259,12 +259,16 @@ pub struct Adapter { #[schemars(with = "Option>")] pub fields: Option>, - /// Canisters this plugin may call in addition to the canister being synced. - /// Each entry is a canister name resolved against the project's canister ID - /// table for the environment being synced (e.g. `backend`, or a namespaced - /// subproject canister such as `services/open-crm:backend`). The plugin - /// picks a target per call via the `call-target` in its `canister-call` - /// request; a target not listed here is rejected by the host. + /// Canisters this plugin may call, or read metadata from, in addition to + /// the canister being synced. Each entry is a canister name resolved against + /// the project's canister ID table for the environment being synced, written + /// as this project spells it: a bare local name for one of its own canisters + /// (e.g. `backend`), or a `:` key for a canister of + /// something it depends on (e.g. `vendor/ledger:ledger`). The same spellings + /// hold when the project is a workspace member, so vendoring it does not + /// change them. The plugin picks a target per request via the `call-target` + /// in its `canister-call` or `canister-metadata-section` request; a target + /// not listed here is rejected by the host. pub canisters: Option>, } diff --git a/docs/concepts/sync-plugins.md b/docs/concepts/sync-plugins.md index e3210181d..34200fff7 100644 --- a/docs/concepts/sync-plugins.md +++ b/docs/concepts/sync-plugins.md @@ -3,7 +3,7 @@ title: Sync Plugins description: How sync plugins extend the sync phase with sandboxed WebAssembly components that run arbitrary post-deployment logic against a canister. --- -A **sync plugin** is a WebAssembly component that runs during the [sync phase](build-deploy-sync.md#sync-phase) to perform arbitrary post-deployment work. icp-cli loads the plugin into a sandboxed [wasmtime](https://wasmtime.dev/) WASI runtime, hands it the ID of the canister being synced (plus the project's canister ID table), and lets it make canister calls and read declared files — nothing more. By default it can call only the canister being synced; it may call other canisters it lists in the sync step's `canisters:` list. +A **sync plugin** is a WebAssembly component that runs during the [sync phase](build-deploy-sync.md#sync-phase) to perform arbitrary post-deployment work. icp-cli loads the plugin into a sandboxed [wasmtime](https://wasmtime.dev/) WASI runtime, hands it the ID of the canister being synced (plus the project's canister ID table), and lets it make canister calls, read canister metadata, and read declared files — nothing more. By default it can call only the canister being synced; it may call other canisters it lists in the sync step's `canisters:` list. You declare a sync plugin in your manifest with a `plugin` sync step. For the exact manifest fields, see [Plugin Sync in the Configuration Reference](../reference/configuration.md#plugin-sync). To author your own plugin, see [Writing a Sync Plugin](../guides/writing-sync-plugins.md). @@ -39,19 +39,23 @@ icp sync │ dirs/files/fields = what you declared in the manifest │ └─ plugin makes canister-call({ target, ... }) (× N) + and canister-metadata-section({ target, name }) target = host (the canister being synced), or a canister from `canisters:` by name ``` ## The Plugin Interface -The interface is defined as a [WIT](https://component-model.bytecodealliance.org/design/wit.html) world. The host provides one import (`canister-call`); the plugin provides one export (`exec`): +The interface is defined as a [WIT](https://component-model.bytecodealliance.org/design/wit.html) world. The host provides two imports (`canister-call` and `canister-metadata-section`); the plugin provides one export (`exec`): ```wit world sync-plugin { // Host import: call the canister being synced or one listed in `canisters:`. import canister-call: func(req: canister-call-request) -> result, string>; + // Host import: read a metadata section from one of those same canisters. + import canister-metadata-section: func(req: metadata-section-request) -> result>, string>; + // Plugin export: run the sync step. export exec: func(input: sync-exec-input) -> result<_, string>; } @@ -74,7 +78,9 @@ The authoritative interface, including all record fields, lives in [`sync-plugin | `proxy-canister-id` | Textual principal of the proxy canister if one was configured via `--proxy`, otherwise absent | | `canister-ids` | The project's canister ID table for this environment — each entry a canister name and the principal it resolves to. Informational; being listed here does not grant permission to call a canister | -Each `canister-ids` entry's name is the canister's fully-qualified project key: a bare local name for a canister defined in the app root, or a `subproject:canister` key for a canister defined in a subproject. Canisters in the same subproject as the one being synced are additionally listed under their bare local name, so a plugin can look up a sibling by the name that subproject's manifest uses. A bare name always means the sibling: if an app-root canister has the same local name, it is not listed for that sync. +Each `canister-ids` entry's name is the canister's fully-qualified project key: a bare local name for a canister defined in the app root, or a `subproject:canister` key for a canister defined in a subproject. + +Every canister the subproject being synced can name for itself is additionally listed under that name, so a plugin looks a canister up by the name that subproject's own manifest uses. For a canister in `services/crm`, that means its siblings under their bare local names, and the canisters of its own dependencies under keys relative to it — `services/crm/vendor/ledger:ledger` is also listed as `vendor/ledger:ledger`. Those are the names the subproject uses when it is deployed on its own, so a plugin written against them keeps working once the subproject is vendored into a workspace. Such a name always means what the subproject means by it: a canister elsewhere in the workspace whose key is spelled the same way is not listed under it for that sync. The manifest declares directories and files together under `files:`; the host splits them into these two lists by what is on disk, so a plugin never has to say up front which an entry will turn out to be. @@ -95,6 +101,25 @@ The plugin calls methods through the `canister-call` import. It picks a `target` The `host` target always resolves to `sync-exec-input.canister-id` and is always permitted. A `name` target is permitted only if that canister appears in the sync step's [`canisters:`](../reference/configuration.md#plugin-sync) list; the host rejects any other target without making a call. A name is the only way to address another canister — the host owns the name→principal mapping, which differs per environment. +### Reading canister metadata — `canister-metadata-section` + +The plugin reads a canister's [metadata sections](../reference/cli.md#icp-canister-metadata) — `candid:service`, for instance — through the `canister-metadata-section` import: + +| Request field | Meaning | +|---------------|---------| +| `target` | Which canister to read from: `host`, or a canister declared in `canisters:` addressed by `name` — the same targets, and the same enforcement, as `canister-call` | +| `name` | The section name, without the `icp:public `/`icp:private ` prefix the wasm custom section carries (e.g. `candid:service`) | +| `direct` | When `false` (default), the read is routed through the [proxy canister](../guides/proxy-canister.md) if one is configured; when `true`, it always goes straight to the target | + +A successful read returns the section's raw bytes, or **absent** when the target provably has no section by that name — including when it has no module installed at all — so a plugin can probe for an optional section without matching on error text. Everything else is an error: a section the reader may not have, a canister that does not exist, a read that fails. + +The two routes differ in who the target sees asking, which decides whether a **private** section reads as its bytes or as an error: + +- **Direct** — a certified `read_state` request signed by the sync identity. A private section requires that identity to control the target. +- **Proxied** — a call to the management canister's `canister_metadata` method made by the proxy, because `read_state` is not a canister method and cannot be forwarded. A private section requires the *proxy* to control the target — the same arrangement proxied update calls rely on. + +With no proxy configured, both settings read directly. + ### Logging — stdout and stderr The plugin's stdout and stderr are captured by the host (no logging import is needed — use ordinary `println!` / `eprintln!`): @@ -125,6 +150,7 @@ The plugin runs with a deliberately narrow capability surface. | Clocks, RNG, `wasi:io` | yes | Rust's `HashMap`, `chrono`, etc. work normally | | `process::exit` / panics | yes | abort the guest cleanly; the host surfaces the error | | Canister calls | yes | to the canister being synced, and to canisters declared in `canisters:` | +| Canister metadata reads | yes | the same set of canisters as calls | | Environment variables / args | no | the WASI environment is empty; use `sync-exec-input.environment` | | Network sockets / DNS | blocked | treat the network as unavailable | | Filesystem writes | blocked | no writable preopens | @@ -139,7 +165,7 @@ The plugin runs with a deliberately narrow capability surface. | Linear memory | wasm32 address space (≤ 4 GiB) | | stdout / stderr per stream | 1 MiB | -The compute-time budget defaults to 60 seconds and is overridable with the [`ICP_CLI_PLUGIN_COMPUTE_LIMIT_SECS`](../reference/environment-variables.md#icp_cli_plugin_compute_limit_secs) environment variable — raise it for compute-heavy plugins (e.g. compressing a large asset bundle) that legitimately need more time, especially on slower CI runners. The budget counts only wasm instruction execution: time spent waiting for a `canister-call` to return over the network is **not** charged against it — the host grants that time back when the call completes. A plugin can make as many canister calls as it needs without the network latency eating into its compute limit. +The compute-time budget defaults to 60 seconds and is overridable with the [`ICP_CLI_PLUGIN_COMPUTE_LIMIT_SECS`](../reference/environment-variables.md#icp_cli_plugin_compute_limit_secs) environment variable — raise it for compute-heavy plugins (e.g. compressing a large asset bundle) that legitimately need more time, especially on slower CI runners. The budget counts only wasm instruction execution: time spent waiting for a host call (`canister-call`, `canister-metadata-section`) to return over the network is **not** charged against it — the host grants that time back when the call completes. A plugin can make as many canister calls as it needs without the network latency eating into its compute limit. ## Next Steps diff --git a/docs/guides/writing-sync-plugins.md b/docs/guides/writing-sync-plugins.md index 9f7d86b53..c1cfd9715 100644 --- a/docs/guides/writing-sync-plugins.md +++ b/docs/guides/writing-sync-plugins.md @@ -37,7 +37,7 @@ wit-bindgen = { version = "0.56", features = ["realloc"] } ## Generate Bindings and Implement `exec` -`wit_bindgen::generate!` reads the WIT at build time and produces the `Guest` trait you implement, the input/request types, and the `canister_call` host function. The `exec` export is your entry point — it returns `Ok(())` on success or `Err(message)` to fail the sync step. +`wit_bindgen::generate!` reads the WIT at build time and produces the `Guest` trait you implement, the input/request types, and the host functions (`canister_call`, `canister_metadata_section`). The `exec` export is your entry point — it returns `Ok(())` on success or `Err(message)` to fail the sync step. ```rust // src/lib.rs @@ -85,9 +85,30 @@ export!(Plugin); A few things to note: - **You encode the arguments.** `arg` is raw Candid bytes. Encode with `candid::Encode!`; decode any response (`Vec`) with `candid::Decode!`. -- **You choose the target.** `target: CallTarget::Host` reaches the canister being synced. To call another canister, declare it in the manifest's [`canisters:`](../reference/configuration.md#plugin-sync) list and address it with `CallTarget::Name("ledger".into())` — the name matches the entries in `input.canister_ids`. Names are the only way to reach another canister; the host resolves them per environment. The host rejects a target you did not declare. +- **You choose the target.** `target: CallTarget::Host` reaches the canister being synced. To call another canister, declare it in the manifest's [`canisters:`](../reference/configuration.md#plugin-sync) list and address it with `CallTarget::Name("ledger".into())` — the name matches the entries in `input.canister_ids`. Names are the only way to reach another canister; the host resolves them per environment. The host rejects a target you did not declare. A name is always the one the plugin's own project uses, so hardcoding it stays correct when that project is vendored into a workspace as a subproject. - **`direct` and `cycles` control proxy routing.** With `direct: false`, update calls go through the [proxy canister](proxy-canister.md) when one is configured, and `cycles` can fund the forwarded call. With `direct: true`, the call always goes straight to the target. See [The Plugin Interface](../concepts/sync-plugins.md#the-plugin-interface) for the full semantics. +## Read Canister Metadata + +`canister_metadata_section` reads a [metadata section](../reference/cli.md#icp-canister-metadata) off a canister — useful for inspecting what is actually deployed before acting on it, e.g. its `candid:service` interface: + +```rust +let interface = canister_metadata_section(&MetadataSectionRequest { + target: CallTarget::Host, // same targets, same rules, as canister_call + name: "candid:service".to_string(), + direct: false, // route through the proxy if one is configured +})?; + +match interface { + Some(bytes) => println!("interface: {}", String::from_utf8_lossy(&bytes)), + // `None` means the canister provably has no such section — not a failure. + // A section you may not read, or a canister that does not exist, is an error. + None => println!("canister exposes no Candid interface"), +} +``` + +`direct` picks who the target sees asking, which is what decides whether a *private* section is readable: a direct read is signed by the sync identity, a proxied one is made by the proxy canister on your behalf. See [Reading canister metadata](../concepts/sync-plugins.md#reading-canister-metadata--canister-metadata-section) for the full semantics. + ## Read Declared Files and Directories A plugin can't see the filesystem freely — only what you grant it in the manifest's `files:`. That one setting holds directories and files alike, named: `seed: assets/seed-data`. The host splits them by what is on disk and hands you `input.dirs` and `input.files`, so a plugin never declares up front which an entry will be. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index a3947351f..090d34cc3 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -153,7 +153,7 @@ sync: fields: # key-value fields passed inline api_url: https://example.com retries: 3 - canisters: # extra canisters the plugin may call + canisters: # extra canisters the plugin may reach - ledger # by name (resolved for the environment) - services/open-crm:backend @@ -171,7 +171,7 @@ sync: | `files` | map of name → path(s) | No | What the plugin may read (relative to the canister directory, anywhere inside the project). A directory is made readable read-only via WASI; a file is read by the host and passed inline | | `dirs` | list of paths | No | Directories the plugin may read. Only for a plugin built against `icp:sync-plugin@0.1` — see below | | `fields` | map of string to string | No | Key-value fields passed inline to the plugin; the plugin decides how to interpret them | -| `canisters` | array of string | No | Canisters the plugin may call in addition to the one being synced. Each entry is a canister name, resolved against the project's canister IDs for the environment | +| `canisters` | array of string | No | Canisters the plugin may call, or read metadata from, in addition to the one being synced. Each entry is a canister name, resolved against the project's canister IDs for the environment | `files:` holds directories and files together; which an entry is comes from what is on disk, not from how it was written. Each key names a single path or a list of paths, and is surfaced to the plugin as that entry's `key` — a key holding a list produces several entries sharing it. For example: @@ -207,7 +207,9 @@ A plugin receives every `fields:` value as a string. Numbers and booleans need n A canister name in `canisters:` is the same name you use elsewhere in the project — a bare local name for a sibling canister, or a namespaced `subproject:canister` key for a canister defined in a subproject. A name that does not resolve to a known canister for the environment fails the sync step. -The plugin runs in a WASI sandbox: it can call update and query methods on the canister being synced (and any canister listed in `canisters:`) and read the declared `files`, but cannot open network sockets, spawn subprocesses, or write to disk. See [Sync Plugins](../concepts/sync-plugins.md) for the mechanism and [Writing a Sync Plugin](../guides/writing-sync-plugins.md) to author one. +Names are always written from this project's point of view, so they keep working when the project is vendored into a workspace as a subproject: a plugin on a canister in `services/crm` reaches a canister of its own `vendor/ledger` dependency as `vendor/ledger:ledger` either way, even though the workspace keys that canister `services/crm/vendor/ledger:ledger`. Both spellings resolve; if a canister elsewhere in the workspace happens to be keyed `vendor/ledger:ledger`, the name means your own. + +The plugin runs in a WASI sandbox: it can call update and query methods on the canister being synced (and any canister listed in `canisters:`), read those canisters' metadata sections, and read the declared `files`, but cannot open network sockets, spawn subprocesses, or write to disk. See [Sync Plugins](../concepts/sync-plugins.md) for the mechanism and [Writing a Sync Plugin](../guides/writing-sync-plugins.md) to author one. ## Recipes diff --git a/docs/schemas/canister-yaml-schema.json b/docs/schemas/canister-yaml-schema.json index 167e58d69..db41b4197 100644 --- a/docs/schemas/canister-yaml-schema.json +++ b/docs/schemas/canister-yaml-schema.json @@ -70,7 +70,7 @@ "description": "Configuration for a sync plugin step.\n\nA sync plugin is a WebAssembly module invoked during `icp sync` for a\nspecific canister. It runs inside a WASI sandbox whose filesystem access is\nlimited to what `files` lists: a directory is preopened read-only, and a\nfile's contents are read by the host and passed inline to the plugin.\nEntries are written relative to the canister directory and may name anything\ninside the project, but nothing outside it.\n\nExample (local path):\n```yaml\n- type: plugin\n path: ./plugins/populate-data.wasm\n sha256: e3b0c44298fc1c149afb... # optional for path\n files: # each entry is named\n seed: assets/seed-data # a directory, preopened read-only\n config: config.txt # a file, read and passed inline\n migrations: # a name may hold a list of paths\n - migrations/2025\n - migrations/2026\n fields: # key-value fields passed inline\n api_url: https://example.com\n retries: 3\n```\n\nExample (remote URL — `sha256` is required):\n```yaml\n- type: plugin\n url: https://example.com/plugins/populate-data.wasm\n sha256: e3b0c44298fc1c149afb... # required for url\n```\n\nA plugin built against the older `icp:sync-plugin@0.1` interface takes the\nshape that interface has instead: a separate `dirs` for the directories, and\nplain lists of paths rather than named entries. The two shapes cannot be\nmixed, and which applies is settled by the plugin, so it is enforced when\nthe plugin is loaded (see [`NamedPaths`]).", "properties": { "canisters": { - "description": "Canisters this plugin may call in addition to the canister being synced.\nEach entry is a canister name resolved against the project's canister ID\ntable for the environment being synced (e.g. `backend`, or a namespaced\nsubproject canister such as `services/open-crm:backend`). The plugin\npicks a target per call via the `call-target` in its `canister-call`\nrequest; a target not listed here is rejected by the host.", + "description": "Canisters this plugin may call, or read metadata from, in addition to\nthe canister being synced. Each entry is a canister name resolved against\nthe project's canister ID table for the environment being synced, written\nas this project spells it: a bare local name for one of its own canisters\n(e.g. `backend`), or a `:` key for a canister of\nsomething it depends on (e.g. `vendor/ledger:ledger`). The same spellings\nhold when the project is a workspace member, so vendoring it does not\nchange them. The plugin picks a target per request via the `call-target`\nin its `canister-call` or `canister-metadata-section` request; a target\nnot listed here is rejected by the host.", "items": { "type": "string" }, diff --git a/docs/schemas/icp-yaml-schema.json b/docs/schemas/icp-yaml-schema.json index a706974bd..e0af63c9b 100644 --- a/docs/schemas/icp-yaml-schema.json +++ b/docs/schemas/icp-yaml-schema.json @@ -70,7 +70,7 @@ "description": "Configuration for a sync plugin step.\n\nA sync plugin is a WebAssembly module invoked during `icp sync` for a\nspecific canister. It runs inside a WASI sandbox whose filesystem access is\nlimited to what `files` lists: a directory is preopened read-only, and a\nfile's contents are read by the host and passed inline to the plugin.\nEntries are written relative to the canister directory and may name anything\ninside the project, but nothing outside it.\n\nExample (local path):\n```yaml\n- type: plugin\n path: ./plugins/populate-data.wasm\n sha256: e3b0c44298fc1c149afb... # optional for path\n files: # each entry is named\n seed: assets/seed-data # a directory, preopened read-only\n config: config.txt # a file, read and passed inline\n migrations: # a name may hold a list of paths\n - migrations/2025\n - migrations/2026\n fields: # key-value fields passed inline\n api_url: https://example.com\n retries: 3\n```\n\nExample (remote URL — `sha256` is required):\n```yaml\n- type: plugin\n url: https://example.com/plugins/populate-data.wasm\n sha256: e3b0c44298fc1c149afb... # required for url\n```\n\nA plugin built against the older `icp:sync-plugin@0.1` interface takes the\nshape that interface has instead: a separate `dirs` for the directories, and\nplain lists of paths rather than named entries. The two shapes cannot be\nmixed, and which applies is settled by the plugin, so it is enforced when\nthe plugin is loaded (see [`NamedPaths`]).", "properties": { "canisters": { - "description": "Canisters this plugin may call in addition to the canister being synced.\nEach entry is a canister name resolved against the project's canister ID\ntable for the environment being synced (e.g. `backend`, or a namespaced\nsubproject canister such as `services/open-crm:backend`). The plugin\npicks a target per call via the `call-target` in its `canister-call`\nrequest; a target not listed here is rejected by the host.", + "description": "Canisters this plugin may call, or read metadata from, in addition to\nthe canister being synced. Each entry is a canister name resolved against\nthe project's canister ID table for the environment being synced, written\nas this project spells it: a bare local name for one of its own canisters\n(e.g. `backend`), or a `:` key for a canister of\nsomething it depends on (e.g. `vendor/ledger:ledger`). The same spellings\nhold when the project is a workspace member, so vendoring it does not\nchange them. The plugin picks a target per request via the `call-target`\nin its `canister-call` or `canister-metadata-section` request; a target\nnot listed here is rejected by the host.", "items": { "type": "string" }, diff --git a/examples/icp-sync-plugin/README.md b/examples/icp-sync-plugin/README.md index 57518b0ab..8f2c8ee2e 100644 --- a/examples/icp-sync-plugin/README.md +++ b/examples/icp-sync-plugin/README.md @@ -27,13 +27,27 @@ A simple Rust canister with three methods: A Rust Wasm component that implements the `sync-plugin` world defined in `crates/icp-sync-plugin/sync-plugin.wit`. The host runtime calls its `exec` -export and provides a `canister-call` import the plugin uses to reach the -canister. +export and provides the `canister-call` and `canister-metadata-section` imports the +plugin uses to reach the canister. ## How the plugin system is exercised This example is designed to demonstrate both routing modes of the -`canister-call` import — the `direct` flag — in a single sync run. +`canister-call` import — the `direct` flag — in a single sync run, plus a +metadata read that follows the same routing. + +### Read — `candid:service` via proxy (`direct: false`) + +Before calling anything, the plugin asks for the canister's `candid:service` +metadata section and reports its size. The build embeds that section with +`ic-wasm`, so it is there; had it not been, the read would return "absent" +rather than fail — a missing section is an answer, not an error. + +Routed through the proxy (`direct: false`), the read reaches the canister as the +management canister's `canister_metadata` method called by the proxy, so it is +the proxy's control over the canister that a private section would be checked +against. A direct read (`direct: true`) is a `read_state` signed by the user +identity instead. ### Call 1 — `set_uploader` via proxy (`direct: false`) @@ -65,6 +79,9 @@ icp sync │ identity-principal = │ proxy-canister-id = │ + ├─ canister-metadata-section candid:service direct=false → proxy → mgmt canister + │ reports the section's size, or "absent" + │ ├─ canister-call set_uploader() direct=false → proxy → canister │ canister stores uploader = │ diff --git a/examples/icp-sync-plugin/icp.yaml b/examples/icp-sync-plugin/icp.yaml index 8dcfacb1d..fad9a94d6 100644 --- a/examples/icp-sync-plugin/icp.yaml +++ b/examples/icp-sync-plugin/icp.yaml @@ -10,7 +10,7 @@ canisters: - type: script commands: - command -v ic-wasm >/dev/null 2>&1 || { echo >&2 "ic-wasm not found. To install ic-wasm, see https://github.com/dfinity/ic-wasm\n"; exit 1; } - - ic-wasm "$ICP_WASM_OUTPUT_PATH" -o "$ICP_WASM_OUTPUT_PATH" metadata candid:service -f demo.did --keep-name-section + - ic-wasm "$ICP_WASM_OUTPUT_PATH" -o "$ICP_WASM_OUTPUT_PATH" metadata candid:service -f demo.did -v public --keep-name-section sync: steps: diff --git a/examples/icp-sync-plugin/plugin/src/lib.rs b/examples/icp-sync-plugin/plugin/src/lib.rs index c3fdee356..09595a47d 100644 --- a/examples/icp-sync-plugin/plugin/src/lib.rs +++ b/examples/icp-sync-plugin/plugin/src/lib.rs @@ -17,7 +17,20 @@ impl Guest for Plugin { input.canister_id, input.environment ); - // 1. Set the uploader to the current identity principal. + // 1. Report the canister's Candid interface, read from its metadata. + // Reported rather than required: the section is only there if the + // build embedded it (this project's build does, via ic-wasm). + let interface = canister_metadata_section(&MetadataSectionRequest { + target: CallTarget::Host, + name: "candid:service".to_string(), + direct: false, + })?; + match &interface { + Some(bytes) => eprintln!("candid:service: {} bytes", bytes.len()), + None => eprintln!("candid:service: absent"), + } + + // 2. Set the uploader to the current identity principal. // Routed through the proxy (direct: false) so the controller-gated // call is signed by the proxy canister, which is a controller. let uploader = Principal::from_text(&input.identity_principal) @@ -33,7 +46,7 @@ impl Guest for Plugin { })?; println!("set_uploader ({}): ok", input.identity_principal); - // 2. Register every file found by traversing the preopened dirs. + // 3. Register every file found by traversing the preopened dirs. // Direct calls (direct: true) because register is gated on the // uploader principal, which is the current identity — not the proxy. let mut registered = 0u32;