diff --git a/CHANGELOG.md b/CHANGELOG.md index bd34d7523..6afc5400f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ air-gapped signing, extensions # Unreleased +* feat: `--untrusted`, on `build`, `deploy` and `sync`, holds a project someone else handed over to what it may run: each build step is a pre-built module, each sync step is a plugin, and the file each names is a regular file within the project once every link is followed. Anything else is refused before the command acts on it, naming what was found. Reading is not restricted: argument files, manifests and recipes are read from wherever the manifest points, and the steps a recipe renders to are held to the same list. * chore: macOS on Intel (`x86_64-apple-darwin`) is no longer a release target. Releases ship no Intel macOS archive, the shell installer refuses that platform, and npm no longer publishes `@icp-sdk/icp-cli-darwin-x64`, so installing `@icp-sdk/icp-cli` on an Intel Mac warns of an unsupported platform. Homebrew dropped Intel macOS in September 2026, and GitHub is retiring its Intel macOS runners. v1.8.0 is the last release with an Intel macOS build. ## Experimental diff --git a/crates/icp-app/src/context/init.rs b/crates/icp-app/src/context/init.rs index b0dc0bf0d..a39edb627 100644 --- a/crates/icp-app/src/context/init.rs +++ b/crates/icp-app/src/context/init.rs @@ -12,7 +12,7 @@ use icp_project::store_artifact::ArtifactStore; use std::time::Duration; use crate::{agent, identity, identity::PasswordFunc}; -use icp_project::{Lazy, ProjectLoadImpl, host::Host, manifest, store_id}; +use icp_project::{Lazy, ProjectLoad, ProjectLoadImpl, host::Host, manifest, store_id, untrusted}; #[derive(Debug, Snafu)] pub enum ContextInitError { @@ -38,11 +38,14 @@ pub enum ContextInitError { }, } +/// With `untrusted`, the loaded project is vetted by [`icp_project::untrusted`] +/// before any command acts on it. pub fn initialize( project_root_override: Option, debug: bool, password_func: PasswordFunc, pem_session_duration: Option, + untrusted: bool, ) -> Result { // Setup global directory structure let dirs = Arc::new(Directories::new().context(DirectoriesSnafu)?); @@ -125,8 +128,11 @@ pub fn initialize( files: files.clone(), }; - let pload = Lazy::new(pload); - let pload = Arc::new(pload); + let pload: Arc = if untrusted { + Arc::new(Lazy::new(untrusted::Vetted::new(pload, files.clone()))) + } else { + Arc::new(Lazy::new(pload)) + }; // Telemetry data bag (written by subsystems, read at session finish) let telemetry_data = Arc::new(crate::telemetry_data::TelemetryData::default()); diff --git a/crates/icp-cli/src/commands/build.rs b/crates/icp-cli/src/commands/build.rs index e6dcfc876..85b226709 100644 --- a/crates/icp-cli/src/commands/build.rs +++ b/crates/icp-cli/src/commands/build.rs @@ -9,7 +9,7 @@ use tracing::info; use icp_project::operations::build::build_many; use crate::{ - options::{EnvironmentOpt, arg_struct_change_help}, + options::{EnvironmentOpt, UntrustedOpt, arg_struct_change_help}, render::rendered, }; @@ -22,6 +22,9 @@ pub(crate) struct BuildArgs { #[command(flatten)] pub(crate) environment: BuildEnvironmentOpt, + + #[command(flatten)] + pub(crate) untrusted: UntrustedOpt, } arg_struct_change_help!( diff --git a/crates/icp-cli/src/commands/deploy.rs b/crates/icp-cli/src/commands/deploy.rs index 781814947..8e6e6377e 100644 --- a/crates/icp-cli/src/commands/deploy.rs +++ b/crates/icp-cli/src/commands/deploy.rs @@ -20,7 +20,7 @@ use tracing::info; use crate::options::EnvironmentOpt; use crate::{ commands::{args::ArgsOpt, canister::create}, - options::{IdentityOpt, arg_struct_change_help}, + options::{IdentityOpt, UntrustedOpt, arg_struct_change_help}, render::rendered, }; @@ -97,6 +97,9 @@ pub(crate) struct DeployArgs { /// `upgrade_args` in the manifest. #[command(flatten)] pub(crate) args_opt: ArgsOpt, + + #[command(flatten)] + pub(crate) untrusted: UntrustedOpt, } arg_struct_change_help!( diff --git a/crates/icp-cli/src/commands/sync.rs b/crates/icp-cli/src/commands/sync.rs index a82eba23a..fcb28e048 100644 --- a/crates/icp-cli/src/commands/sync.rs +++ b/crates/icp-cli/src/commands/sync.rs @@ -12,7 +12,7 @@ use tracing::info; use icp_project::operations::{readiness::wait_until_serving_queries, sync::sync_many}; use crate::{ - options::{EnvironmentOpt, IdentityOpt}, + options::{EnvironmentOpt, IdentityOpt, UntrustedOpt}, render::rendered, }; @@ -32,6 +32,9 @@ pub(crate) struct SyncArgs { #[command(flatten)] pub(crate) identity: IdentityOpt, + + #[command(flatten)] + pub(crate) untrusted: UntrustedOpt, } pub(crate) async fn exec(ctx: &Context, args: &SyncArgs) -> Result<(), anyhow::Error> { diff --git a/crates/icp-cli/src/complete.rs b/crates/icp-cli/src/complete.rs index be3e877ee..d181d91ce 100644 --- a/crates/icp-cli/src/complete.rs +++ b/crates/icp-cli/src/complete.rs @@ -66,6 +66,7 @@ fn context() -> Option<&'static Context> { false, Arc::new(|| Err("cannot prompt while completing".to_string())), None, + false, ) .ok() }) diff --git a/crates/icp-cli/src/main.rs b/crates/icp-cli/src/main.rs index 8cf2e5e64..c449b1f1a 100644 --- a/crates/icp-cli/src/main.rs +++ b/crates/icp-cli/src/main.rs @@ -214,6 +214,7 @@ async fn run() -> Result<(), Error> { cli.debug, password_func, pem_session_duration, + command.untrusted(), )?; // Extension commands are left out of telemetry: telemetry parses against @@ -266,6 +267,21 @@ enum Invocation { Extension(icp_app::extension::Installed, ArgMatches), } +impl Invocation { + /// Whether the command was asked to treat the project as untrusted. The + /// flag belongs to the commands that act on what a project says — build, + /// deploy, sync — but it is the context every command runs in that + /// applies it, so it is read off the command before that is composed. + fn untrusted(&self) -> bool { + match self { + Invocation::BuiltIn(Command::Build(args)) => args.untrusted.untrusted, + Invocation::BuiltIn(Command::Deploy(args)) => args.untrusted.untrusted, + Invocation::BuiltIn(Command::Sync(args)) => args.untrusted.untrusted, + Invocation::BuiltIn(_) | Invocation::Extension(..) => false, + } + } +} + /// Dispatch the command to its handler. async fn dispatch(ctx: &icp_app::context::Context, command: Command) -> Result<(), Error> { match command { diff --git a/crates/icp-cli/src/options.rs b/crates/icp-cli/src/options.rs index 7a3bd4446..a691ba1af 100644 --- a/crates/icp-cli/src/options.rs +++ b/crates/icp-cli/src/options.rs @@ -40,6 +40,19 @@ impl From for IdentitySelection { } } +/// `--untrusted`, for a command that acts on what a project says: build, +/// deploy, sync. A deploy service handed a project by an agent is what asks +/// for it, and those are the commands it runs. +#[derive(Args, Clone, Debug, Default)] +pub(crate) struct UntrustedOpt { + /// Hold the project to what one someone else handed over may run: each + /// build step a pre-built module, each sync step a plugin, and what each + /// names a regular file within the project once links are followed. + /// Anything else is refused. Reading is not restricted + #[arg(long)] + pub(crate) untrusted: bool, +} + #[derive(Args, Clone, Debug, Default)] #[clap(group(ArgGroup::new("environment-select").multiple(false)))] pub(crate) struct EnvironmentOpt { diff --git a/crates/icp-cli/tests/untrusted_tests.rs b/crates/icp-cli/tests/untrusted_tests.rs new file mode 100644 index 000000000..7d447f75f --- /dev/null +++ b/crates/icp-cli/tests/untrusted_tests.rs @@ -0,0 +1,263 @@ +//! `--untrusted` holds a project to what one from elsewhere may run: see +//! `icp_project::untrusted`. Each test here sets up a tree the CLI would act +//! on as the user's own, and shows the flag refusing it, naming what it +//! refused — or letting it be, where the project only reads. + +use indoc::indoc; +use predicates::{prelude::PredicateBooleanExt, str::contains}; + +use crate::common::TestContext; +use icp_project::fs::write_string; + +mod common; + +/// A project directory holding a module of its own, and beside it a file +/// outside the project: `outside.bin`, and `outside.wasm`, a module. +fn project(ctx: &TestContext, manifest: &str) -> camino::Utf8PathBuf { + let project_dir = ctx.create_project_dir("icp"); + let wasm = ctx.make_asset("example_icp_mo.wasm"); + let outside = project_dir.parent().unwrap(); + std::fs::copy(&wasm, project_dir.join("backend.wasm")).expect("copy wasm"); + std::fs::copy(&wasm, outside.join("outside.wasm")).expect("copy wasm outside"); + std::fs::write(outside.join("outside.bin"), b"secret").expect("write outside"); + write_string(&project_dir.join("icp.yaml"), manifest).expect("write manifest"); + project_dir +} + +const PREBUILT: &str = indoc! {r#" + canisters: + - name: backend + build: + steps: + - type: pre-built + path: backend.wasm +"#}; + +const SCRIPT: &str = indoc! {r#" + canisters: + - name: backend + build: + steps: + - type: script + command: cp backend.wasm "$ICP_WASM_OUTPUT_PATH" +"#}; + +#[test] +fn a_bundle_shaped_project_builds_as_untrusted() { + let ctx = TestContext::new(); + let project_dir = project(&ctx, PREBUILT); + + ctx.icp() + .current_dir(&project_dir) + .args(["build", "--untrusted"]) + .assert() + .success(); +} + +#[test] +fn a_script_step_is_refused_as_untrusted_but_not_otherwise() { + let ctx = TestContext::new(); + let project_dir = project(&ctx, SCRIPT); + + ctx.icp() + .current_dir(&project_dir) + .args(["build"]) + .assert() + .success(); + + ctx.icp() + .current_dir(&project_dir) + .args(["build", "--untrusted"]) + .assert() + .failure() + .stderr( + contains("more than an untrusted project may") + .and(contains( + "canister 'backend' has a build step that is not a pre-built module", + )) + .and(contains("(script) cp backend.wasm")), + ); +} + +/// A recipe is read and rendered like any other; it is the steps it renders +/// to that are held to the list. +#[test] +fn a_recipe_is_rendered_and_its_steps_held_to_the_list() { + let ctx = TestContext::new(); + let project_dir = project( + &ctx, + indoc! {r#" + canisters: + - name: backend + recipe: + type: file://recipe.hbs + "#}, + ); + let recipe = project_dir.join("recipe.hbs"); + + std::fs::write( + &recipe, + "build:\n steps:\n - type: pre-built\n path: backend.wasm\n", + ) + .expect("write recipe"); + ctx.icp() + .current_dir(&project_dir) + .args(["build", "--untrusted"]) + .assert() + .success(); + + std::fs::write( + &recipe, + "build:\n steps:\n - type: script\n command: cp backend.wasm \"$ICP_WASM_OUTPUT_PATH\"\n", + ) + .expect("write recipe"); + ctx.icp() + .current_dir(&project_dir) + .args(["build", "--untrusted"]) + .assert() + .failure() + .stderr(contains("not a pre-built module").and(contains("(script) cp backend.wasm"))); +} + +/// Reading is not restricted: an argument file outside the project is read +/// as it would be for the user's own project. +#[test] +fn reading_outside_the_project_is_not_restricted() { + let ctx = TestContext::new(); + let project_dir = project( + &ctx, + indoc! {r#" + canisters: + - name: backend + init_args: + path: ../outside.bin + format: bin + build: + steps: + - type: pre-built + path: backend.wasm + "#}, + ); + + ctx.icp() + .current_dir(&project_dir) + .args(["build", "--untrusted"]) + .assert() + .success(); +} + +#[test] +fn a_module_outside_the_project_is_refused_as_untrusted() { + let ctx = TestContext::new(); + let project_dir = project( + &ctx, + indoc! {r#" + canisters: + - name: backend + build: + steps: + - type: pre-built + path: ../outside.wasm + "#}, + ); + + ctx.icp() + .current_dir(&project_dir) + .args(["build"]) + .assert() + .success(); + + ctx.icp() + .current_dir(&project_dir) + .args(["build", "--untrusted"]) + .assert() + .failure() + .stderr( + contains("names '") + .and(contains("outside.wasm")) + .and(contains("outside the project")), + ); +} + +/// A link is judged by where it leads: out of the project is refused. +#[cfg(unix)] +#[test] +fn a_module_linked_out_of_the_project_is_refused_as_untrusted() { + let ctx = TestContext::new(); + let project_dir = project( + &ctx, + indoc! {r#" + canisters: + - name: backend + build: + steps: + - type: pre-built + path: linked.wasm + "#}, + ); + let link = project_dir.join("linked.wasm"); + + std::os::unix::fs::symlink(project_dir.parent().unwrap().join("outside.wasm"), &link) + .expect("symlink out"); + ctx.icp() + .current_dir(&project_dir) + .args(["build"]) + .assert() + .success(); + ctx.icp() + .current_dir(&project_dir) + .args(["build", "--untrusted"]) + .assert() + .failure() + .stderr( + contains("linked.wasm") + .and(contains("resolves to")) + .and(contains("outside.wasm")) + .and(contains("outside the project")), + ); +} + +/// Where the file really is decides, so the root may be spelled relatively. +#[test] +fn a_relative_project_root_is_taken_from_the_working_directory() { + let ctx = TestContext::new(); + let project_dir = project(&ctx, PREBUILT); + let parent = project_dir.parent().unwrap(); + + ctx.icp() + .current_dir(parent) + .args(["build", "--untrusted", "--project-root-override", "icp"]) + .assert() + .success(); + + ctx.icp() + .current_dir(parent) + .env("ICP_PROJECT_ROOT", "icp") + .args(["build", "--untrusted"]) + .assert() + .success(); +} + +/// The flag is on the three commands that act on what a project says, and +/// each refuses before it reaches any network. It is on no other command. +#[test] +fn the_flag_is_on_build_deploy_and_sync_alone() { + let ctx = TestContext::new(); + let project_dir = project(&ctx, SCRIPT); + + for command in ["build", "deploy", "sync"] { + ctx.icp() + .current_dir(&project_dir) + .args([command, "--untrusted"]) + .assert() + .failure() + .stderr(contains("more than an untrusted project may")); + } + + ctx.icp() + .current_dir(&project_dir) + .args(["identity", "list", "--untrusted"]) + .assert() + .failure() + .stderr(contains("unexpected argument '--untrusted'")); +} diff --git a/crates/icp-project/src/lib.rs b/crates/icp-project/src/lib.rs index ec17458fc..831620667 100644 --- a/crates/icp-project/src/lib.rs +++ b/crates/icp-project/src/lib.rs @@ -43,6 +43,7 @@ pub mod random; pub mod signal; pub mod store_artifact; pub mod store_id; +pub mod untrusted; /// The per-project state directory. `cache` holds what can be thrown away and /// rebuilt (ids for managed networks, build artifacts); `data` holds what @@ -219,6 +220,12 @@ pub enum ProjectLoadError { Project { source: project::ConsolidateManifestError, }, + + #[snafu(display("the project asks for more than an untrusted project may"))] + Untrusted { + #[snafu(source(from(untrusted::Refused, Box::new)))] + source: Box, + }, } #[async_trait] diff --git a/crates/icp-project/src/untrusted.rs b/crates/icp-project/src/untrusted.rs new file mode 100644 index 000000000..d87edc920 --- /dev/null +++ b/crates/icp-project/src/untrusted.rs @@ -0,0 +1,605 @@ +//! Holding a project someone else handed over to what it may run. +//! +//! The CLI trusts a project the way it trusts the user's own checkout: a +//! build step may run any command, a sync step any script, and either may +//! take its module or plugin from anywhere on the machine. A project from +//! elsewhere — an archive an agent passed to a deploy service, a checkout the +//! user has not read — may not. The line is between reading and running. +//! Reading is not restricted: argument files, manifests and recipe templates +//! are read from wherever the manifest points. What runs is held to a list, +//! stated as what is allowed so that a kind of step added later is refused +//! until someone decides otherwise: +//! +//! - a build step is a pre-built module, and a sync step is a plugin; +//! - the file either names is a regular file within the project: its path, +//! with every link followed and every `..` applied, lies under the project +//! root resolved the same way. +//! +//! A module or plugin named by URL is not on the list, and the steps a recipe +//! renders to are held to it like any other. A checksum is not asked for: the +//! same untrusted hand wrote it and the module. [`vet`] applies the list to a +//! loaded [`Project`], and [`Vetted`] wraps a loader so that every load is +//! vetted before the project is handed out. + +use std::sync::Arc; + +use async_trait::async_trait; +use snafu::{ResultExt, Snafu}; + +use crate::{ + Project, ProjectLoad, ProjectLoadError, UntrustedSnafu, + files::FileSystem, + manifest::{ + adapter::prebuilt::{LocalSource, SourceField}, + canister::{BuildStep, SyncStep}, + }, + prelude::*, +}; + +/// Why a loaded project may not be acted on as untrusted. +#[derive(Debug, Snafu)] +pub enum Refused { + #[snafu(display( + "canister '{canister}' has a build step that is not a pre-built module, which is all an \ + untrusted project may have: {step}" + ))] + BuildStep { canister: String, step: String }, + + #[snafu(display( + "canister '{canister}' has a sync step that is not a plugin, which is all an untrusted \ + project may have: {step}" + ))] + SyncStep { canister: String, step: String }, + + #[snafu(display( + "canister '{canister}' names '{path}', which resolves to '{resolved}', outside the \ + project at '{root}': an untrusted project runs only what is within it" + ))] + OutsidePath { + canister: String, + path: PathBuf, + resolved: PathBuf, + root: PathBuf, + }, + + #[snafu(display( + "canister '{canister}' names '{path}', which is '{resolved}', not a regular file: an \ + untrusted project runs only a file within it" + ))] + NotAFile { + canister: String, + path: PathBuf, + resolved: PathBuf, + }, + + #[snafu(display("canister '{canister}' names '{path}', which does not resolve to anything"))] + UnresolvedPath { canister: String, path: PathBuf }, + + #[snafu(display("the project root '{root}' does not resolve to a directory"))] + UnresolvedRoot { root: PathBuf }, +} + +/// The file `step` names, if it is the one build step an untrusted project +/// may have: a pre-built module from a file. +fn local_module(step: &BuildStep) -> Option<&LocalSource> { + match step { + BuildStep::Prebuilt(adapter) => local_source(&adapter.source), + BuildStep::Script(_) => None, + } +} + +/// The file `step` names, if it is the one sync step an untrusted project +/// may have: a plugin from a file. +fn local_plugin(step: &SyncStep) -> Option<&LocalSource> { + match step { + SyncStep::Plugin(adapter) => local_source(&adapter.source), + SyncStep::Script(_) => None, + } +} + +fn local_source(source: &SourceField) -> Option<&LocalSource> { + match source { + SourceField::Local(local) => Some(local), + SourceField::Remote(_) => None, + } +} + +/// Hold a loaded project to the list. +/// +/// Within is decided on the real paths, the file's and the root's, as +/// `files` canonicalizes them, so a link is judged by where it leads. A path +/// that does not resolve is refused, and so is one that is not a regular +/// file: a read of a pipe nobody writes to waits forever. +pub async fn vet(project: &Project, files: &dyn FileSystem) -> Result<(), Refused> { + let root = files + .canonicalize(&project.dir) + .await + .ok_or_else(|| UnresolvedRootSnafu { root: &project.dir }.build())?; + + for (_, (canister_dir, canister)) in &project.canisters { + for step in &canister.build.steps { + let Some(local) = local_module(step) else { + return BuildStepSnafu { + canister: &canister.name, + step: one_line(step), + } + .fail(); + }; + within(files, &root, canister, &canister_dir.join(&local.path)).await?; + } + for step in &canister.sync.steps { + let Some(local) = local_plugin(step) else { + return SyncStepSnafu { + canister: &canister.name, + step: one_line(step), + } + .fail(); + }; + within(files, &root, canister, &canister_dir.join(&local.path)).await?; + } + } + Ok(()) +} + +/// Refuse `path` unless it resolves to a regular file under the resolved +/// `root`. What is there is asked of the path as spelled, not as resolved: a +/// link is followed either way, and a filesystem confined to a root reached +/// through a link has no answer for the resolved one. +async fn within( + files: &dyn FileSystem, + root: &Path, + canister: &crate::Canister, + path: &Path, +) -> Result<(), Refused> { + let resolved = files.canonicalize(path).await.ok_or_else(|| { + UnresolvedPathSnafu { + canister: &canister.name, + path, + } + .build() + })?; + if !resolved.starts_with(root) { + return OutsidePathSnafu { + canister: &canister.name, + path, + resolved, + root, + } + .fail(); + } + if !files.is_file(path).await { + return NotAFileSnafu { + canister: &canister.name, + path, + resolved, + } + .fail(); + } + Ok(()) +} + +/// A step as it displays, on one line: its kind, then the lines after the +/// first run together with `;` between. +fn one_line(step: &impl std::fmt::Display) -> String { + let text = step.to_string(); + let mut lines = text.lines().map(str::trim).filter(|line| !line.is_empty()); + let kind = lines.next().unwrap_or_default(); + let rest = lines.collect::>().join("; "); + if rest.is_empty() { + kind.to_owned() + } else { + format!("{kind} {rest}") + } +} + +/// A [`ProjectLoad`] that hands out only a project [`vet`] accepts. +pub struct Vetted { + loader: L, + files: Arc, +} + +impl Vetted { + /// `files` is what the paths are resolved through: the filesystem the + /// project is on. + pub fn new(loader: L, files: Arc) -> Self { + Self { loader, files } + } +} + +#[async_trait] +impl ProjectLoad for Vetted { + async fn load(&self) -> Result { + let project = self.loader.load().await?; + vet(&project, self.files.as_ref()) + .await + .context(UntrustedSnafu)?; + Ok(project) + } + + async fn exists(&self) -> Result { + self.loader.exists().await + } + + fn member_dir(&self) -> Option { + self.loader.member_dir() + } +} + +#[cfg(test)] +mod tests { + use std::collections::{HashMap, HashSet}; + + use super::*; + use crate::{ + MockProjectLoader, + files::{FsError, Scratch}, + manifest::{ + adapter::{prebuilt, script}, + canister::{BuildSteps, SyncSteps}, + }, + }; + + /// A filesystem that resolves the paths it is told to and nothing else, + /// standing in for links and `..` on disk, and says which of the places + /// they resolve to hold a regular file — asked of a path by any + /// spelling, followed to where it resolves, as the host follows a link. + /// The vet asks for nothing but where a path really is and what is there. + struct Resolves { + paths: HashMap, + files: HashSet, + } + + #[async_trait] + impl FileSystem for Resolves { + async fn canonicalize(&self, path: &Path) -> Option { + self.paths.get(path).cloned() + } + async fn is_file(&self, path: &Path) -> bool { + self.paths + .get(path) + .is_some_and(|resolved| self.files.contains(resolved)) + } + async fn read(&self, _: &Path) -> Result, FsError> { + unreachable!("the vet reads nothing") + } + async fn read_to_string(&self, _: &Path) -> Result { + unreachable!("the vet reads nothing") + } + async fn write(&self, _: &Path, _: &[u8]) -> Result<(), FsError> { + unreachable!("the vet writes nothing") + } + async fn create_dir_all(&self, _: &Path) -> Result<(), FsError> { + unreachable!("the vet writes nothing") + } + async fn copy(&self, _: &Path, _: &Path) -> Result<(), FsError> { + unreachable!("the vet writes nothing") + } + async fn exists(&self, _: &Path) -> bool { + unreachable!("the vet looks at nothing but where a path is") + } + async fn is_dir(&self, _: &Path) -> bool { + unreachable!("the vet looks at nothing but where a path is") + } + async fn read_dir(&self, _: &Path) -> Result, FsError> { + unreachable!("the vet reads nothing") + } + async fn scratch_dir(&self) -> Result, FsError> { + unreachable!("the vet writes nothing") + } + } + + /// The minimal project is at `/project`, and this is its disk: a module + /// of its own, a plugin, a link out to `/elsewhere/secret.wasm`, a link + /// within to the module, a climb that lands outside, and a directory + /// `dir` of its own. + fn disk() -> Arc { + Arc::new(Resolves { + paths: HashMap::from([ + ("/project".into(), "/project".into()), + ( + "/project/backend.wasm".into(), + "/project/backend.wasm".into(), + ), + ("/project/p.wasm".into(), "/project/p.wasm".into()), + ("/project/out.wasm".into(), "/elsewhere/secret.wasm".into()), + ("/project/in.wasm".into(), "/project/backend.wasm".into()), + ( + "/project/../elsewhere/secret.wasm".into(), + "/elsewhere/secret.wasm".into(), + ), + ("/project/dir".into(), "/project/dir".into()), + ]), + files: HashSet::from([ + "/project/backend.wasm".into(), + "/project/p.wasm".into(), + "/elsewhere/secret.wasm".into(), + ]), + }) + } + + async fn project_with(build: Vec, sync: Vec) -> Project { + let mut project = MockProjectLoader::minimal().load().await.unwrap(); + let (_, canister) = project.canisters.get_mut("backend").unwrap(); + canister.build = BuildSteps { steps: build }; + canister.sync = SyncSteps { steps: sync }; + project + } + + async fn vetted(build: Vec, sync: Vec) -> Result<(), Refused> { + vet(&project_with(build, sync).await, disk().as_ref()).await + } + + fn local(path: &str) -> BuildStep { + BuildStep::Prebuilt(prebuilt::Adapter { + source: SourceField::Local(prebuilt::LocalSource { path: path.into() }), + sha256: None, + }) + } + + fn script(command: &str) -> script::Adapter { + script::Adapter { + command: script::CommandField::Command(command.to_owned()), + } + } + + fn plugin(source: &str) -> SyncStep { + SyncStep::Plugin(Box::new( + serde_yaml::from_str(&format!("{source}\nsha256: abc")).unwrap(), + )) + } + + #[tokio::test] + async fn a_bundle_shaped_project_is_accepted() { + vetted(vec![local("backend.wasm")], vec![plugin("path: p.wasm")]) + .await + .expect("pre-built from the project, plugin from the project"); + } + + /// A script step and a remote module are the two other build steps there + /// are. Neither is on the list, and the message says what the list is + /// and what the step was. + #[tokio::test] + async fn a_build_step_that_is_not_a_local_prebuilt_module_is_refused() { + let err = vetted(vec![BuildStep::Script(script("make"))], vec![]) + .await + .unwrap_err(); + assert!(matches!(err, Refused::BuildStep { ref canister, .. } if canister == "backend")); + let text = err.to_string(); + assert!(text.contains("not a pre-built module"), "{text}"); + assert!(text.contains("(script) make"), "{text}"); + + let several = script::Adapter { + command: script::CommandField::Commands(vec!["make".into(), "cp a b".into()]), + }; + let err = vetted(vec![BuildStep::Script(several)], vec![]) + .await + .unwrap_err(); + assert!(err.to_string().contains("(script) make; cp a b"), "{err}"); + + let remote = BuildStep::Prebuilt(prebuilt::Adapter { + source: SourceField::Remote(prebuilt::RemoteSource { + url: "https://example.com/backend.wasm".into(), + }), + sha256: Some("abc".into()), + }); + let err = vetted(vec![remote], vec![]).await.unwrap_err(); + assert!(matches!(err, Refused::BuildStep { .. })); + assert!( + err.to_string().contains("example.com/backend.wasm"), + "{err}" + ); + + // One good step does not carry a bad one. + let err = vetted( + vec![local("backend.wasm"), BuildStep::Script(script("make"))], + vec![], + ) + .await + .unwrap_err(); + assert!(matches!(err, Refused::BuildStep { .. })); + } + + #[tokio::test] + async fn a_sync_step_that_is_not_a_local_plugin_is_refused() { + let err = vetted( + vec![local("backend.wasm")], + vec![SyncStep::Script(script("upload"))], + ) + .await + .unwrap_err(); + assert!(matches!(err, Refused::SyncStep { .. })); + let text = err.to_string(); + assert!(text.contains("not a plugin"), "{text}"); + assert!(text.contains("script"), "{text}"); + + let err = vetted( + vec![local("backend.wasm")], + vec![plugin("url: https://example.com/plugin.wasm")], + ) + .await + .unwrap_err(); + assert!(matches!(err, Refused::SyncStep { .. })); + assert!(err.to_string().contains("example.com/plugin.wasm"), "{err}"); + } + + /// Where a path leads is what counts, not how it is spelled: a link out + /// is refused for where it lands, a link within is fine, and a climb + /// out is refused. + #[tokio::test] + async fn the_file_a_step_names_is_held_to_where_it_resolves() { + vetted(vec![local("in.wasm")], vec![]) + .await + .expect("a link within the project"); + + for path in ["out.wasm", "../elsewhere/secret.wasm"] { + let err = vetted(vec![local(path)], vec![]).await.unwrap_err(); + assert!( + matches!(err, Refused::OutsidePath { ref resolved, ref root, .. } + if resolved == "/elsewhere/secret.wasm" && root == "/project"), + "{path}: {err}" + ); + assert!( + err.to_string().contains("outside the project"), + "{path}: {err}" + ); + } + + let err = vetted(vec![local("backend.wasm")], vec![plugin("path: out.wasm")]) + .await + .unwrap_err(); + assert!(matches!(err, Refused::OutsidePath { .. }), "{err}"); + + let err = vetted(vec![local("missing.wasm")], vec![]) + .await + .unwrap_err(); + assert!(matches!(err, Refused::UnresolvedPath { .. }), "{err}"); + assert!(err.to_string().contains("missing.wasm"), "{err}"); + } + + /// Where a path leads must hold a regular file: a directory within the + /// project is refused as a module and as a plugin, and the message says + /// what was there instead. + #[tokio::test] + async fn what_a_step_names_must_be_a_regular_file() { + let err = vetted(vec![local("dir")], vec![]).await.unwrap_err(); + assert!( + matches!(err, Refused::NotAFile { ref resolved, .. } if resolved == "/project/dir"), + "{err}" + ); + assert!(err.to_string().contains("not a regular file"), "{err}"); + + let err = vetted(vec![local("backend.wasm")], vec![plugin("path: dir")]) + .await + .unwrap_err(); + assert!(matches!(err, Refused::NotAFile { .. }), "{err}"); + } + + #[tokio::test] + async fn a_vetted_loader_refuses_what_vet_refuses() { + let project = project_with(vec![BuildStep::Script(script("make"))], vec![]).await; + let loader = Vetted::new(MockProjectLoader::new(project), disk()); + assert!(loader.exists().await.unwrap()); + let err = loader.load().await.unwrap_err(); + assert!(matches!(err, ProjectLoadError::Untrusted { .. }), "{err}"); + + let loader = Vetted::new(MockProjectLoader::minimal(), disk()); + loader + .load() + .await + .expect("the minimal project is pre-built from its own file"); + } + + /// On a real disk the links are followed by the host, which is what the + /// CLI resolves through: to the end of a chain of them, and through a + /// linked directory on the way. And what is at the end must be a + /// regular file: a directory and a named pipe are refused, the pipe + /// before anything tries to read it. + #[cfg(all(unix, feature = "host"))] + #[tokio::test] + async fn on_disk_a_link_is_followed_to_where_it_leads() { + use crate::files::HostFileSystem; + use std::os::unix::fs::symlink; + + let dir = camino_tempfile::tempdir().unwrap(); + let root = dir.path().join("project"); + std::fs::create_dir(&root).unwrap(); + std::fs::write(root.join("backend.wasm"), b"module").unwrap(); + std::fs::write(dir.path().join("secret.wasm"), b"secret").unwrap(); + symlink(root.join("backend.wasm"), root.join("in.wasm")).unwrap(); + // out.wasm -> hop.wasm -> ../secret.wasm + symlink(dir.path().join("secret.wasm"), root.join("hop.wasm")).unwrap(); + symlink(root.join("hop.wasm"), root.join("out.wasm")).unwrap(); + // outdir -> .. so that outdir/secret.wasm is the file outside + symlink(dir.path(), root.join("outdir")).unwrap(); + // chain of three inside: a -> b -> c -> backend.wasm + symlink(root.join("backend.wasm"), root.join("c.wasm")).unwrap(); + symlink(root.join("c.wasm"), root.join("b.wasm")).unwrap(); + symlink(root.join("b.wasm"), root.join("a.wasm")).unwrap(); + // a directory, and a pipe nobody writes to, each with a link to it + std::fs::create_dir(root.join("dir.wasm")).unwrap(); + symlink(root.join("dir.wasm"), root.join("to-dir.wasm")).unwrap(); + let made = std::process::Command::new("mkfifo") + .arg(root.join("pipe.wasm")) + .status() + .unwrap(); + assert!(made.success(), "mkfifo"); + symlink(root.join("pipe.wasm"), root.join("to-pipe.wasm")).unwrap(); + + let mut project = project_with(vec![], vec![]).await; + project.dir = root.clone(); + project.canisters.get_mut("backend").unwrap().0 = root.clone(); + + for path in ["in.wasm", "a.wasm"] { + project.canisters.get_mut("backend").unwrap().1.build = BuildSteps { + steps: vec![local(path)], + }; + vet(&project, &HostFileSystem) + .await + .unwrap_or_else(|err| panic!("{path} leads to the project's own module: {err}")); + } + + for path in ["out.wasm", "outdir/secret.wasm"] { + project.canisters.get_mut("backend").unwrap().1.build = BuildSteps { + steps: vec![local(path)], + }; + let err = vet(&project, &HostFileSystem).await.unwrap_err(); + assert!( + matches!(err, Refused::OutsidePath { ref resolved, .. } + if resolved.file_name() == Some("secret.wasm")), + "{path}: {err}" + ); + } + + for (path, at) in [ + ("dir.wasm", "dir.wasm"), + ("to-dir.wasm", "dir.wasm"), + ("pipe.wasm", "pipe.wasm"), + ("to-pipe.wasm", "pipe.wasm"), + ] { + project.canisters.get_mut("backend").unwrap().1.build = BuildSteps { + steps: vec![local(path)], + }; + let err = vet(&project, &HostFileSystem).await.unwrap_err(); + assert!( + matches!(err, Refused::NotAFile { ref resolved, .. } + if resolved.file_name() == Some(at)), + "{path}: {err}" + ); + } + } + + /// A filesystem that holds paths to a root as the caller spells it — the + /// confined one, with the project reached through a link, as a macOS + /// temp dir is through `/var` — resolves a module to its real place, + /// which is not under that spelling. The vet must not then ask it what + /// is at the real place: a module within the project is accepted, and + /// what is not a file is still refused. + #[cfg(all(unix, feature = "host"))] + #[tokio::test] + async fn on_disk_a_root_spelled_through_a_link_is_asked_by_that_spelling() { + use crate::files::Confined; + use std::os::unix::fs::symlink; + + let dir = camino_tempfile::tempdir().unwrap(); + let real = dir.path().join("project"); + std::fs::create_dir(&real).unwrap(); + std::fs::write(real.join("backend.wasm"), b"module").unwrap(); + std::fs::create_dir(real.join("dir.wasm")).unwrap(); + let spelled = dir.path().join("alias"); + symlink(&real, &spelled).unwrap(); + let files = Confined::new(&spelled); + + let mut project = project_with(vec![local("backend.wasm")], vec![]).await; + project.dir = spelled.clone(); + project.canisters.get_mut("backend").unwrap().0 = spelled.clone(); + vet(&project, &files) + .await + .expect("the project's own module, with the root spelled through a link"); + + project.canisters.get_mut("backend").unwrap().1.build = BuildSteps { + steps: vec![local("dir.wasm")], + }; + let err = vet(&project, &files).await.unwrap_err(); + assert!(matches!(err, Refused::NotAFile { .. }), "{err}"); + } +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 9c14edebb..732a082ee 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -136,6 +136,7 @@ Build canisters ###### **Options:** * `-e`, `--environment ` — Override the environment to build for. By default, the local environment is used +* `--untrusted` — Hold the project to what one someone else handed over may run: each build step a pre-built module, each sync step a plugin, and what each names a regular file within the project once links are followed. Anything else is refused. Reading is not restricted @@ -1071,6 +1072,7 @@ using --args or --args-file: - `bin`: Raw binary (only valid for file references) +* `--untrusted` — Hold the project to what one someone else handed over may run: each build step a pre-built module, each sync step a plugin, and what each names a regular file within the project once links are followed. Anything else is refused. Reading is not restricted @@ -1919,6 +1921,7 @@ Synchronize canisters * `--proxy ` — Principal of a proxy canister to route sync plugin calls to the target canister through * `-e`, `--environment ` — Override the environment to connect to. By default, the local environment is used * `--identity ` — The user identity to run this command as +* `--untrusted` — Hold the project to what one someone else handed over may run: each build step a pre-built module, each sync step a plugin, and what each names a regular file within the project once links are followed. Anything else is refused. Reading is not restricted