Skip to content
Open
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
14 changes: 13 additions & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ cargo fmt && cargo clippy # Run after changes pass tests
- **`crates/icp-cli`**: Main CLI binary (`icp`): argument parsing, command implementations, and all terminal presentation
- **`crates/icp-app`**: Everything about the machine the tool runs on: identities and the keyring, user settings, the global directory layout, the package cache, local networks and the launcher that runs them, telemetry, offline message signing, and the operations that act on a canister by principal
- **`crates/icp-project`**: Everything about a project: the project model, manifest loading and consolidation, canister management, and the operations that build, install, sync and deploy
- **`crates/icp-sync-plugin`**: The wasmtime Component Model runtime for sync plugins — one implementation of `icp-project`'s plugin-runner seam
- **`crates/icp-events`**: Typed progress events passed from operations to the CLI's renderers
- **`crates/icp-canister-interfaces`**: Canister interface definitions for ICP system canisters
- **`crates/schema-gen`**: JSON schema generation for manifest validation
Expand All @@ -42,13 +43,24 @@ even though it takes a principal, and `icp-app` calls down into it.

`icp-project` is meant to end up runnable inside a canister, so it must not
reach the host directly. What it needs from the machine it asks for through a
trait declared there and implemented in `icp-app`:
trait declared there and implemented elsewhere — in `icp-app`, or in
`icp-sync-plugin`, which likewise depends on `icp-project` and never the
reverse:

- `files::FileSystem` — the files a project is made of
- `calls::CanisterCalls` — submitting a call, and reading a certified fact
- `network::Access` — a network's endpoints, root key and friendly domains
- `canister::wasm::Fetch` — a wasm module a manifest names by URL
- `canister::recipe::Resolve` — a recipe's Handlebars template
- `canister::sync::plugin::Run` — running one sync plugin
- `canister::sync::script::ScriptRunner` — running one sync script
- `store_id::Access` / `store_artifact::Access` — the project's `.icp` stores
- `host::Observe` — what resolution turned up, for telemetry

Host implementations of the first, the last two stores and the script runner
ship in `icp-project` itself behind the default-on `host` feature; the rest
have no implementation there at all.

Because those are implemented across a crate boundary, their error types carry
their cause boxed and pass it through with `#[snafu(transparent)]`, which leaves
the wrapper out of the source chain so the cause is reported once rather than
Expand Down
9 changes: 2 additions & 7 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 crates/icp-app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ ic-ledger-types = { workspace = true }
ic-management-canister-types = { workspace = true }
ic-utils = { workspace = true }
icp-project = { workspace = true }
icp-sync-plugin = { workspace = true }
icp-canister-interfaces = { workspace = true }
icp-events = { workspace = true }
icrc-ledger-types = { workspace = true }
Expand Down
199 changes: 184 additions & 15 deletions crates/icp-app/src/calls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ use candid::{Encode, Nat, Principal};
use ic_agent::{
Agent, AgentError,
agent::{CallResponse, EffectiveId, SubnetType},
hash_tree::{Label, LookupResult},
};
use ic_management_canister_types::{CanisterMetadataArgs, CanisterMetadataResult};
use icp_canister_interfaces::proxy::{ProxyArgs, ProxyResult};
use icp_project::calls::{Authority, Call, CallError, CanisterCalls, RouteTo};

Expand Down Expand Up @@ -117,6 +119,121 @@ impl AgentCalls {
.is_ok_and(|controllers| controllers.is_some())
}

/// Ask the target's subnet to certify a metadata section, reporting only
/// what the certificate proves.
///
/// The section path is requested together with `controllers`, because a
/// metadata path proven absent is equally what a canister that was never
/// created looks like — `controllers` is written at creation, so its
/// presence is what separates the two. A canister with no module installed
/// has no sections at all, which the certificate reports as an absent path
/// under a canister that exists, and so as `Ok(None)`.
async fn certified_metadata_section(
&self,
canister: Principal,
path: &str,
) -> Result<Option<Vec<u8>>, CallError> {
let metadata_path: Vec<Label<Vec<u8>>> = vec![
"canister".into(),
Label::from_bytes(canister.as_slice()),
"metadata".into(),
path.into(),
];
let controllers_path: Vec<Label<Vec<u8>>> = vec![
"canister".into(),
Label::from_bytes(canister.as_slice()),
"controllers".into(),
];
let method = "read_state(metadata)";
let cert = self
.agent
.read_state_raw(
vec![metadata_path.clone(), controllers_path.clone()],
canister,
)
.await
.map_err(|err| Self::wrap(canister, method, err))?;

let unproven = |about: String| {
Err(CallError::Rejected {
canister,
method: method.to_owned(),
code: None,
message: format!("the certificate proves nothing about {about}"),
})
};
match cert.tree.lookup_path(&metadata_path) {
LookupResult::Found(bytes) => Ok(Some(bytes.to_vec())),
LookupResult::Absent => match cert.tree.lookup_path(&controllers_path) {
LookupResult::Found(_) => Ok(None),
LookupResult::Absent => Err(CallError::Rejected {
canister,
method: method.to_owned(),
code: None,
message: format!("canister {canister} does not exist"),
}),
_ => unproven(format!("canister {canister}")),
},
// Not proof of absence, just a certificate that says nothing about
// the path — reporting the section missing off this would be a
// guess, and a private section is exactly what it looks like.
_ => unproven(format!("section `{path}` of canister {canister}")),
}
}

