Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ ic-utils = { version = "0.49.1" }
icp-app = { path = "crates/icp-app" }
icp-canister-interfaces = { path = "crates/icp-canister-interfaces" }
icp-events = { path = "crates/icp-events" }
icp-extension = { path = "crates/icp-extension" }
icp-project = { path = "crates/icp-project" }
icp-sync-plugin = { path = "crates/icp-sync-plugin" }
ic-identity-hsm = "0.49.1"
Expand Down
32 changes: 32 additions & 0 deletions crates/icp-extension/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
[package]
name = "icp-extension"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true

[dependencies]
candid.workspace = true
# Only for the canister-call seam an extension's calls go through.
icp-project = { path = "../icp-project", default-features = false }
semver.workspace = true
serde.workspace = true
snafu.workspace = true
# `rt-multi-thread` for `block_in_place`, which the runtime needs because a
# wasm import cannot suspend while a canister call is in flight.
tokio = { workspace = true, features = ["rt-multi-thread"] }
url.workspace = true
wasmtime.workspace = true
wasmtime-wasi.workspace = true

[build-dependencies]
camino.workspace = true

[dev-dependencies]
async-trait.workspace = true
camino.workspace = true
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }

[lints]
workspace = true
54 changes: 54 additions & 0 deletions crates/icp-extension/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
use camino::Utf8PathBuf;
use std::process::Command;

fn main() {
println!("cargo:rerun-if-changed=extension.wit");
println!("cargo:rerun-if-changed=tests/fixtures/hello-extension/src/lib.rs");
println!("cargo:rerun-if-changed=tests/fixtures/hello-extension/Cargo.toml");

if wasm32_wasip2_is_installed() {
build_test_fixture("hello-extension", "hello_extension", "HELLO_EXTENSION_WASM");
}
}

fn wasm32_wasip2_is_installed() -> bool {
let Ok(output) = Command::new("rustc").args(["--print", "sysroot"]).output() else {
return false;
};
if !output.status.success() {
return false;
}
let sysroot = String::from_utf8_lossy(&output.stdout);
Utf8PathBuf::from(sysroot.trim())
.join("lib/rustlib/wasm32-wasip2")
.exists()
}

fn build_test_fixture(crate_dir: &str, wasm_stem: &str, env_var: &str) {
let manifest_dir = Utf8PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
let out_dir = Utf8PathBuf::from(std::env::var("OUT_DIR").unwrap());
let fixture_manifest = manifest_dir.join(format!("tests/fixtures/{crate_dir}/Cargo.toml"));
let fixture_target_dir = out_dir.join(format!("fixture-target/{crate_dir}"));
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());

let status = Command::new(&cargo)
.args([
"build",
"--target",
"wasm32-wasip2",
"--release",
"--locked",
"--manifest-path",
fixture_manifest.as_str(),
"--target-dir",
fixture_target_dir.as_str(),
])
.status()
.expect("failed to spawn cargo build for test fixture");
assert!(
status.success(),
"cargo build --target wasm32-wasip2 failed for test fixture {crate_dir}"
);
let wasm = fixture_target_dir.join(format!("wasm32-wasip2/release/{wasm_stem}.wasm"));
println!("cargo:rustc-env={env_var}={wasm}");
}
171 changes: 171 additions & 0 deletions crates/icp-extension/extension.wit
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package icp:extension@0.1.0;

/// Types shared between icp-cli and extensions.
interface types {
/// Whether a canister call is an update or a query.
enum call-type { update, query }

/// A flag icp-cli owns and attaches to a command that asks for it. The
/// extension never parses these; it receives what they resolved to in
/// `context`.
enum standard-opt {
/// `--identity`. Without it a command runs as the anonymous identity.
identity,
/// `-e/--environment`, `-n/--network` and `-k/--root-key`. Without it
/// a command has no network, and every canister call fails.
network,
}

/// How many values an argument takes.
enum arg-kind {
/// A switch: present or absent, no value.
%flag,
/// One value.
value,
/// Any number of values; an option may be repeated.
values,
}

/// One argument of a command. Neither `long` nor `short` makes it
/// positional, in the order declared.
record arg {
/// The key the parsed value comes back under in `matched.values`.
name: string,
long: option<string>,
short: option<char>,
kind: arg-kind,
required: bool,
default-value: option<string>,
/// When non-empty, the only values accepted.
possible-values: list<string>,
/// The placeholder help shows for the value, e.g. `USD`.
value-name: option<string>,
help: string,
}

/// A command of the extension, addressed by its path under `icp <name>`.
/// The empty path is `icp <name>` itself. A command with commands beneath
/// it is a group: it is never run itself, takes no arguments, and is
/// declared only to give it an `about`. Undeclared parents are groups with
/// none.
record command {
path: list<string>,
about: string,
args: list<arg>,
standard: list<standard-opt>,
/// The network a command that asks for `network` uses when neither
/// `-e` nor `-n` is given, e.g. `ic` for a mainnet-only service. When
/// absent, the project's default environment is used, as for the
/// built-in commands.
default-network: option<string>,
}

/// A mainnet canister the extension calls. On mainnet a call to any
/// canister not listed here is refused; on other networks calls are
/// unrestricted. The management canister cannot be listed.
record allowed-canister {
/// Textual principal.
id: string,
/// What it is, shown to the user when they approve the install.
label: string,
}

/// What an extension is. Read once, at install.
record info {
/// The subcommand the extension is installed under: `icp <name>`.
name: string,
/// The extension's own version (semver). Informational.
version: string,
/// One line, shown in `icp --help` and `icp extension list`.
summary: string,
commands: list<command>,
canisters: list<allowed-canister>,
}

/// One parsed argument. A flag that is set has no values; one that is not
/// set is absent.
record arg-value {
name: string,
values: list<string>,
}

/// The command the user invoked, parsed by icp-cli.
record matched {
path: list<string>,
values: list<arg-value>,
}

/// An entry in the project's canister ID table.
record canister-id-entry {
name: string,
id: string,
}

/// What icp-cli resolved for the invoked command.
record context {
/// The caller principal; absent unless the command asked for
/// `identity` (it then runs as the anonymous identity).
identity-principal: option<string>,
/// The network calls go to; absent unless the command asked for
/// `network`.
network: option<string>,
/// Whether that network is the IC mainnet, decided by its root key.
mainnet: bool,
/// The environment, when the network came from one.
environment: option<string>,
/// The project's canister IDs in that environment; empty outside a
/// project or without an environment.
canister-ids: list<canister-id-entry>,
/// Whether a person is at the terminal (stdin and stdout are a TTY).
interactive: bool,
}

record canister-call-request {
/// Textual principal of the canister to call.
canister: string,
method: string,
/// Candid-encoded argument; the host forwards it unchanged.
arg: list<u8>,
call-type: call-type,
}
}

