diff --git a/crates/icp-cli/src/operations/bundle.rs b/crates/icp-cli/src/operations/bundle.rs index 6786fc7d5..bd460d576 100644 --- a/crates/icp-cli/src/operations/bundle.rs +++ b/crates/icp-cli/src/operations/bundle.rs @@ -650,6 +650,7 @@ async fn prepare_canister( canister_path, &path_name, idx, + local_names, pkg_cache, out, ) @@ -710,6 +711,24 @@ fn localize_controllers( settings } +/// Rewrite a plugin's declared call targets from workspace store keys back to the +/// local names of the instance being written, on the same grounds as +/// [`localize_controllers`]. +fn localize_call_targets( + canisters: Option<&[String]>, + local_names: &HashMap<&str, &str>, +) -> Option> { + canisters.map(|canisters| { + canisters + .iter() + .map(|target| match local_names.get(target.as_str()) { + Some(local) => (*local).to_owned(), + None => target.clone(), + }) + .collect() + }) +} + #[allow(clippy::too_many_arguments)] async fn prepare_plugin_step( adapter: &plugin::Adapter, @@ -718,6 +737,7 @@ async fn prepare_plugin_step( canister_path: &Path, path_name: &str, idx: usize, + local_names: &HashMap<&str, &str>, pkg_cache: &PackageCache, out: &mut BundleArtifacts, ) -> Result { @@ -788,6 +808,7 @@ async fn prepare_plugin_step( sha256: Some(plugin_sha256), dirs: bundle_dirs, files: bundle_files, + canisters: localize_call_targets(adapter.canisters.as_deref(), local_names), })) } diff --git a/crates/icp-cli/tests/bundle_tests.rs b/crates/icp-cli/tests/bundle_tests.rs index 74748e4e4..67b8a014f 100644 --- a/crates/icp-cli/tests/bundle_tests.rs +++ b/crates/icp-cli/tests/bundle_tests.rs @@ -1088,6 +1088,134 @@ fn bundle_packages_plugin_sync_steps() { ); } +/// A plugin's declared call targets must survive bundling — dropping them would turn a +/// working project into a bundle whose cross-canister calls are all rejected. Names of +/// the writing instance's own canisters come back out as local names; principals and +/// names that already resolved against the workspace are left alone. +#[test] +fn bundle_preserves_plugin_call_targets() { + let ctx = TestContext::new(); + let project_dir = ctx.create_project_dir("icp"); + let wasm_src = ctx.make_asset("example_icp_mo.wasm"); + + let build_step = formatdoc! {r#" + build: + steps: + - type: script + command: cp '{wasm_src}' "$ICP_WASM_OUTPUT_PATH" + "#}; + + // Bundling only repackages the plugin wasm bytes, so any non-empty content works. + write(&project_dir.join("plugin.wasm"), b"\x00asm\x01\x00\x00\x00") + .expect("failed to write plugin wasm"); + + let dep_dir = project_dir.join("vendor/openemail"); + create_dir_all(&dep_dir).expect("failed to create dependency dir"); + write(&dep_dir.join("plugin.wasm"), b"\x00asm\x01\x00\x00\x00") + .expect("failed to write dependency plugin wasm"); + + // The dependency's plugin names its own sibling, both bare and by store key. + write_string( + &dep_dir.join("icp.yaml"), + &formatdoc! {r#" + canisters: + - name: backend + {build_step} + sync: + steps: + - type: plugin + path: plugin.wasm + canisters: + - helper + - vendor/openemail:helper + - name: helper + {build_step} + "#}, + ) + .expect("failed to write dependency manifest"); + + // The root's plugin names a root sibling and a dependency canister by store key. + write_string( + &project_dir.join("icp.yaml"), + &formatdoc! {r#" + canisters: + - name: frontend + {build_step} + sync: + steps: + - type: plugin + path: plugin.wasm + canisters: + - api + - vendor/openemail:backend + - name: api + {build_step} + + dependencies: + - name: openemail + path: ./vendor/openemail + canisters: [backend] + "#}, + ) + .expect("failed to write project manifest"); + + let bundle_path = project_dir.join("bundle.tar.gz"); + ctx.icp() + .current_dir(&project_dir) + .args(["project", "bundle", "--output", bundle_path.as_str()]) + .assert() + .success(); + + let bundle_bytes = fs::read(bundle_path.as_std_path()).expect("failed to read bundle"); + let gz = GzDecoder::new(BufReader::new(bundle_bytes.as_slice())); + let mut archive = Archive::new(gz); + + let mut manifests: std::collections::HashMap = std::collections::HashMap::new(); + for entry in archive.entries().expect("failed to read archive entries") { + let mut entry = entry.expect("failed to read archive entry"); + let path = entry + .path() + .expect("failed to get entry path") + .to_string_lossy() + .into_owned(); + if path.ends_with("icp.yaml") { + let mut yaml = String::new(); + entry + .read_to_string(&mut yaml) + .expect("failed to read manifest"); + manifests.insert(path, yaml); + } + } + + let plugin_targets = |yaml: &str, canister: &str| -> Vec { + let parsed: serde_yaml::Value = + serde_yaml::from_str(yaml).expect("manifest yaml is invalid"); + let canisters = parsed["canisters"] + .as_sequence() + .expect("manifest has no canisters"); + let entry = canisters + .iter() + .find(|c| c["name"].as_str() == Some(canister)) + .unwrap_or_else(|| panic!("{canister} not found in bundled manifest: {yaml}")); + entry["sync"]["steps"][0]["canisters"] + .as_sequence() + .unwrap_or_else(|| panic!("{canister} plugin step lost its canisters: {yaml}")) + .iter() + .map(|t| t.as_str().expect("call target is not a string").to_owned()) + .collect() + }; + + assert_eq!( + plugin_targets(&manifests["icp.yaml"], "frontend"), + ["api", "vendor/openemail:backend"], + ); + // Both spellings of the dependency's own sibling come out as its local name. + assert_eq!( + plugin_targets(&manifests["vendor/openemail/icp.yaml"], "backend"), + ["helper", "helper"], + ); +} + /// An `icp_appmanifest.yaml` next to the project manifest must be included in the bundle, with its /// top-level `images` paths relocated under a top-level `images/` folder and the /// referenced image files copied alongside. Unrelated metadata is preserved. diff --git a/crates/icp-sync-plugin/DESIGN.md b/crates/icp-sync-plugin/DESIGN.md index 2a5787b6e..9d57338c9 100644 --- a/crates/icp-sync-plugin/DESIGN.md +++ b/crates/icp-sync-plugin/DESIGN.md @@ -27,15 +27,19 @@ docs; the *reasons* behind those choices are recorded here. - **Raw Candid bytes at the boundary** — `canister-call-request.arg` is `list`. The plugin owns Candid encoding/decoding; the host forwards bytes unchanged. This keeps the host free of any per-canister type knowledge. -- **`canister-call` takes no canister ID** — the host always calls the canister - from `sync-exec-input.canister-id`. There is deliberately no field for a - different target, so the single-canister restriction is *structural* rather - than a policy the plugin could bypass. +- **`canister-call` takes an explicit `target`** — the plugin selects the + canister being synced (`host`) or a canister from the step's `canisters:` + list, by name. The host resolves the target and *enforces* the list: a target + absent from it is rejected without a call. Names are the only way to address + another canister: the name→principal mapping is the host's to make, since it + varies per environment, and a plugin that hardcodes a principal is pinned to + 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.) - **`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: `canister-call` - still targets the canister being synced, so the table grants no ability to - call other canisters. + canister names it knows about. It is informational only; calling still + requires an entry in `canisters:`. - **Filesystem access via WASI, not a host import** — plugins use standard language APIs (`std::fs`); the host preopens the declared `dirs` read-only. No bespoke `read-file`/`list-dir` import is needed. @@ -71,9 +75,12 @@ pub fn run_plugin(invocation: PluginInvocation) -> Result, RunPlugin ``` `PluginInvocation` bundles the inputs: `wasm_path`, `base_dir`, `dirs`, `files`, -`target_canister_id` (the canister being synced), `agent`, `proxy`, -`identity_principal`, `environment`, `compute_limit_secs`, and the exposed -`canister_ids` table, plus `stdio`. +`host_canister_id` (the canister being synced), `agent`, `proxy`, +`identity_principal`, `environment`, `compute_limit_secs`, the exposed +`canister_ids` table, the `callable: CallableCanisters` enforcement set, and +`stdio`. The CLI resolves the manifest's declared `canisters:` into +`CallableCanisters` before calling; this crate stays free of any manifest +knowledge. `dirs` and `files` are the manifest-relative path strings, straight from the adapter. The runtime owns *all* filesystem access anchored at `base_dir`: it @@ -112,7 +119,8 @@ mod v2 { wasmtime::component::bindgen!({ world: "sync-plugin", path: "sync-plugi mod v1 { wasmtime::component::bindgen!({ world: "sync-plugin", path: "sync-plugin-v1.wit" }); } struct HostState { - target_canister_id: Principal, + host_canister_id: Principal, + callable: CallableCanisters, // name → principal, from the manifest agent: Arc, proxy: Option, wasi_ctx: wasmtime_wasi::WasiCtx, @@ -121,16 +129,18 @@ struct HostState { } // Implemented for both v1::SyncPluginImports and v2::SyncPluginImports; both -// delegate to one shared `do_canister_call(...)`. +// delegate to one shared `do_canister_call(target, ...)`. ``` `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 -`tokio::task::block_in_place`. Both interface versions call the canister being -synced. 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 target via `ic-agent`. +`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`. +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`. ### Interface versioning (parallel v0.1.0 / v0.2.0 support) @@ -174,21 +184,26 @@ Deserializes the `canister.yaml` fields into: ```rust pub struct Adapter { - pub source: SourceField, // path: or url: + pub source: SourceField, // path: or url: pub sha256: Option, pub dirs: Option>, pub files: Option>, + pub canisters: Option>, // extra callable canisters, by name } ``` -`Deserialize` is hand-written to reject a `url` source without a `sha256`. +Each `canisters:` entry is a canister name resolved against the project's ID +table for the environment being synced. `Deserialize` is hand-written to reject a +`url` source without a `sha256`. ### `crates/icp/src/canister/sync/plugin.rs` Resolves the wasm (local read or remote HTTP fetch into the package cache), -verifies sha256, builds the exposed canister ID table, then calls +verifies sha256, builds the exposed canister ID table and the `CallableCanisters` +enforcement set (resolving `canisters:` against the project's IDs), then calls `icp_sync_plugin::run_plugin(...)` with a `PluginInvocation`. The runtime — not the CLI — opens the declared paths and enforces the path-safety checks, so the CLI no longer touches the plugin's input files itself. `exposed_canister_ids` adds a bare-local-name duplicate for every canister in the same subproject as -the one being synced. +the one being synced; `resolve_callable` fails the step if a name in +`canisters:` does not resolve. diff --git a/crates/icp-sync-plugin/src/lib.rs b/crates/icp-sync-plugin/src/lib.rs index 84c5f4e41..053be28a3 100644 --- a/crates/icp-sync-plugin/src/lib.rs +++ b/crates/icp-sync-plugin/src/lib.rs @@ -2,6 +2,6 @@ mod path; mod runtime; pub use runtime::{ - DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS, PLUGIN_COMPUTE_LIMIT_ENV, PluginInvocation, RunPluginError, - run_plugin, + CallableCanisters, DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS, PLUGIN_COMPUTE_LIMIT_ENV, + PluginInvocation, RunPluginError, run_plugin, }; diff --git a/crates/icp-sync-plugin/src/runtime.rs b/crates/icp-sync-plugin/src/runtime.rs index 2cf39ad3b..4632a1ab9 100644 --- a/crates/icp-sync-plugin/src/runtime.rs +++ b/crates/icp-sync-plugin/src/runtime.rs @@ -39,9 +39,7 @@ use wasmtime_wasi::{DirPerms, FilePerms}; // module so their generated type names don't collide. `run_plugin` reads the // interface version from the component's own metadata (see `detect_plugin_abi`) // and drives it through the matching module, so plugins built against either -// interface load. The two interfaces are currently structurally identical; the -// split exists so later breaking changes to the current interface can land -// without dropping support for already-built plugins. +// interface load. mod v2 { wasmtime::component::bindgen!({ world: "sync-plugin", @@ -56,11 +54,46 @@ mod v1 { }); } -use v2::icp::sync_plugin::types::{CallType, CanisterIdEntry}; +use v2::icp::sync_plugin::types::{CallTarget, CallType, CanisterIdEntry}; + +/// The canisters a sync plugin is permitted to call, beyond the canister being +/// synced (which is always reachable via [`CallTarget::Host`]). +/// +/// Built by the CLI from the plugin step's `canisters` list, resolved against +/// the project's canister ID table. Keeping the resolution on the CLI side +/// keeps this runtime crate free of any manifest knowledge. +#[derive(Clone, Debug, Default)] +pub struct CallableCanisters { + /// Canisters callable by name ([`CallTarget::Name`]). Maps the name — as it + /// appears in the canister ID table — to the principal it resolves to. + pub by_name: BTreeMap, +} + +/// 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. +fn resolve_call_target( + target: &CallTarget, + host_canister_id: Principal, + callable: &CallableCanisters, +) -> Result { + match target { + CallTarget::Host => Ok(host_canister_id), + CallTarget::Name(name) => callable.by_name.get(name).copied().ok_or_else(|| { + format!( + "plugin is not permitted to call canister '{name}': declare it in the sync step's \ + `canisters` list to allow it" + ) + }), + } +} // HostState holds everything the plugin's import functions need. struct HostState { - target_canister_id: Principal, + /// The canister being synced — the target of [`CallTarget::Host`] calls. + host_canister_id: Principal, + /// Canisters the plugin declared in `canisters` and may also call. + callable: CallableCanisters, agent: Arc, /// Proxy canister to route update calls through, if configured. proxy: Option, @@ -85,10 +118,12 @@ impl wasmtime_wasi::WasiView for HostState { } impl HostState { - /// Perform a canister call to the canister being synced. Shared by both - /// interface versions. + /// Perform a canister call to an already-resolved target principal. Shared + /// by both interface versions: the v0.1.0 import always passes the canister + /// being synced; the v0.2.0 import passes the resolved `call-target`. fn do_canister_call( &mut self, + target: Principal, method: String, arg_bytes: Vec, call_type: CallType, @@ -97,7 +132,6 @@ impl HostState { ) -> Result, String> { use icp_canister_interfaces::proxy::{ProxyArgs, ProxyResult}; - let cid = self.target_canister_id; let agent = Arc::clone(&self.agent); let proxy = if direct { None } else { self.proxy }; @@ -109,7 +143,7 @@ impl HostState { CallType::Update => { if let Some(proxy_cid) = proxy { let proxy_args = ProxyArgs { - canister_id: cid, + canister_id: target, method: method.clone(), args: arg_bytes, cycles: candid::Nat::from(cycles), @@ -129,14 +163,14 @@ impl HostState { } } else { agent - .update(&cid, &method) + .update(&target, &method) .with_arg(arg_bytes) .await .map_err(|e| format!("canister call failed: {e}")) } } CallType::Query => agent - .query(&cid, &method) + .query(&target, &method) .with_arg(arg_bytes) .call() .await @@ -152,7 +186,7 @@ impl HostState { } } -// -- v0.2.0 interface. --------------------------------------------------------- +// -- v0.2.0 interface: the plugin chooses the target via `call-target`. -------- // `types::Host` is an empty marker trait generated for the `types` interface. impl v2::icp::sync_plugin::types::Host for HostState {} @@ -162,11 +196,19 @@ impl v2::SyncPluginImports for HostState { &mut self, req: v2::icp::sync_plugin::types::CanisterCallRequest, ) -> Result, String> { - self.do_canister_call(req.method, req.arg, req.call_type, req.direct, req.cycles) + let target = resolve_call_target(&req.target, self.host_canister_id, &self.callable)?; + self.do_canister_call( + target, + req.method, + req.arg, + req.call_type, + req.direct, + req.cycles, + ) } } -// -- v0.1.0 interface. --------------------------------------------------------- +// -- v0.1.0 interface: calls always go to the canister being synced. ----------- impl v1::icp::sync_plugin::types::Host for HostState {} @@ -175,12 +217,16 @@ impl v1::SyncPluginImports for HostState { &mut self, req: v1::icp::sync_plugin::types::CanisterCallRequest, ) -> Result, String> { + // The legacy interface has no target field; always call the host canister. + let target = self.host_canister_id; // v1's `call-type` is a distinct generated enum; map it to the shared one. let call_type = match req.call_type { v1::icp::sync_plugin::types::CallType::Update => CallType::Update, v1::icp::sync_plugin::types::CallType::Query => CallType::Query, }; - self.do_canister_call(req.method, req.arg, call_type, req.direct, req.cycles) + self.do_canister_call( + target, req.method, req.arg, call_type, req.direct, req.cycles, + ) } } @@ -266,9 +312,11 @@ pub enum RunPluginError { /// Which version of the sync-plugin interface a component was built against. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum PluginAbi { - /// Current interface (`icp:sync-plugin@0.2.x`). + /// Current interface (`icp:sync-plugin@0.2.x`): `canister-call` chooses a + /// target and `sync-exec-input` carries the canister ID table. V2, - /// Legacy interface (`icp:sync-plugin@0.1.x`). + /// Legacy interface (`icp:sync-plugin@0.1.x`): calls always reach the + /// canister being synced. V1, } @@ -340,8 +388,8 @@ pub struct PluginInvocation { pub dirs: Vec, /// Manifest-relative files to read and pass inline. pub files: Vec, - /// The canister being synced. - pub target_canister_id: Principal, + /// The canister being synced. Reachable via `call-target::host`. + pub host_canister_id: Principal, /// Agent used for canister calls. pub agent: Agent, /// Proxy canister to route update calls through, if configured. @@ -356,6 +404,10 @@ pub struct PluginInvocation { /// plugin. Same-project canisters appear both under their fully-qualified /// key and their bare local name (see the WIT `canister-id-entry` docs). pub canister_ids: BTreeMap, + /// Canisters the plugin declared in `canisters` and may call, beyond the + /// canister being synced. Ignored by v0.1.0 plugins, which can only reach + /// the canister being synced. + pub callable: CallableCanisters, /// Channel for live rolling-view output, if any. pub stdio: Option>, } @@ -366,13 +418,14 @@ pub fn run_plugin(invocation: PluginInvocation) -> Result, RunPlugin base_dir, dirs, files, - target_canister_id, + host_canister_id, agent, proxy, identity_principal, environment, compute_limit_secs, canister_ids, + callable, stdio, } = invocation; @@ -464,7 +517,8 @@ pub fn run_plugin(invocation: PluginInvocation) -> Result, RunPlugin let epoch_extension = Arc::new(AtomicU64::new(0)); let host_state = HostState { - target_canister_id, + host_canister_id, + callable, agent: Arc::new(agent), proxy, wasi_ctx: wasi_builder.build(), @@ -486,13 +540,15 @@ pub fn run_plugin(invocation: PluginInvocation) -> Result, RunPlugin } }); - let canister_id_text = target_canister_id.to_text(); + let canister_id_text = host_canister_id.to_text(); let identity_text = identity_principal.to_text(); let proxy_text = proxy.map(|p| p.to_text()); // Which interface the plugin was built against is read from the component's // own declared metadata (see `detect_plugin_abi`) rather than probed by - // trial instantiation, then driven through the matching bindgen world. + // trial instantiation. Both are served in parallel: v0.2.0 plugins choose a + // call target and receive the canister ID table; v0.1.0 plugins get neither + // and always call the canister being synced. let call_result = match detect_plugin_abi(&engine, &component, &wasm_path)? { PluginAbi::V2 => { let mut linker: Linker = Linker::new(&engine); @@ -744,8 +800,8 @@ mod tests { } /// A [`PluginInvocation`] with test-friendly defaults: anonymous canister - /// and identity, no proxy, an empty canister ID table, the default compute - /// limit, and the current directory as the base. Individual tests override + /// and identity, no proxy, no declared callable canisters, the default + /// compute limit, and the current directory as the base. Tests override /// the few fields they care about. fn invocation(wasm_path: &str, environment: &str) -> PluginInvocation { PluginInvocation { @@ -753,17 +809,51 @@ mod tests { base_dir: ".".into(), dirs: vec![], files: vec![], - target_canister_id: anon(), + host_canister_id: anon(), agent: dummy_agent(), proxy: None, identity_principal: anon(), environment: environment.to_string(), compute_limit_secs: DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS, canister_ids: BTreeMap::new(), + callable: CallableCanisters::default(), stdio: None, } } + // ------------------------------------------------------------------------- + // Call-target resolution (enforcement) — pure logic, no fixture WASM needed + // ------------------------------------------------------------------------- + + #[test] + fn resolve_target_host_is_always_allowed() { + let host = Principal::from_slice(&[1; 4]); + let callable = CallableCanisters::default(); + assert_eq!( + resolve_call_target(&CallTarget::Host, host, &callable).unwrap(), + host + ); + } + + #[test] + fn resolve_target_name_requires_declaration() { + let host = Principal::from_slice(&[1; 4]); + let dep = Principal::from_slice(&[2; 4]); + let callable = CallableCanisters { + by_name: BTreeMap::from([("backend".to_string(), dep)]), + }; + assert_eq!( + resolve_call_target(&CallTarget::Name("backend".into()), host, &callable).unwrap(), + dep + ); + let err = resolve_call_target(&CallTarget::Name("frontend".into()), host, &callable) + .expect_err("undeclared name must be rejected"); + assert!( + err.contains("not permitted") && err.contains("frontend"), + "got: {err}" + ); + } + // ------------------------------------------------------------------------- // Error-path tests — no fixture WASM needed // ------------------------------------------------------------------------- diff --git a/crates/icp-sync-plugin/sync-plugin.wit b/crates/icp-sync-plugin/sync-plugin.wit index e201568b5..c2d51fde2 100644 --- a/crates/icp-sync-plugin/sync-plugin.wit +++ b/crates/icp-sync-plugin/sync-plugin.wit @@ -33,6 +33,23 @@ interface types { id: string, } + /// Which canister a `canister-call-request` targets. + /// + /// A plugin may target the canister being synced, or any canister listed in + /// the sync step's `canisters` list — always by that canister's name. + /// Targeting a canister that was not listed is rejected by the host. + variant call-target { + /// The canister being synced (`sync-exec-input.canister-id`). Always + /// 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. + name(string), + } + /// Input passed by the runtime to the plugin's exec() export. record sync-exec-input { /// Textual principal of the canister being synced. @@ -54,13 +71,17 @@ interface types { /// Name→principal mapping for every named canister in the project for /// the environment being synced, sorted by name. Informational: the /// plugin may use it to resolve canister names it knows about. Being - /// listed here does not let the plugin call a canister — the - /// `canister-call` import always targets the canister being synced. + /// listed here does not grant permission to call a canister — that + /// still requires listing it in `canisters` (see `call-target`). canister-ids: list, } - /// A request to call a method on the target canister. + /// A request to call a method on a canister. record canister-call-request { + /// Which canister to call. `host` targets the canister being synced; + /// `name` targets a canister listed in the sync step's `canisters` + /// list. + target: call-target, /// The canister method to call. method: string, /// Candid-encoded argument bytes. The plugin is responsible for @@ -84,15 +105,16 @@ interface types { /// The complete interface of a sync plugin. world sync-plugin { - use types.{sync-exec-input, canister-call-request, canister-id-entry, file-input}; + use types.{sync-exec-input, canister-call-request, call-target, canister-id-entry, file-input}; // ------------------------------------------------------------------------- // Host functions (imports) — provided by icp-cli, called by the plugin // ------------------------------------------------------------------------- - /// Make an update or query call to the canister being synced. - /// The host always calls the canister from sync-exec-input.canister-id; - /// the plugin does not choose the target. + /// Make an update or query call to a canister. + /// The `req.target` selects the canister: the one being synced (`host`), or + /// a canister listed in the sync step's `canisters` list, by name. A target + /// that was not listed is rejected without making a call. /// Returns the raw Candid-encoded response bytes on success or an error /// message on failure. The plugin is responsible for decoding. import canister-call: func(req: canister-call-request) -> result, string>; diff --git a/crates/icp/src/canister/sync/mod.rs b/crates/icp/src/canister/sync/mod.rs index 93dfdd0bd..a90ff93be 100644 --- a/crates/icp/src/canister/sync/mod.rs +++ b/crates/icp/src/canister/sync/mod.rs @@ -20,7 +20,7 @@ pub struct Params { pub path: PathBuf, pub cid: Principal, /// Fully-qualified store key of the canister being synced (e.g. `backend`, - /// or `services/open-crm:backend` for a dependency canister). Its namespace + /// or `services/open-crm:backend` for a canister in a subproject). Its namespace /// prefix identifies which other canisters are in the same subproject. pub name: String, /// Name of the environment being synced (e.g. "local", "production"). diff --git a/crates/icp/src/canister/sync/plugin.rs b/crates/icp/src/canister/sync/plugin.rs index 22f749724..73a111306 100644 --- a/crates/icp/src/canister/sync/plugin.rs +++ b/crates/icp/src/canister/sync/plugin.rs @@ -4,8 +4,8 @@ use camino::Utf8PathBuf; use candid::Principal; use ic_agent::Agent; use icp_sync_plugin::{ - DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS, PLUGIN_COMPUTE_LIMIT_ENV, PluginInvocation, RunPluginError, - run_plugin, + CallableCanisters, DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS, PLUGIN_COMPUTE_LIMIT_ENV, + PluginInvocation, RunPluginError, run_plugin, }; use snafu::prelude::*; use tokio::sync::mpsc::Sender; @@ -29,6 +29,12 @@ pub enum PluginError { #[snafu(display("failed to run plugin"))] Run { source: RunPluginError }, + + #[snafu(display( + "sync plugin lists canister '{name}' as callable, but no canister by that name \ + is known in environment '{environment}'" + ))] + UnknownCallableCanister { name: String, environment: String }, } /// Resolve the plugin compute-time limit, honoring the @@ -94,8 +100,10 @@ pub(super) async fn sync( let dirs: Vec = adapter.dirs.clone().unwrap_or_default(); let files: Vec = adapter.files.clone().unwrap_or_default(); - // 3. Build the canister ID table exposed to the plugin. + // 3. Build the canister ID table exposed to the plugin, then resolve the + // step's `canisters` list against it. let canister_ids = exposed_canister_ids(params); + let callable = resolve_callable(adapter, &canister_ids, environment)?; // 4. Run the plugin (blocking call — signal Tokio that this thread will block). let identity_principal = agent @@ -112,13 +120,14 @@ pub(super) async fn sync( base_dir, dirs, files, - target_canister_id: params.cid, + host_canister_id: params.cid, agent: agent_clone, proxy, identity_principal, environment: environment_owned, compute_limit_secs, canister_ids, + callable, stdio: stdio_clone, }) }) @@ -128,9 +137,10 @@ 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 dependency canister 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. +/// `:` 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 @@ -150,6 +160,28 @@ fn exposed_canister_ids(params: &Params) -> BTreeMap { table } +/// 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. +fn resolve_callable( + adapter: &Adapter, + canister_ids: &BTreeMap, + environment: &str, +) -> Result { + let mut by_name = BTreeMap::new(); + for name in adapter.canisters.iter().flatten() { + let principal = canister_ids + .get(name) + .copied() + .context(UnknownCallableCanisterSnafu { + name: name.clone(), + environment: environment.to_owned(), + })?; + by_name.insert(name.clone(), principal); + } + Ok(CallableCanisters { by_name }) +} + #[cfg(test)] mod tests { use super::*; @@ -173,6 +205,8 @@ mod tests { } } + use crate::manifest::adapter::prebuilt::{LocalSource, SourceField}; + fn principal(byte: u8) -> Principal { Principal::from_slice(&[byte; 4]) } @@ -189,6 +223,18 @@ mod tests { } } + fn adapter_with(canisters: Option>) -> Adapter { + Adapter { + source: SourceField::Local(LocalSource { + path: "plugin.wasm".into(), + }), + sha256: None, + dirs: None, + files: None, + canisters, + } + } + /// Canisters sharing the syncing canister's subproject are additionally /// exposed under their bare local name; canisters in other subprojects are /// not. @@ -276,4 +322,34 @@ mod tests { assert_eq!(table.len(), 1); assert_eq!(table.get("backend"), Some(&backend)); } + + #[test] + fn resolve_callable_resolves_names() { + let dep = principal(1); + let sibling = principal(2); + let table = BTreeMap::from([ + ("backend".to_owned(), sibling), + ("services/open-crm:backend".to_owned(), dep), + ]); + let adapter = adapter_with(Some(vec![ + "backend".to_owned(), + "services/open-crm:backend".to_owned(), + ])); + + let callable = resolve_callable(&adapter, &table, "demo").unwrap(); + + assert_eq!(callable.by_name.get("backend"), Some(&sibling)); + assert_eq!( + callable.by_name.get("services/open-crm:backend"), + Some(&dep) + ); + } + + #[test] + fn resolve_callable_rejects_unknown_name() { + let adapter = adapter_with(Some(vec!["nope".to_owned()])); + let err = resolve_callable(&adapter, &BTreeMap::new(), "demo") + .expect_err("an undeclared name must fail"); + assert!(matches!(err, PluginError::UnknownCallableCanister { .. })); + } } diff --git a/crates/icp/src/manifest/adapter/plugin.rs b/crates/icp/src/manifest/adapter/plugin.rs index 915b03c1d..099825626 100644 --- a/crates/icp/src/manifest/adapter/plugin.rs +++ b/crates/icp/src/manifest/adapter/plugin.rs @@ -45,6 +45,14 @@ pub struct Adapter { /// Files (relative to canister directory) the host reads and passes to /// the plugin as part of `sync-exec-input.files`. pub files: 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. + pub canisters: Option>, } impl<'de> Deserialize<'de> for Adapter { @@ -56,6 +64,7 @@ impl<'de> Deserialize<'de> for Adapter { sha256: Option, dirs: Option>, files: Option>, + canisters: Option>, } let h = AdapterHelper::deserialize(d)?; @@ -69,6 +78,7 @@ impl<'de> Deserialize<'de> for Adapter { sha256: h.sha256, dirs: h.dirs, files: h.files, + canisters: h.canisters, }) } } @@ -94,6 +104,7 @@ mod tests { sha256: None, dirs: None, files: None, + canisters: None, }, ); } @@ -120,6 +131,7 @@ mod tests { sha256: Some("abc123".to_string()), dirs: Some(vec!["assets/seed-data".to_string(), "config".to_string()]), files: Some(vec!["config.txt".to_string()]), + canisters: None, }, ); } @@ -139,6 +151,26 @@ mod tests { ); } + #[test] + fn canisters_parse_as_names() { + let adapter = serde_yaml::from_str::( + r#" + path: plugins/my-sync.wasm + canisters: + - backend + - services/open-crm:backend + "#, + ) + .expect("failed to deserialize Adapter with canisters"); + assert_eq!( + adapter.canisters, + Some(vec![ + "backend".to_string(), + "services/open-crm:backend".to_string(), + ]), + ); + } + #[test] fn remote_url_with_sha256() { assert_eq!( @@ -156,6 +188,7 @@ mod tests { sha256: Some("a665a45920422f9d417e".to_string()), dirs: None, files: None, + canisters: None, }, ); } diff --git a/crates/icp/src/manifest/canister.rs b/crates/icp/src/manifest/canister.rs index b032be7e5..8d2838a5b 100644 --- a/crates/icp/src/manifest/canister.rs +++ b/crates/icp/src/manifest/canister.rs @@ -793,6 +793,7 @@ mod tests { sha256: None, dirs: Some(vec!["assets/seed-data/".to_string()]), files: None, + canisters: None, } )] }), @@ -837,6 +838,7 @@ mod tests { ), dirs: None, files: None, + canisters: None, })] }), }, diff --git a/docs/concepts/sync-plugins.md b/docs/concepts/sync-plugins.md index a7a6eb3f9..144c30ec6 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 against a single canister. 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. +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. 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). @@ -15,7 +15,7 @@ Sync plugins fill that gap. A plugin is: - **Portable** — written in any language that compiles to `wasm32-wasip2`, distributed as one `.wasm` file (local path or remote URL + `sha256`). - **Sandboxed** — it cannot open network sockets, spawn subprocesses, or touch the filesystem outside the directories you explicitly grant it. -- **Scoped to one canister** — it can call update and query methods, but only on the canister being synced. The target is fixed by the host; the plugin cannot choose a different one. +- **Scoped by declaration** — it can call update and query methods on the canister being synced, plus any canister listed in the manifest's `canisters:` list. A call to a canister that was not listed is rejected by the host. The most common way to get a sync plugin is through a [recipe](recipes.md). For example, the `@dfinity/asset-canister` recipe emits a `plugin` sync step (starting with `v2.2.1`) that uploads your built static files to the asset canister — so for everyday frontend deployment you never write a plugin yourself. @@ -38,7 +38,9 @@ icp sync │ canister-ids = │ dirs / files = what you declared in the manifest │ - └─ plugin makes canister-call(...) to the target canister (× N) + └─ plugin makes canister-call({ target, ... }) (× N) + target = host (the canister being synced), or a + canister from `canisters:` by name ``` ## The Plugin Interface @@ -47,7 +49,7 @@ The interface is defined as a [WIT](https://component-model.bytecodealliance.org ```wit world sync-plugin { - // Host import: call the canister being synced. + // Host import: call the canister being synced or one listed in `canisters:`. import canister-call: func(req: canister-call-request) -> result, string>; // Plugin export: run the sync step. @@ -71,21 +73,22 @@ 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 that came from a dependency. 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. 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. -### Calling the canister — `canister-call` +### Calling a canister — `canister-call` -The plugin calls methods on the target canister through the `canister-call` import. It supplies the method name, **Candid-encoded argument bytes** (the host forwards them unchanged), and a few routing options: +The plugin calls methods through the `canister-call` import. It picks a `target`, supplies the method name, **Candid-encoded argument bytes** (the host forwards them unchanged), and a few routing options: | Request field | Meaning | |---------------|---------| +| `target` | Which canister to call: `host` (the canister being synced), or a canister declared in `canisters:` addressed by `name` | | `method` | The canister method to call | | `arg` | Candid-encoded argument bytes (the plugin encodes; the host forwards as-is) | | `call-type` | `update` or `query` | | `direct` | When `false` (default), update calls are routed through the [proxy canister](../guides/proxy-canister.md) if one is configured; when `true`, the call always goes directly to the target. Query calls always go directly regardless. | | `cycles` | Cycles to attach to a proxied update call; only meaningful when `direct` is `false`, a proxy is configured, and `call-type` is `update` | -The host always calls the canister named in `sync-exec-input.canister-id`. There is no field for a different canister ID — the single-canister restriction is structural, not a policy the plugin can opt out of. +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. ### Logging — stdout and stderr @@ -114,7 +117,7 @@ The plugin runs with a deliberately narrow capability surface. | Read declared `dirs:` | yes | read-only preopens | | 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 | only to the canister being synced | +| Canister calls | yes | to the canister being synced, and to canisters declared in `canisters:` | | 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 | diff --git a/docs/guides/writing-sync-plugins.md b/docs/guides/writing-sync-plugins.md index 979a43081..80cf1f46e 100644 --- a/docs/guides/writing-sync-plugins.md +++ b/docs/guides/writing-sync-plugins.md @@ -65,6 +65,7 @@ impl Guest for Plugin { // Call a method on the canister being synced. canister_call(&CanisterCallRequest { + target: CallTarget::Host, // the canister being synced method: "set_uploader".to_string(), arg, call_type: icp::sync_plugin::types::CallType::Update, @@ -84,7 +85,7 @@ 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!`. -- **The target is fixed.** `canister_call` always reaches the canister in `input.canister_id` — there is no field to target another canister. +- **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. - **`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 Declared Files and Directories diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 219eff450..3ba6ccc62 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -151,6 +151,9 @@ sync: - config files: # files read by the host and passed inline - config.txt + canisters: # extra canisters the plugin may call, + - ledger # by name (resolved for the environment) + - services/open-crm:backend # Remote plugin (downloaded and verified before execution) - type: plugin @@ -165,10 +168,13 @@ sync: | `sha256` | string | Required for `url`, optional for `path` | SHA-256 hex digest of the wasm file, verified before execution | | `dirs` | array of string | No | Directories (relative to the canister directory) the plugin may read; each is preopened read-only via WASI | | `files` | array of string | No | Files (relative to the canister directory) read by the host and passed inline to the plugin | +| `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 | Entries in `dirs:`/`files:` must be relative, may not contain `..`, and may not be — or traverse — a symlink, so a declared path cannot resolve to a target outside the canister directory. -The plugin runs in a WASI sandbox: it can call update and query methods on the canister being synced and read the declared `dirs`/`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. +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 `dirs`/`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 7559c5cb2..9b9db63d9 100644 --- a/docs/schemas/canister-yaml-schema.json +++ b/docs/schemas/canister-yaml-schema.json @@ -69,6 +69,16 @@ ], "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\nis limited to the directories listed in `dirs` (preopened read-only) plus\nthe contents of any files listed in `files` (read by the host and passed\ninline to the plugin).\n\nExample (local path):\n```yaml\n- type: plugin\n path: ./plugins/populate-data.wasm\n sha256: e3b0c44298fc1c149afb... # optional for path\n dirs: # directories preopened read-only\n - assets/seed-data\n files: # files read by the host and passed inline\n - config.txt\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```", "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.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "dirs": { "description": "Directories (relative to canister directory) the plugin may read from.\nEach entry must be a directory; it is preopened via WASI so the plugin\ncan traverse it using standard filesystem APIs.", "items": { diff --git a/docs/schemas/icp-yaml-schema.json b/docs/schemas/icp-yaml-schema.json index b83a4e7a9..87186951b 100644 --- a/docs/schemas/icp-yaml-schema.json +++ b/docs/schemas/icp-yaml-schema.json @@ -69,6 +69,16 @@ ], "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\nis limited to the directories listed in `dirs` (preopened read-only) plus\nthe contents of any files listed in `files` (read by the host and passed\ninline to the plugin).\n\nExample (local path):\n```yaml\n- type: plugin\n path: ./plugins/populate-data.wasm\n sha256: e3b0c44298fc1c149afb... # optional for path\n dirs: # directories preopened read-only\n - assets/seed-data\n files: # files read by the host and passed inline\n - config.txt\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```", "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.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "dirs": { "description": "Directories (relative to canister directory) the plugin may read from.\nEach entry must be a directory; it is preopened via WASI so the plugin\ncan traverse it using standard filesystem APIs.", "items": { diff --git a/examples/icp-sync-plugin/plugin/src/lib.rs b/examples/icp-sync-plugin/plugin/src/lib.rs index 6f8d25508..f6d7534bd 100644 --- a/examples/icp-sync-plugin/plugin/src/lib.rs +++ b/examples/icp-sync-plugin/plugin/src/lib.rs @@ -24,6 +24,7 @@ impl Guest for Plugin { .map_err(|e| format!("invalid identity principal: {e}"))?; let arg = Encode!(&uploader).map_err(|e| format!("encode set_uploader arg: {e}"))?; canister_call(&CanisterCallRequest { + target: CallTarget::Host, method: "set_uploader".to_string(), arg, call_type: icp::sync_plugin::types::CallType::Update, @@ -68,6 +69,7 @@ fn register_dir(dir: &Path) -> Result { let arg = Encode!(&path_str, &content_trimmed) .map_err(|e| format!("encode register arg: {e}"))?; canister_call(&CanisterCallRequest { + target: CallTarget::Host, method: "register".to_string(), arg, call_type: icp::sync_plugin::types::CallType::Update,