/// Read a metadata section by having the proxy ask the management canister
/// for it, which is what reaches a section private to the proxy's control.
///
/// `read_state` is not a canister method, so it cannot be forwarded; the
/// management canister's `canister_metadata` can. It does not distinguish
/// an absent section from one the caller may not have, so a reply claiming
/// absence is confirmed against a certificate before it is reported as
/// one.
async fn metadata_through_proxy(
&self,
proxy: Principal,
canister: Principal,
path: &str,
) -> Result<Option<Vec<u8>>, CallError> {
let arg = Encode!(&CanisterMetadataArgs {
canister_id: canister,
name: path.to_owned(),
})
.map_err(|e| CallError::failed(canister, "canister_metadata", e))?;
let call = Call::management("canister_metadata", canister, arg);

match self.through_proxy(proxy, &call).await {
Ok(reply) => {
let (metadata,): (CanisterMetadataResult,) = candid::decode_args(&reply)
.map_err(|e| CallError::failed(canister, "canister_metadata", e))?;
Ok(Some(metadata.value))
}
Err(err) => {
let claims_absent = err
.message()
.is_some_and(|message| rejected_as_no_such_section(message, canister, path));
if !claims_absent {
return Err(err);
}
// The management canister says the same thing about a section
// that isn't there and one that is private to someone else, so
// its word alone cannot be reported as absence. Only a
// certificate proves the section absent.
match self.certified_metadata_section(canister, path).await? {
None => Ok(None),
Some(_) => Err(CallError::Rejected {
canister,
method: "canister_metadata".to_owned(),
code: None,
message: format!(
"canister {canister} does not let {proxy} read section `{path}`"
),
}),
}
}
}
}

/// A subnet-scoped update: routed to a subnet rather than to any canister
/// on it, which the agent only exposes through a signed submission.
async fn to_subnet(&self, subnet: Principal, call: &Call) -> Result<Vec<u8>, CallError> {
Expand Down Expand Up @@ -152,6 +269,28 @@ impl AgentCalls {
}
}

/// Whether the management canister rejected a metadata read by claiming the
/// target has no such section, rather than because the read itself failed.
///
/// The claim is not proof: the same rejection covers a section private to
/// someone other than the proxy, so the caller confirms it against a
/// certificate. A proxied read comes back as reject text with no code
/// attached, so recognizing the claim at all means matching the replica's
/// wording. Both sentences name the canister and one names the section, so the
/// match is anchored on the values this call supplied rather than on a loose
/// phrase that text relayed from elsewhere might happen to contain. A reword
/// upstream turns the claim into an error rather than into a wrong answer.
fn rejected_as_no_such_section(message: &str, canister: Principal, path: &str) -> bool {
// A canister with no module installed has no sections at all, so it reports
// absence in its own words. The certificate says the same thing about it:
// the metadata path is absent while the canister itself is there.
message.contains(&format!(
"The canister {canister} has no Wasm module and hence no metadata is available."
)) || message.contains(&format!(
"The canister {canister} has no metadata section with the name {path}."
))
}

