From f4e356de084172e958fdaff4c0662e9b0e98ace8 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 18 Sep 2026 12:43:17 -0700 Subject: [PATCH] Update to released version of icp-cli --- API.md | 67 ++++++---------- README.md | 18 ++--- src/engine.rs | 207 ++++++++++++------------------------------------ src/fs.rs | 25 +++--- src/lib.rs | 6 +- src/testing.rs | 2 + sync-plugin.wit | 74 +++++++---------- 7 files changed, 129 insertions(+), 270 deletions(-) diff --git a/API.md b/API.md index 20d6b6d..7da7a39 100644 --- a/API.md +++ b/API.md @@ -12,7 +12,6 @@ the language plus the host functions documented here. - [Sync inputs (globals)](#sync-inputs-globals) - [Canister calls](#canister-calls) - [Metadata sections](#metadata-sections) -- [Environment variables](#environment-variables) - [Candid](#candid) - [Number types](#number-types) - [Exact-encoding classes](#exact-encoding-classes) @@ -38,15 +37,16 @@ sync: files: script: sync.js seed: [seed/users.json, seed/roles.json] - dirs: assets: assets ``` -Every declared file — the entry script included — is read by the host and handed -to the script via the `files` object, keyed by path. Directories declared in -`dirs` are preopened read-only and reachable with the filesystem functions below. -Declaring `files:`/`dirs:` as a map instead of a plain list tags each entry with -its key, which the script reads back through `fileKeys` / `dirKeys`. +`files:` is a map of name → path (or list of paths), and holds directories as +well as files; the host sorts them by what it finds on disk. Every declared +file — the entry script included — is read by the host and handed to the script +via the `files` object, keyed by path. Every declared directory is preopened +read-only and reachable with the filesystem functions below, and appears in +`dirs`. Each entry keeps the key it was declared under, which the script reads +back through `fileKeys` / `dirKeys`. A script runs to completion for a clean sync; throwing (or a runtime error) fails the step with the thrown message. @@ -69,6 +69,8 @@ suspends itself. | `identityId` | `string` | Textual principal of the signing identity. | | `identity` | `Principal` | The signing identity as a `Principal`. | | `proxy` | `Principal` \| `null` | Proxy canister if `--proxy` was set, else `null`. | +| `apiUrl` | `string` | The network's API endpoint, with a trailing slash. | +| `gatewayUrl` | `string` \| `null` | The network's HTTP gateway, or `null` if it has none. | | `dirs` | `string[]` | Declared directory paths (preopened read-only). | | `dirKeys` | `object` (key → `string[]`) | Manifest key → the directory paths declared under it. | | `files` | `object` (path → `string`) | Contents of every declared file, by path. | @@ -76,15 +78,21 @@ suspends itself. | `fields` | `object` (name → `string`) | Key-value fields declared in the step's `fields`. | | `canisterIds` | `object` (name → `string`) | Every project canister's name → textual principal. | -`dirKeys` and `fileKeys` cover only the entries declared under a map key; a -plain-list `dirs:`/`files:` has none, and appears only in `dirs`/`files`. One key -may name several paths, so each maps to an array: +Every `files:` entry is declared under a key, so `dirKeys` and `fileKeys` +between them cover all of `dirs` and `files`. One key may name several paths, so +each maps to an array: ```js // Contents of every file declared under the `seed` key. let seeds = fileKeys.seed.map((path) => files[path]); ``` +`apiUrl` and `gatewayUrl` say where the network is reached, normalized so a URL +with no path carries a trailing slash (`"http://127.0.0.1:4943/"`). They are +there to compose a URL from — to hand to a canister, or to print — not to fetch: +the plugin has no sockets, and the host makes every canister call on the +script's behalf. + `canisterIds` is informational: it maps each named canister in the project (both `subproject:local` keys and bare local names for same-subproject siblings) to its textual principal for the environment being synced. Being listed does not @@ -151,35 +159,6 @@ signed by the sync identity, which reaches a private section only if that identity controls the target; a proxied read reaches one private to the proxy's control. -## Environment variables - -`canisterSetenv` sets one of a canister's runtime environment variables, leaving -its other variables — and the rest of its settings — as they are. It names its -receiver first, as a call shorthand does. - -```js -canisterSetenv(self, "SEEDED_BY", environment); -canisterSetenv("ledger", "ADMIN", canisterIds.backend); - -// Optional trailing options; `direct` is the only one. -canisterSetenv(self, "ADMIN", identityId, { direct: true }); -``` - -The value is a string: the canister reads it back verbatim, so anything else is -the script's to render (`String(x)`, or `x.toText()` for a `Principal`). The -update is controller-gated — with `direct` the sync identity must control the -receiver, and by default the proxy configured via `--proxy` makes it, so that is -what must control it. With no proxy configured the sync identity signs either -way. - -Set the variable on every sync rather than once. The management canister can only -replace a canister's variables as a whole list, so the host reads them and writes -them back with yours added — and a later `icp deploy` rewrites that list from the -manifest, dropping what a plugin added. Deploy runs the sync phase afterwards, so -a script that always sets it always restores it. For a variable that should not -depend on the plugin running, declare it in the manifest's -`environment_variables` setting instead. - ## Candid An argument is written as Candid source with JavaScript values interpolated @@ -458,7 +437,7 @@ call returns at most 1 MiB. ## Filesystem -Read-only access to the directories the step declared under `dirs:`, backed by +Read-only access to the directories the step declared under `files:`, backed by WASI. Each is readable at the path the manifest declared it at, and nothing outside them is readable at all. @@ -492,10 +471,10 @@ for (const name of readDir("assets")) { The reads throw with the underlying error; the predicates answer `false` instead, so a path that may not be there — or may not be reachable — can be -asked about. Since only the declared `dirs:` are readable, a failed read also -says what the step declared, and a path naming a declared *file* says to read it -from `files`: the host passes those contents inline rather than putting them on -the filesystem. +asked about. Since only the declared directories are readable, a failed read +also says which ones the step declared, and a path naming a declared *file* says +to read it from `files`: the host passes those contents inline rather than +putting them on the filesystem. `joinPath` separates the parts it is given with single slashes however they are punctuated, dropping empty ones. A part that starts at the root replaces what diff --git a/README.md b/README.md index e8b1431..180d18e 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ An [icp-cli](https://github.com/dfinity/icp-cli) **sync plugin** that runs a JavaScript script against the canister being synced. It exposes to the script roughly the same capabilities a native sync plugin has — -calling the target canister, reading its metadata, setting its environment -variables, the sync inputs, and read-only filesystem access — plus Candid, -principal, and encoding helpers convenient for canister work. +calling the target canister, reading its metadata, the sync inputs, and +read-only filesystem access — plus Candid, principal, and encoding helpers +convenient for canister work. Scripts run on [QuickJS](https://bellard.org/quickjs/) via [rquickjs](https://crates.io/crates/rquickjs); it is a small ES2020-class engine @@ -31,9 +31,9 @@ without a WebAssembly runtime. ## Using it Declare the plugin as a sync step, with the entry script under the `script` key -(or inline in a `script` field). Any other files declared are read by the host -and handed to the script by path; directories under `dirs:` are preopened -read-only. +(or inline in a `script` field). `files:` is a map of name → path, and holds +directories as well as files: the host reads each file and hands its contents to +the script by path, and preopens each directory read-only. ```yaml sync: @@ -43,6 +43,7 @@ sync: files: script: sync.js config: config.json + assets: assets/ ``` A script runs to completion for a clean sync; throwing fails the step with the @@ -77,15 +78,14 @@ Each of these is covered in [API.md](./API.md): | | | | --- | --- | -| [Sync inputs](./API.md#sync-inputs-globals) | `canisterId`, `identity`, `environment`, `proxy`, `files`, `dirs`, `fields`, `canisterIds` and friends, as globals. | +| [Sync inputs](./API.md#sync-inputs-globals) | `canisterId`, `identity`, `environment`, `proxy`, `apiUrl`, `files`, `dirs`, `fields`, `canisterIds` and friends, as globals. | | [Canister calls](./API.md#canister-calls) | `callQuery` / `callUpdate` / `canisterCall`, against the synced canister or any canister the step declared. | | [Coerced calls](./API.md#coerced-calls) | `callTyped` / `canisterCallTyped` and `CandidInterface`, which encode and decode against the callee's own `.did`. | | [Candid](./API.md#candid) | The `candid` template tag, `CandidArgs`, `candidEncode` / `candidDecode`, the [number types](./API.md#number-types), and the [exact-encoding classes](./API.md#exact-encoding-classes) for variants, optionals, tuples and references. | | [Metadata sections](./API.md#metadata-sections) | `canisterMetadata`, reading a canister's custom sections. | -| [Environment variables](./API.md#environment-variables) | `canisterSetenv`, setting one runtime variable on a canister. | | [Principals](./API.md#principals) | The `Principal` class of [icp-js-core](https://github.com/dfinity/icp-js-core). | | [Helpers](./API.md#encoding-helpers) | `sha256`, `encodeUtf8` / `decodeUtf8`, and [`randomBytes`](./API.md#randomness). | -| [Filesystem](./API.md#filesystem) | Read-only reads, predicates and `joinPath` over the declared `dirs:`. | +| [Filesystem](./API.md#filesystem) | Read-only reads, predicates and `joinPath` over the directories the step declared. | | [Output](./API.md#output) | `print` / `eprint` and the `console` methods. | ## License diff --git a/src/engine.rs b/src/engine.rs index de90762..30d2e6d 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,6 +1,6 @@ //! Builds the QuickJS context the plugin runs scripts on, wiring in every -//! capability a sync plugin has: canister calls, environment variable updates, -//! the sync inputs, read-only filesystem access over WASI, and +//! capability a sync plugin has: canister calls, metadata reads, the sync +//! inputs, read-only filesystem access over WASI, and //! Candid/principal/encoding helpers. use ::candid::Principal as CandidPrincipal; @@ -16,10 +16,7 @@ use crate::convert; use crate::icp::sync_plugin::types::{CallTarget, CallType}; use crate::interface::SelfTarget; use crate::principal::{self, Principal}; -use crate::{ - CanisterCallRequest, SetEnvironmentVariableRequest, SyncExecInput, canister_call, - canister_set_environment_variable, -}; +use crate::{CanisterCallRequest, SyncExecInput, canister_call}; use crate::{exact, fs, interface, number}; /// Run the entry script with all capabilities wired in. Returns the plugin's @@ -163,10 +160,7 @@ impl RejectionLog { /// Resolve the entry script to a `(name for error messages, source)` pair. fn entry_script(input: &SyncExecInput) -> Result<(String, String), String> { let field = input.fields.iter().find(|f| f.name == "script"); - let mut files = input - .files - .iter() - .filter(|f| f.key.as_deref() == Some("script")); + let mut files = input.files.iter().filter(|f| f.key == "script"); let file = files.next(); if let Some(extra) = files.next() { @@ -199,7 +193,6 @@ fn install(ctx: &Ctx<'_>, input: &SyncExecInput) -> JsResult<()> { exact::register(ctx)?; register_output(ctx)?; register_canister_calls(ctx)?; - register_environment(ctx)?; candid::register(ctx)?; interface::register(ctx)?; register_encoding(ctx)?; @@ -445,100 +438,6 @@ fn name_hint(ctx: &Ctx<'_>, principal: &str) -> String { } } -// --------------------------------------------------------------------------- -// Canister environment variables -// --------------------------------------------------------------------------- - -/// Register `canisterSetenv`. -fn register_environment(ctx: &Ctx<'_>) -> JsResult<()> { - ctx.globals().set( - "canisterSetenv", - Function::new(ctx.clone(), canister_setenv_js)?, - ) -} - -/// `canisterSetenv(receiver, name, value, options)` — the receiver first, the -/// way a call shorthand names its own. -/// -/// Sets one of the receiver's runtime environment variables, leaving its other -/// variables — and the rest of its settings — as they are. The receiver is -/// `self` or the name of a canister listed in the step's `canisters:` (see -/// [`resolve_target`]). The options are `{ direct }` and may be omitted or -/// `null`; `direct` is false by default, which lets the proxy make the update -/// when one is configured. -fn canister_setenv_js<'js>( - ctx: Ctx<'js>, - receiver: Value<'js>, - name: String, - value: Value<'js>, - options: OptArg>, -) -> JsResult<()> { - let (target, _) = resolve_target(&ctx, Some(&receiver), "canisterSetenv")?; - let value = setenv_value(&ctx, &value)?; - let direct = setenv_direct(&ctx, options)?; - - let req = SetEnvironmentVariableRequest { - target, - name, - value, - direct, - }; - canister_set_environment_variable(&req) - .map_err(|e| throw(&ctx, &format!("canisterSetenv failed: {e}"))) -} - -/// The trailing options, whose one field is `direct`. Everything else about the -/// update is positional, so a field this does not know is a mistake worth naming -/// rather than a setting silently dropped. -fn setenv_direct<'js>(ctx: &Ctx<'js>, options: OptArg>) -> JsResult { - let Some(options) = options.0.filter(|v| !v.is_null() && !v.is_undefined()) else { - return Ok(false); - }; - let Some(options) = options.as_object() else { - return Err(throw( - ctx, - &format!( - "canisterSetenv: options are an object with a `direct` field, got {}", - convert::type_name(&options), - ), - )); - }; - - let unknown: Vec = options - .keys::() - .flatten() - .filter(|key| key != "direct") - .map(|key| format!("`{key}`")) - .collect(); - if !unknown.is_empty() { - return Err(throw( - ctx, - &format!( - "canisterSetenv: `direct` is the only option, got {}", - unknown.join(", "), - ), - )); - } - Ok(options.get::<_, Option>("direct")?.unwrap_or(false)) -} - -/// The value to set, which is a string: the canister reads the variable back -/// verbatim, so how a value that is not one renders is the script's to say -/// rather than a coercion's to guess. -fn setenv_value<'js>(ctx: &Ctx<'js>, value: &Value<'js>) -> JsResult { - match value.as_string() { - Some(text) => text.to_string(), - None => Err(throw( - ctx, - &format!( - "canisterSetenv: a value is a string, got {}; convert it first — `String(x)`, or \ - `x.toText()` for a Principal", - convert::type_name(value), - ), - )), - } -} - // --------------------------------------------------------------------------- // Encoding helpers, for what the engine itself has no answer to. JSON is native // (`JSON.parse`/`JSON.stringify`), and so are hex and base64 — a `Uint8Array` @@ -641,6 +540,16 @@ fn inject_inputs(ctx: &Ctx<'_>, input: &SyncExecInput) -> JsResult<()> { globals.set("identityId", input.identity_principal.clone())?; globals.set("identity", Principal::from(identity))?; + // Where the network is reached. The plugin has no sockets, so these are for + // composing a URL to hand to a canister or to print, not for fetching. + globals.set("apiUrl", input.api_url.clone())?; + match &input.gateway_url { + Some(url) => globals.set("gatewayUrl", url.clone())?, + // Explicitly `null`, so a network with no gateway reads as a value the + // host passed rather than as an unset global — as `proxy` does below. + None => globals.set("gatewayUrl", Value::new_null(ctx.clone()))?, + } + match &input.proxy_canister_id { Some(text) => { let p = CandidPrincipal::from_text(text).map_err(|e| { @@ -662,16 +571,16 @@ fn inject_inputs(ctx: &Ctx<'_>, input: &SyncExecInput) -> JsResult<()> { "files", string_map(ctx, input.files.iter().map(|f| (&f.name, &f.content)))?, )?; - // The manifest keys `dirs:`/`files:` were declared under, if any, grouped for - // lookup: a key maps to every path declared beneath it, in declaration order. - // Plain-list entries carry no key and appear only in `dirs`/`files`. + // The manifest keys the `files:` entries were declared under, grouped for + // lookup: a key maps to every path declared beneath it, in declaration + // order. Every entry has one, split between the two by what is on disk. globals.set( "dirKeys", - group_by_key(ctx, input.dirs.iter().map(|d| (d.key.as_deref(), &d.path)))?, + group_by_key(ctx, input.dirs.iter().map(|d| (&d.key, &d.path)))?, )?; globals.set( "fileKeys", - group_by_key(ctx, input.files.iter().map(|f| (f.key.as_deref(), &f.name)))?, + group_by_key(ctx, input.files.iter().map(|f| (&f.key, &f.name)))?, )?; globals.set( "fields", @@ -699,21 +608,20 @@ fn string_map<'js, 'a>( Ok(obj) } -/// Group declared paths by the manifest map key they were declared under, -/// dropping the entries that have none. Each key maps to an array of paths in -/// declaration order, since one key may name several paths. +/// Group declared paths by the manifest map key they were declared under. Each +/// key maps to an array of paths in declaration order, since one key may name +/// several paths. fn group_by_key<'js, 'a>( ctx: &Ctx<'js>, - entries: impl Iterator, &'a String)>, + entries: impl Iterator, ) -> JsResult> { // Grouped in Rust first, so the keys are written to the object exactly once // and never read back through its prototype chain. let mut grouped: Vec<(&str, Vec<&str>)> = Vec::new(); for (key, path) in entries { - let Some(key) = key else { continue }; match grouped.iter_mut().find(|(k, _)| *k == key) { Some((_, paths)) => paths.push(path), - None => grouped.push((key, vec![path.as_str()])), + None => grouped.push((key.as_str(), vec![path.as_str()])), } } @@ -769,34 +677,36 @@ mod tests { use crate::{DirInput, FieldInput, FileInput}; /// A step declaring the entry script under the `script` file key, two files - /// under a shared `seed` key, and one keyed and one plain-list directory. + /// under a shared `seed` key, and two directories. fn input(script: &str) -> SyncExecInput { SyncExecInput { canister_id: "ryjl3-tyaaa-aaaaa-aaaba-cai".to_string(), environment: "local".to_string(), + api_url: "http://127.0.0.1:4943/".to_string(), + gateway_url: Some("http://localhost:4943/".to_string()), dirs: vec![ DirInput { - key: Some("assets".into()), + key: "assets".into(), path: "assets".into(), }, DirInput { - key: None, - path: "plain".into(), + key: "vendor".into(), + path: "vendor".into(), }, ], files: vec![ FileInput { - key: Some("script".into()), + key: "script".into(), name: "sync.js".into(), content: script.into(), }, FileInput { - key: Some("seed".into()), + key: "seed".into(), name: "a.json".into(), content: "1".into(), }, FileInput { - key: Some("seed".into()), + key: "seed".into(), name: "b.json".into(), content: "2".into(), }, @@ -824,19 +734,31 @@ mod tests { ("identityId", "identityId === `${identity}`"), ("environment", "environment === 'local'"), ("proxy", "proxy === null"), + ("apiUrl", "apiUrl === 'http://127.0.0.1:4943/'"), + ("gatewayUrl", "gatewayUrl === 'http://localhost:4943/'"), ("fields", "fields.mode === 'fast'"), ("canisterIds", "Object.keys(canisterIds).length === 0"), ]); } + /// A network with no HTTP gateway leaves `gatewayUrl` null rather than + /// unset, the way an absent proxy does. + #[test] + fn a_network_without_a_gateway_reports_none() { + let mut input = input("if (gatewayUrl !== null) throw 'gatewayUrl';"); + input.gateway_url = None; + run(input).unwrap(); + } + #[test] fn declared_dirs_and_files_are_visible() { assert_script(&[ - // Plain-list entries appear in `dirs`/`files` but under no key. - ("dirs", "JSON.stringify(dirs) === '[\"assets\",\"plain\"]'"), + // Every entry carries the key it was declared under, and the host + // splits them by what it found on disk. + ("dirs", "JSON.stringify(dirs) === '[\"assets\",\"vendor\"]'"), ( "dirKeys", - "JSON.stringify(dirKeys) === '{\"assets\":[\"assets\"]}'", + "JSON.stringify(dirKeys) === '{\"assets\":[\"assets\"],\"vendor\":[\"vendor\"]}'", ), // One key may name several files, and the entry script stays visible. ( @@ -854,7 +776,7 @@ mod tests { #[test] fn script_field_is_an_alternative_to_a_script_file() { let mut input = input(""); - input.files.retain(|f| f.key.as_deref() != Some("script")); + input.files.retain(|f| f.key != "script"); input.fields.push(FieldInput { name: "script".into(), value: "if (Object.keys(fileKeys).length !== 1) throw 'fileKeys';".into(), @@ -876,7 +798,7 @@ mod tests { fn a_script_key_naming_two_files_is_an_error() { let mut input = input(""); input.files.push(FileInput { - key: Some("script".into()), + key: "script".into(), name: "other.js".into(), content: String::new(), }); @@ -886,7 +808,7 @@ mod tests { #[test] fn declaring_no_script_is_an_error() { let mut input = input(""); - input.files.retain(|f| f.key.as_deref() != Some("script")); + input.files.retain(|f| f.key != "script"); assert!(run(input).unwrap_err().contains("no script provided")); } @@ -915,33 +837,6 @@ mod tests { } } - /// `canisterSetenv` names its receiver first too. The update needs a host - /// to make it, so what a test reaches is the checking that precedes it. - #[test] - fn setenv_names_its_receiver_first() { - for (script, expected) in [ - ( - "canisterSetenv('ryjl3-tyaaa-aaaaa-aaaba-cai', 'SEEDED_BY', 'local');", - "canisterSetenv: a target is `self` or the name of a canister listed", - ), - ( - "canisterSetenv(self, 'SEEDED_BY', 7);", - "canisterSetenv: a value is a string, got a number", - ), - ( - "canisterSetenv(self, 'SEEDED_BY', undefined);", - "canisterSetenv: a value is a string, got undefined", - ), - ( - "canisterSetenv(self, 'SEEDED_BY', 'local', { target: 'ledger' });", - "canisterSetenv: `direct` is the only option, got `target`", - ), - ] { - let reported = crate::testing::error(script); - assert!(reported.contains(expected), "{script}\n{reported}"); - } - } - #[test] fn a_thrown_value_becomes_the_step_error() { let err = run(input("throw 'nope';")).unwrap_err(); diff --git a/src/fs.rs b/src/fs.rs index 3b47e05..ba5722e 100644 --- a/src/fs.rs +++ b/src/fs.rs @@ -1,12 +1,12 @@ //! Read-only filesystem access, over the directories the manifest step -//! declared under `dirs:`. +//! declared under `files:`. //! //! QuickJS has no filesystem of its own, so these are plain globals backed by //! `std::fs`, which under `wasm32-wasip2` reaches exactly the directories the -//! host preopened — every declared `dirs:` entry, at the path it was declared -//! at, and nothing else. Writes are absent because those preopens are -//! read-only; a script that needs to hand something back makes a canister call -//! with it. +//! host preopened — every `files:` entry that turned out to be a directory, at +//! the path it was declared at, and nothing else. Writes are absent because +//! those preopens are read-only; a script that needs to hand something back +//! makes a canister call with it. //! //! ```js //! for (const name of readDir("assets")) { @@ -226,7 +226,7 @@ fn fail(ctx: &Ctx<'_>, what: &str, path: &str, err: &std::io::Error) -> rquickjs /// The advice an unreadable path deserves. /// -/// The filesystem holds the directories the step declared under `dirs:` and +/// The filesystem holds the directories the step declared under `files:` and /// nothing else, so a path outside every one of them is the usual reason a read /// fails — and it fails with a WASI error about preopened descriptors, which /// says nothing about the manifest the reader would have to fix. A path that @@ -257,9 +257,10 @@ fn path_hint(ctx: &Ctx<'_>, path: &str) -> String { return String::new(); } match dirs.len() { - 0 => "; the step declares no `dirs:`, so no path is readable".to_string(), + 0 => "; the step's `files:` declares no directory, so no path is readable".to_string(), _ => format!( - "; the step's `dirs:` declares {}, and a path outside those is not readable", + "; the step's `files:` declares the directories {}, and a path outside those is not \ + readable", dirs.iter() .map(|d| format!("'{d}'")) .collect::>() @@ -428,14 +429,14 @@ mod tests { reported.contains("readFile('elsewhere/data.json') failed"), "{reported}" ); - assert!(reported.contains("declares no `dirs:`"), "{reported}"); + assert!(reported.contains("declares no directory"), "{reported}"); } #[test] fn a_declared_file_is_reported_as_one_the_host_passed_inline() { let mut input = testing::input("readFile('seed.json');"); input.files.push(crate::FileInput { - key: None, + key: "seed".into(), name: "seed.json".into(), content: "{}".into(), }); @@ -447,7 +448,7 @@ mod tests { fn a_path_under_a_declared_dir_is_reported_as_it_failed() { let mut input = testing::input("readDir('assets/missing');"); input.dirs.push(crate::DirInput { - key: None, + key: "assets".into(), path: "assets".into(), }); let reported = crate::engine::run(input).unwrap_err(); @@ -457,6 +458,6 @@ mod tests { ); // The step declared the tree the path sits in, so there is no manifest // advice to give — only the failure itself. - assert!(!reported.contains("`dirs:`"), "{reported}"); + assert!(!reported.contains("`files:`"), "{reported}"); } } diff --git a/src/lib.rs b/src/lib.rs index 00baff6..0fbefb7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,9 +2,9 @@ //! being synced. //! //! The plugin exposes to the script the same capabilities a native sync plugin -//! has — calling the target canister, reading its metadata sections, setting its -//! environment variables, the sync inputs, and read-only filesystem access to -//! the manifest's `dirs` — plus +//! has — calling the target canister, reading its metadata sections, the sync +//! inputs, and read-only filesystem access to the directories the manifest +//! declared under `files:` — plus //! Candid, principal, and encoding helpers convenient for canister work. See //! [`engine`] for the wiring, [`candid`] for how an argument is written, and //! [`interface`] for calling a method by name against the types the callee diff --git a/src/testing.rs b/src/testing.rs index 5f7edbf..27230b7 100644 --- a/src/testing.rs +++ b/src/testing.rs @@ -8,6 +8,8 @@ pub fn input(script: &str) -> SyncExecInput { SyncExecInput { canister_id: "ryjl3-tyaaa-aaaaa-aaaba-cai".to_string(), environment: "local".to_string(), + api_url: "http://127.0.0.1:4943/".to_string(), + gateway_url: None, dirs: vec![], files: vec![], fields: vec![FieldInput { diff --git a/sync-plugin.wit b/sync-plugin.wit index cb1107d..ce72848 100644 --- a/sync-plugin.wit +++ b/sync-plugin.wit @@ -7,10 +7,11 @@ interface types { /// A directory the host made readable for the plugin. record dir-input { - /// The map key this directory was declared under in the manifest, or - /// `none` when `dirs` was written as a plain list. Several entries share - /// one key when a key maps to a list of directories. - key: option, + /// The map key this directory was declared under in the manifest. Every + /// entry has one: `files:` must be written as a map of name → path(s) + /// for a plugin built against this interface. The key is *non-unique* — + /// several entries share one when a key maps to a list of paths. + key: string, /// Path of the directory as declared in the manifest (relative to the /// canister directory). It is readable at this same path. Entries may /// repeat a path or name a directory inside another entry's; the host @@ -21,10 +22,9 @@ interface types { /// A file the host read on behalf of the plugin. record file-input { - /// The map key this file was declared under in the manifest, or `none` - /// when `files` was written as a plain list. Several entries share one - /// key when a key maps to a list of files. - key: option, + /// The map key this file was declared under in the manifest, on the + /// same terms as `dir-input.key`. + key: string, /// Path of the file as declared in the manifest (relative to /// the canister directory). name: string, @@ -92,16 +92,26 @@ interface types { canister-id: string, /// Name of the environment being synced (e.g. "production", "local"). environment: string, - /// Directories declared in the manifest step's `dirs` setting. - /// The host makes each entry readable via WASI preopens; the plugin - /// traverses them with standard `wasi:filesystem` (e.g. Rust's `std::fs`). - /// Each entry carries the map key it was declared under, if any (see - /// `dir-input`). + /// URL of the network's API endpoint: where the host submits the + /// canister calls it makes on the plugin's behalf. The plugin has no + /// sockets of its own, so this is something to compose a URL from or + /// hand to a canister, not something to fetch. Normalized, so a URL + /// with no path carries a trailing slash ("http://127.0.0.1:4943/"). + api-url: string, + /// URL of the network's HTTP gateway, which serves canisters over + /// HTTP, normalized the same way. `none` when the network exposes no + /// gateway. + gateway-url: option, + /// Those entries of the manifest step's `files` setting that name a + /// directory, in the order they were written. The manifest declares + /// directories and files together under `files:`; the host splits them + /// by what is on disk. It makes each entry here readable via WASI + /// preopens; the plugin traverses them with standard `wasi:filesystem` + /// (e.g. Rust's `std::fs`). dirs: list, - /// Files declared in the manifest step's `files` setting, read by - /// the host and passed inline. The plugin decides how to use them. - /// Each entry carries the map key it was declared under, if any (see - /// `file-input`). + /// Those entries of the manifest step's `files` setting that name a + /// file, in the order they were written. The host reads each and passes + /// it inline; the plugin decides how to use them. files: list, /// Key-value fields declared in the manifest step's `fields` setting, /// passed inline. The plugin decides how to use them. @@ -165,31 +175,11 @@ interface types { /// configured the read goes directly either way. direct: bool, } - - /// A request to set one of a canister's environment variables. - record set-environment-variable-request { - /// Which canister to set the variable on. 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 environment variable, spelled as the canister reads it. - name: string, - /// Value to set it to, replacing whatever value the target currently - /// has under this name. - value: string, - /// When true, the update is signed by the sync identity, which must - /// control the target for it to be accepted. When false (the default), - /// it is made by the proxy canister configured via `--proxy`, and it is - /// the proxy that must control the target — the same arrangement - /// proxied update calls rely on. With no proxy configured the update is - /// signed by the sync identity either way. - direct: bool, - } } /// The complete interface of a sync plugin. world sync-plugin { - use types.{sync-exec-input, canister-call-request, metadata-section-request, set-environment-variable-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 @@ -218,14 +208,6 @@ world sync-plugin { /// read. The plugin is responsible for interpreting the bytes. import canister-metadata-section: func(req: metadata-section-request) -> result>, string>; - /// Set an environment variable on a canister, leaving the target's other - /// environment variables — and the rest of its settings — as they are. - /// 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. - /// Returns an error message on failure. - import canister-set-environment-variable: func(req: set-environment-variable-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. //