world extension {
use types.{info, matched, context, canister-call-request};

// -------------------------------------------------------------------------
// Host functions (imports) — provided by icp-cli, called by the extension
// -------------------------------------------------------------------------

/// Call a canister as the resolved identity, on the resolved network. The
/// reply is the raw Candid bytes. Fails when the command did not ask for
/// `network`; on mainnet, for a canister not in `info.canisters`; and for
/// the management canister (`aaaaa-aa`), which this version does not
/// reach.
import canister-call: func(req: canister-call-request) -> result<list<u8>, string>;

/// Ask the user a yes/no question. Returns `default` without asking when
/// nobody is at the terminal.
import confirm: func(prompt: string, default: bool) -> bool;

/// Show the user a web page to visit: icp-cli opens it in a browser when
/// it can, and prints it (with a QR code on a terminal) either way. Fails,
/// without showing anything, for anything but an absolute http(s) URL: a
/// local path or another scheme would reach the user's own applications.
import open-url: func(url: string) -> result<_, string>;

// The extension's stdout and stderr go straight to the terminal, and it
// may read the clocks and sleep. It has no filesystem, no environment
// variables, no sockets and no processes.

// -------------------------------------------------------------------------
// Extension exports — implemented by the extension, called by icp-cli
// -------------------------------------------------------------------------

/// What the extension is and which commands it has. Called at install,
/// with every import refusing.
export describe: func() -> info;

/// Run one command.
export run: func(cmd: matched, ctx: context) -> result<_, string>;
}
101 changes: 101 additions & 0 deletions crates/icp-extension/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
//! The host side of icp-cli extensions: a wasm component implementing the
//! `icp:extension` world (`extension.wit`), loaded into a wasmtime sandbox
//! with no filesystem, environment, sockets or processes.
//!
//! An extension *declares* its commands ([`Info`]); icp-cli parses them with
//! its own clap and hands the extension the result ([`Matched`]) together
//! with what it resolved for the command ([`RunContext`]). The extension's
//! only effects are the imports in [`RunHost`].

mod runtime;
mod validate;

use serde::{Deserialize, Serialize};

pub use runtime::{DescribeError, RunError, Terminal, describe, run};
pub use validate::{InvalidInfo, RESERVED_LONGS, RESERVED_SHORTS, validate};

/// Which flag set icp-cli attaches to a command. See `standard-opt`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum StandardOpt {
Identity,
Network,
}

/// How many values an argument takes. See `arg-kind`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ArgKind {
Flag,
Value,
Values,
}

/// One argument of a declared command. See `arg`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Arg {
pub name: String,
pub long: Option<String>,
pub short: Option<char>,
pub kind: ArgKind,
pub required: bool,
pub default_value: Option<String>,
pub possible_values: Vec<String>,
pub value_name: Option<String>,
pub help: String,
}

impl Arg {
pub fn is_positional(&self) -> bool {
self.long.is_none() && self.short.is_none()
}
}

/// A declared command. See `command`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Command {
pub path: Vec<String>,
pub about: String,
pub args: Vec<Arg>,
pub standard: Vec<StandardOpt>,
pub default_network: Option<String>,
}

/// A mainnet canister the extension may call. See `allowed-canister`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AllowedCanister {
pub id: candid::Principal,
pub label: String,
}

/// What an extension is: read once at install and kept beside it, so that
/// its commands can be offered without loading it. See `info`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Info {
pub name: String,
pub version: String,
pub summary: String,
pub commands: Vec<Command>,
pub canisters: Vec<AllowedCanister>,
}

/// The command the user invoked, as icp-cli parsed it. See `matched`.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Matched {
pub path: Vec<String>,
/// Each argument that was given or has a default, by name. A set flag has
/// no values.
pub values: Vec<(String, Vec<String>)>,
}

/// What icp-cli resolved for the invoked command. See `context`.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct RunContext {
pub identity_principal: Option<candid::Principal>,
pub network: Option<String>,
pub mainnet: bool,
pub environment: Option<String>,
pub canister_ids: Vec<(String, candid::Principal)>,
pub interactive: bool,
}
Loading
Loading