/// Wraps a resolved agent as the caller this workspace's operations take,
/// forwarding through `proxy` when one was asked for.
pub fn calls(
Expand Down Expand Up @@ -213,23 +352,13 @@ impl CanisterCalls for AgentCalls {
&self,
canister: Principal,
path: &str,
authority: Authority,
) -> Result<Option<Vec<u8>>, CallError> {
match self
.agent
.read_state_canister_metadata(canister, path)
.await
{
Ok(bytes) => Ok(Some(bytes)),
Err(err) => {
// A path the certificate will not certify looks the same as a
// canister that was never created, which is the error this
// reports rather than "no such section".
if self.exists(canister).await {
Ok(None)
} else {
Err(Self::wrap(canister, "read_state(metadata)", err))
}
match self.proxy {
Some(proxy) if authority == Authority::Mediated => {
self.metadata_through_proxy(proxy, canister, path).await
}
_ => self.certified_metadata_section(canister, path).await,
}
}

Expand Down Expand Up @@ -314,4 +443,44 @@ mod tests {
assert!(err.is_rejection());
assert!(!err.is_transient());
}

/// The replica's own wording for the two ways a target reports it has no
/// section, copied from `CanisterManagerError` in the IC repo. Both are
/// absence, not failure, so both must reach the plugin as `none`.
#[test]
fn management_canister_absence_rejects_are_recognized() {
let target = Principal::from_text("aaaaa-aa").unwrap();
let other = Principal::from_text("2vxsx-fae").unwrap();

let no_module = format!(
"Proxy call failed: The canister {target} has no Wasm module and hence no metadata is available."
);
let no_section = format!(
"Proxy call failed: The canister {target} has no metadata section with the name candid:service."
);
assert!(rejected_as_no_such_section(
&no_module,
target,
"candid:service"
));
assert!(rejected_as_no_such_section(
&no_section,
target,
"candid:service"
));

// A section by another name, a canister other than the one asked about,
// and an unrelated failure are all reads that failed.
assert!(!rejected_as_no_such_section(&no_section, target, "dfx"));
assert!(!rejected_as_no_such_section(
&no_module,
other,
"candid:service"
));
assert!(!rejected_as_no_such_section(
&format!("Proxy call failed: Canister {target} not found."),
target,
"candid:service"
));
}
}
5 changes: 4 additions & 1 deletion crates/icp-app/src/context/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,10 @@ pub fn initialize(
let builder = Arc::new(Builder::new(wasm.clone(), files.clone()));

// Canister syncer
let syncer = Arc::new(Syncer::host(wasm.clone()));
let syncer = Arc::new(Syncer::host(
wasm.clone(),
Arc::new(icp_sync_plugin::Wasmtime),
));

// Project loader
let pload = ProjectLoadImpl {
Expand Down
2 changes: 1 addition & 1 deletion crates/icp-cli/src/commands/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow:
// command having to await it phase by phase.
let mut report = DeployReport::default();
let result = rendered(ctx.debug, async |reporter| {
deploy(&ctx.host, &calls, &agent, &params, reporter, &mut report).await
deploy(&ctx.host, &calls, &params, reporter, &mut report).await
})
.await;

Expand Down
4 changes: 2 additions & 2 deletions crates/icp-cli/src/commands/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ pub(crate) async fn exec(ctx: &Context, args: &SyncArgs) -> Result<(), anyhow::E
let agent = ctx
.get_agent_for_env(&identity_selection, &environment_selection)
.await?;
let calls = icp_app::calls::calls(agent.clone(), args.proxy)?;
let calls = icp_app::calls::calls(agent, args.proxy)?;

// Prepare list of canisters with their info for syncing
let sync_canisters = try_join_all(cnames.iter().map(|name| async {
Expand Down Expand Up @@ -135,7 +135,7 @@ pub(crate) async fn exec(ctx: &Context, args: &SyncArgs) -> Result<(), anyhow::E
rendered(ctx.debug, async |reporter| {
sync_many(
ctx.host.syncer.clone(),
agent,
calls,
sync_canisters,
project_dir,
environment_selection.name().to_owned(),
Expand Down
7 changes: 1 addition & 6 deletions crates/icp-project/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,11 @@ futures = { workspace = true }
glob = { workspace = true }
handlebars = { workspace = true }
hex = { workspace = true }
ic-agent = { workspace = true }
ic-ledger-types = { workspace = true }
ic-management-canister-types = { workspace = true }
ic-utils = { workspace = true }
icrc-ledger-types = { workspace = true }
icp-canister-interfaces = { workspace = true }
icp-events = { workspace = true }
icp-sync-plugin = { workspace = true }
indexmap = { workspace = true }
indoc = { workspace = true }
itertools = { workspace = true }
Expand All @@ -65,9 +62,7 @@ strum = { workspace = true }
tar = { workspace = true }
time = { workspace = true }
# `io-std` is listed for `tokio::io::stdout`/`stderr`; feature unification with other
# crates supplies it anyway, so dropping it would not fail the build. `rt-multi-thread`
# is needed by `block_in_place` in `canister::sync::plugin` and arrives via the
# workspace `tokio` entry.
# crates supplies it anyway, so dropping it would not fail the build.
tokio = { workspace = true, features = ["sync", "macros", "rt", "time", "io-util", "io-std", "process", "signal"] }
tracing = { workspace = true }
url = { workspace = true }
Expand Down
Loading
Loading