From dcec00849122cf73c792fdfd73dc53ceb2ec5718 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Wed, 4 Nov 2026 00:32:23 +0000 Subject: [PATCH 01/24] feat(project): a filesystem confined to the project root, for a project from elsewhere `icp project bundle` holds every path a manifest names to the project it is bundling, and refuses one that resolves outside. Loading a project holds nothing: the loader joins each path to its base and reads it through the host filesystem, which is right for the user's own tree and wrong for a project someone else handed over, a bundle an agent passes to a deploy service, say. A manifest in such a bundle could name `../../etc/hostname` as an argument file and have it read into the canister's arguments at load, before any caller could check. `files::Confined` is the host filesystem held to one directory. Every path is resolved against the root before the disk sees it: a relative path is joined, `.` dropped, `..` applied, and a path that comes to rest outside the root, by climbing, by being absolute elsewhere, or through a symlink inside the root that points out, is refused with `OutsideRoot`. The predicates answer false for such a path, scratch directories are made under the root, and `resolve` hands out the real path for a caller that needs one for something that does not read through the trait. The loader resolves every path a manifest names through the filesystem it is given, so confining that one seam holds all of them, canister directories and dependency projects included, without the loader enumerating its fields; the tests load one manifest through both filesystems to show the difference. Nothing changes for the CLI, which keeps loading through `HostFileSystem`. Co-Authored-By: Claude Fable 5.1 --- crates/icp-project/src/files.rs | 323 ++++++++++++++++++++++++++++++ crates/icp-project/src/project.rs | 107 ++++++++++ 2 files changed, 430 insertions(+) diff --git a/crates/icp-project/src/files.rs b/crates/icp-project/src/files.rs index 860cee6a9..e58ddf160 100644 --- a/crates/icp-project/src/files.rs +++ b/crates/icp-project/src/files.rs @@ -88,6 +88,199 @@ pub trait Scratch: Send + Sync { fn path(&self) -> &Path; } +/// This machine's filesystem, held to one directory. +/// +/// Every path is resolved against the root before the disk sees it: a +/// relative path is joined to the root, `.` is dropped, `..` is applied, and a +/// path that comes to rest outside the root — by climbing above it, by being +/// absolute somewhere else, or through a symlink inside the root that points +/// out — is refused with [`OutsideRoot`]. The predicates answer `false` for +/// such a path, as they do for any other failure, and scratch directories are +/// made under the root, so what a build writes and reads back stays inside. +/// +/// For a project whose files are not the user's own tree: a bundle handed +/// over by someone else, which `icp project bundle` wrote with every path +/// held to the project it came from, but which the loader would otherwise +/// take at its word. The loader resolves every path a manifest names +/// through the [`FileSystem`] it is given, so confining that one seam holds +/// all of them — the canister directories, the manifests in them, the +/// argument and variable files, the dependency projects — without the +/// loader enumerating its fields. +#[cfg(feature = "host")] +#[derive(Debug, Clone)] +pub struct Confined { + /// Canonical, so a canonicalized path compares against it. + root: PathBuf, +} + +/// A path a [`Confined`] filesystem was asked for that is not under its root. +#[cfg(feature = "host")] +#[derive(Debug, Snafu)] +#[snafu(display("'{path}' is outside '{root}', the directory this project is confined to"))] +pub struct OutsideRoot { + pub path: PathBuf, + pub root: PathBuf, +} + +/// Why a directory cannot be confined to. +#[cfg(feature = "host")] +#[derive(Debug, Snafu)] +pub enum ConfineError { + #[snafu(display("cannot resolve '{path}' to confine the project to it"))] + Resolve { + path: PathBuf, + source: std::io::Error, + }, + #[snafu(display("'{path}' resolves to a path that is not UTF-8"))] + NotUtf8 { path: PathBuf }, +} + +#[cfg(feature = "host")] +impl Confined { + /// Confine the host filesystem to `root`, which must exist. + pub fn new(root: &Path) -> Result { + let real = dunce::canonicalize(root).context(ResolveSnafu { path: root })?; + let root = PathBuf::from_path_buf(real).map_err(|_| ConfineError::NotUtf8 { + path: root.to_path_buf(), + })?; + Ok(Self { root }) + } + + /// The directory everything is held to. + pub fn root(&self) -> &Path { + &self.root + } + + /// Where `path` lies under the root, by its spelling alone: joined to the + /// root if relative, with `.` dropped and `..` applied, and refused if it + /// would come to rest outside. What a caller that needs a real path for + /// something that does not read through the trait — a runtime mounting a + /// directory — asks for; the trait's own reads also follow symlinks + /// before trusting the answer. + pub fn resolve(&self, path: &Path) -> Result { + let outside = || { + FsError::new(OutsideRoot { + path: path.to_path_buf(), + root: self.root.clone(), + }) + }; + let relative = if path.is_absolute() { + path.strip_prefix(&self.root).map_err(|_| outside())? + } else { + path + }; + let mut resolved = self.root.clone(); + for component in relative.components() { + match component { + Utf8Component::Normal(part) => resolved.push(part), + Utf8Component::CurDir => {} + Utf8Component::ParentDir => { + if resolved == self.root || !resolved.pop() { + return Err(outside()); + } + } + Utf8Component::RootDir | Utf8Component::Prefix(_) => return Err(outside()), + } + } + Ok(resolved) + } + + /// [`resolve`](Self::resolve), then follow the symlinks of as much of the + /// path as exists and check where that leads: a link inside the root to + /// a place outside it resolves outside, however it is spelled. + fn resolve_real(&self, path: &Path) -> Result { + let resolved = self.resolve(path)?; + let mut probe = resolved.as_path(); + loop { + if let Ok(real) = dunce::canonicalize(probe) { + if !real.starts_with(&self.root) { + return Err(FsError::new(OutsideRoot { + path: path.to_path_buf(), + root: self.root.clone(), + })); + } + return Ok(resolved); + } + match probe.parent() { + Some(parent) => probe = parent, + None => return Ok(resolved), + } + } + } +} + +#[cfg(feature = "host")] +#[async_trait] +impl FileSystem for Confined { + async fn read(&self, path: &Path) -> Result, FsError> { + HostFileSystem.read(&self.resolve_real(path)?).await + } + + async fn read_to_string(&self, path: &Path) -> Result { + HostFileSystem + .read_to_string(&self.resolve_real(path)?) + .await + } + + async fn write(&self, path: &Path, contents: &[u8]) -> Result<(), FsError> { + HostFileSystem + .write(&self.resolve_real(path)?, contents) + .await + } + + async fn create_dir_all(&self, path: &Path) -> Result<(), FsError> { + HostFileSystem + .create_dir_all(&self.resolve_real(path)?) + .await + } + + async fn copy(&self, from: &Path, to: &Path) -> Result<(), FsError> { + HostFileSystem + .copy(&self.resolve_real(from)?, &self.resolve_real(to)?) + .await + } + + async fn exists(&self, path: &Path) -> bool { + match self.resolve_real(path) { + Ok(real) => HostFileSystem.exists(&real).await, + Err(_) => false, + } + } + + async fn is_file(&self, path: &Path) -> bool { + match self.resolve_real(path) { + Ok(real) => HostFileSystem.is_file(&real).await, + Err(_) => false, + } + } + + async fn is_dir(&self, path: &Path) -> bool { + match self.resolve_real(path) { + Ok(real) => HostFileSystem.is_dir(&real).await, + Err(_) => false, + } + } + + async fn read_dir(&self, path: &Path) -> Result, FsError> { + HostFileSystem.read_dir(&self.resolve_real(path)?).await + } + + async fn canonicalize(&self, path: &Path) -> Option { + HostFileSystem + .canonicalize(&self.resolve_real(path).ok()?) + .await + .filter(|real| real.starts_with(&self.root)) + } + + async fn scratch_dir(&self) -> Result, FsError> { + let dir = camino_tempfile::Builder::new() + .prefix(".scratch-") + .tempdir_in(&self.root) + .map_err(FsError::new)?; + Ok(Box::new(HostScratch(dir))) + } +} + #[cfg(feature = "host")] /// The [`FileSystem`] backed by this machine's filesystem. #[derive(Debug, Default, Clone, Copy)] @@ -385,6 +578,136 @@ async fn list( mod tests { use super::*; + /// Every spelling of a path resolves under the root or is refused: a + /// relative one is joined, `.` and `..` are applied, an absolute one is + /// taken only under the root, and a symlink inside the root that points + /// out leads nowhere. + #[cfg(feature = "host")] + mod confined { + use super::*; + + fn root() -> (camino_tempfile::Utf8TempDir, Confined) { + let dir = camino_tempfile::Utf8TempDir::new().expect("temp dir"); + std::fs::create_dir_all(dir.path().join("project/canisters")).expect("mkdir"); + std::fs::write(dir.path().join("project/icp.yaml"), b"canisters: []").expect("write"); + std::fs::write(dir.path().join("project/canisters/a.wasm"), b"wasm").expect("write"); + std::fs::write(dir.path().join("outside.txt"), b"secret").expect("write"); + let fs = Confined::new(&dir.path().join("project")).expect("confine"); + (dir, fs) + } + + #[tokio::test] + async fn paths_resolve_under_the_root_by_any_spelling_inside_it() { + let (_dir, fs) = root(); + let root = fs.root().to_path_buf(); + for spelling in [ + "canisters/a.wasm", + "./canisters/a.wasm", + "canisters/../canisters/a.wasm", + root.join("canisters/a.wasm").as_str(), + ] { + assert_eq!(fs.read(Path::new(spelling)).await.expect(spelling), b"wasm"); + assert!(fs.is_file(Path::new(spelling)).await, "{spelling}"); + assert_eq!( + fs.canonicalize(Path::new(spelling)).await, + Some(root.join("canisters/a.wasm")), + "{spelling}" + ); + } + assert!(fs.is_dir(Path::new("canisters")).await); + assert!(fs.is_dir(&root).await); + assert_eq!( + fs.read_dir(&root).await.expect("list"), + vec![root.join("canisters"), root.join("icp.yaml")] + ); + assert!( + fs.resolve(Path::new("made/later")) + .unwrap() + .starts_with(&root) + ); + } + + #[tokio::test] + async fn paths_outside_the_root_are_refused_however_spelled() { + let (dir, fs) = root(); + let outside = dir.path().join("outside.txt"); + assert!( + outside.is_file(), + "the file exists, for the host filesystem to read" + ); + for spelling in [ + "../outside.txt", + "canisters/../../outside.txt", + outside.as_str(), + "/etc/hostname", + "../../../../../../etc/hostname", + ] { + let err = fs + .read(Path::new(spelling)) + .await + .expect_err(spelling) + .to_string(); + assert!(err.contains("is outside"), "{spelling}: {err}"); + assert!(!fs.exists(Path::new(spelling)).await, "{spelling}"); + assert!(!fs.is_file(Path::new(spelling)).await, "{spelling}"); + assert!( + fs.canonicalize(Path::new(spelling)).await.is_none(), + "{spelling}" + ); + assert!(fs.resolve(Path::new(spelling)).is_err(), "{spelling}"); + } + // Nor may anything be written there. + let err = fs + .write(Path::new("../planted"), b"x") + .await + .unwrap_err() + .to_string(); + assert!(err.contains("is outside"), "{err}"); + assert!(!dir.path().join("planted").exists()); + } + + #[cfg(unix)] + #[tokio::test] + async fn a_symlink_inside_pointing_out_is_refused() { + let (dir, fs) = root(); + let root = fs.root().to_path_buf(); + std::os::unix::fs::symlink(dir.path().join("outside.txt"), root.join("link.txt")) + .expect("symlink"); + std::os::unix::fs::symlink(dir.path(), root.join("up")).expect("symlink"); + for spelling in ["link.txt", "up/outside.txt"] { + let err = fs + .read(Path::new(spelling)) + .await + .expect_err(spelling) + .to_string(); + assert!(err.contains("is outside"), "{spelling}: {err}"); + assert!(!fs.exists(Path::new(spelling)).await, "{spelling}"); + } + // A link that leads back inside is inside: what matters is where + // the path comes to rest, not how it got there. + assert_eq!( + fs.read(Path::new("up/project/icp.yaml")).await.unwrap(), + b"canisters: []" + ); + } + + #[tokio::test] + async fn scratch_is_made_under_the_root() { + let (_dir, fs) = root(); + let scratch = fs.scratch_dir().await.expect("scratch"); + assert!(scratch.path().starts_with(fs.root())); + let out = scratch.path().join("out/module.wasm"); + fs.create_dir_all(&scratch.path().join("out")) + .await + .expect("mkdir"); + fs.write(&out, b"built").await.expect("write"); + assert_eq!(fs.read(&out).await.unwrap(), b"built"); + let path = scratch.path().to_path_buf(); + drop(scratch); + assert!(!path.exists()); + } + } + /// Builds a tree of empty files, creating parents as needed, and returns its /// root. fn tree(paths: &[&str]) -> camino_tempfile::Utf8TempDir { diff --git a/crates/icp-project/src/project.rs b/crates/icp-project/src/project.rs index e8f28cfbc..297ccd18f 100644 --- a/crates/icp-project/src/project.rs +++ b/crates/icp-project/src/project.rs @@ -1690,6 +1690,113 @@ pub async fn consolidate_manifest( }) } +/// A project loaded through a [`Confined`] filesystem reads nothing outside +/// its root, whatever its manifest names: the loader resolves every path +/// through the filesystem it is given, so the one rule holds them all. +#[cfg(all(test, feature = "host"))] +mod confined_tests { + use super::*; + use crate::canister::recipe::{Fetched, Resolve, ResolveError}; + use crate::files::{Confined, HostFileSystem}; + use crate::manifest::recipe::Recipe; + use camino_tempfile::Utf8TempDir; + + struct NoRecipes; + + #[async_trait::async_trait] + impl Resolve for NoRecipes { + async fn resolve(&self, _recipe: &Recipe) -> Result { + unreachable!("no manifest here names a recipe") + } + } + + /// A tree with a project directory and, beside it, what the project must + /// not reach: an argument file, and a canister directory of its own. + fn tree(manifest: &str) -> Utf8TempDir { + let dir = Utf8TempDir::new().expect("temp dir"); + let project = dir.path().join("project"); + std::fs::create_dir_all(&project).expect("mkdir"); + std::fs::write(project.join(PROJECT_MANIFEST), manifest).expect("write"); + std::fs::write(dir.path().join("outside.bin"), b"secret").expect("write"); + std::fs::create_dir_all(dir.path().join("elsewhere")).expect("mkdir"); + std::fs::write( + dir.path().join("elsewhere").join(CANISTER_MANIFEST), + "name: stray\nbuild:\n steps:\n - type: pre-built\n path: stray.wasm\n", + ) + .expect("write"); + dir + } + + async fn load(files: &dyn FileSystem, pdir: &Path) -> Result { + let m: ProjectManifest = load_manifest_from_path(files, &pdir.join(PROJECT_MANIFEST)) + .await + .map_err(|e| chain(&e))?; + consolidate_manifest(files, pdir, &NoRecipes, &m) + .await + .map_err(|e| chain(&e)) + } + + fn chain(e: &(dyn std::error::Error + 'static)) -> String { + let mut text = e.to_string(); + let mut source = e.source(); + while let Some(cause) = source { + text.push_str(": "); + text.push_str(&cause.to_string()); + source = cause.source(); + } + text + } + + #[tokio::test] + async fn an_argument_file_outside_the_root_is_read_by_the_host_and_refused_confined() { + let dir = tree(indoc::indoc! {" + canisters: + - name: backend + init_args: + path: ../outside.bin + format: bin + build: + steps: + - type: pre-built + path: backend.wasm + "}); + let pdir = dir.path().join("project"); + + let project = load(&HostFileSystem, &pdir) + .await + .expect("the host reads anywhere"); + let (_, backend) = &project.canisters["backend"]; + assert_eq!( + backend.init_args, + Some(CanisterArgs::Binary(b"secret".to_vec())) + ); + + let confined = Confined::new(&pdir).expect("confine"); + let err = load(&confined, &pdir) + .await + .expect_err("confined, the read is refused"); + assert!(err.contains("init_args"), "{err}"); + assert!(err.contains("is outside"), "{err}"); + } + + #[tokio::test] + async fn a_canister_directory_outside_the_root_is_found_by_the_host_and_not_confined() { + let dir = tree("canisters:\n - ../elsewhere\n"); + let pdir = dir.path().join("project"); + + let project = load(&HostFileSystem, &pdir) + .await + .expect("the host reads anywhere"); + assert!(project.canisters.contains_key("stray")); + + let confined = Confined::new(&pdir).expect("confine"); + let err = load(&confined, &pdir) + .await + .expect_err("confined, the directory is not there"); + assert!(err.contains("../elsewhere"), "{err}"); + } +} + #[cfg(all(test, feature = "host"))] mod recipe_sync_tests { use super::*; From 6f5b535ba00c5eff3e86e59ff1ca2bf3489cd79f Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Wed, 4 Nov 2026 19:00:07 +0000 Subject: [PATCH 02/24] fix(project): hold Confined to the root by spelling alone The symlink walk was the wrong layer. A symlink is the business of whoever puts a tree on disk: bundling writes only regular files and directories, and a consumer unpacking an archive it does not trust refuses any entry that is not, so the tree Confined reads has no links to follow. The user's own tree may link wherever it likes, and the host follows it. Confined now resolves every path lexically against the root and does nothing else. Canonicalizing the root in `new` is also what failed CI on macOS and Windows: the root was stored as `/private/var/...` or `runneradmin`, while every absolute path the loader passed was spelled as the caller got it (`/var/...`, `RUNNER~1`), and so refused. The root is now kept as spelled, and a unix test reproduces the failure through a symlinked alias of the project directory. The doc claim that one seam holds every loader read is narrowed: a local recipe template is read by the `Resolve` implementation, which a consumer holding an untrusted tree must confine or refuse itself. Co-Authored-By: Claude Fable 5.1 --- crates/icp-project/src/files.rs | 194 ++++++++++++++---------------- crates/icp-project/src/project.rs | 10 +- 2 files changed, 94 insertions(+), 110 deletions(-) diff --git a/crates/icp-project/src/files.rs b/crates/icp-project/src/files.rs index e58ddf160..b08c0f5c2 100644 --- a/crates/icp-project/src/files.rs +++ b/crates/icp-project/src/files.rs @@ -88,28 +88,45 @@ pub trait Scratch: Send + Sync { fn path(&self) -> &Path; } -/// This machine's filesystem, held to one directory. +/// This machine's filesystem, held to one directory by the spelling of each +/// path alone. /// /// Every path is resolved against the root before the disk sees it: a -/// relative path is joined to the root, `.` is dropped, `..` is applied, and a -/// path that comes to rest outside the root — by climbing above it, by being -/// absolute somewhere else, or through a symlink inside the root that points -/// out — is refused with [`OutsideRoot`]. The predicates answer `false` for -/// such a path, as they do for any other failure, and scratch directories are -/// made under the root, so what a build writes and reads back stays inside. +/// relative path is joined to the root, `.` is dropped, `..` is applied, and +/// a path that comes to rest outside the root — by climbing above it, or by +/// being absolute somewhere else — is refused with [`OutsideRoot`]. The +/// predicates answer `false` for such a path, as they do for any other +/// failure, and scratch directories are made under the root, so what a build +/// writes and reads back stays inside. /// /// For a project whose files are not the user's own tree: a bundle handed /// over by someone else, which `icp project bundle` wrote with every path /// held to the project it came from, but which the loader would otherwise -/// take at its word. The loader resolves every path a manifest names -/// through the [`FileSystem`] it is given, so confining that one seam holds -/// all of them — the canister directories, the manifests in them, the -/// argument and variable files, the dependency projects — without the -/// loader enumerating its fields. +/// take at its word. The loader resolves every path it reads itself through +/// the [`FileSystem`] it is given, so confining that one seam holds all of +/// them — the canister directories, the manifests in them, the argument and +/// variable files, the dependency projects — without the loader enumerating +/// its fields. A local recipe template is the one file the loader has read +/// for it rather than reading: the [`Resolve`](crate::canister::recipe::Resolve) +/// implementation fetches it, and one holding a tree it does not trust must +/// confine or refuse that itself. +/// +/// The check is lexical, so it holds only for a tree without symlinks. That +/// is the shape a bundle has: `icp project bundle` writes an archive of +/// regular files and directories, and a consumer unpacking one it does not +/// trust refuses any entry that is not, so nothing in the tree can lead +/// elsewhere. A tree that does carry symlinks is followed as the host +/// follows them, and that is deliberate — the user's own project may link +/// wherever it likes. The tree is also taken to be the caller's own while +/// the project is loaded, with nothing else rewriting it underneath. +/// +/// Paths are matched against the root as the caller spelled it, not as it +/// canonicalizes, so a caller that names the root through a symlink (macOS +/// keeps `/var` as a link to `/private/var`) or by its short name (Windows' +/// `RUNNER~1`) is understood as long as it keeps to the same spelling. #[cfg(feature = "host")] #[derive(Debug, Clone)] pub struct Confined { - /// Canonical, so a canonicalized path compares against it. root: PathBuf, } @@ -122,28 +139,13 @@ pub struct OutsideRoot { pub root: PathBuf, } -/// Why a directory cannot be confined to. -#[cfg(feature = "host")] -#[derive(Debug, Snafu)] -pub enum ConfineError { - #[snafu(display("cannot resolve '{path}' to confine the project to it"))] - Resolve { - path: PathBuf, - source: std::io::Error, - }, - #[snafu(display("'{path}' resolves to a path that is not UTF-8"))] - NotUtf8 { path: PathBuf }, -} - #[cfg(feature = "host")] impl Confined { - /// Confine the host filesystem to `root`, which must exist. - pub fn new(root: &Path) -> Result { - let real = dunce::canonicalize(root).context(ResolveSnafu { path: root })?; - let root = PathBuf::from_path_buf(real).map_err(|_| ConfineError::NotUtf8 { - path: root.to_path_buf(), - })?; - Ok(Self { root }) + /// Confine the host filesystem to `root`. + pub fn new(root: &Path) -> Self { + Self { + root: root.to_path_buf(), + } } /// The directory everything is held to. @@ -151,12 +153,11 @@ impl Confined { &self.root } - /// Where `path` lies under the root, by its spelling alone: joined to the - /// root if relative, with `.` dropped and `..` applied, and refused if it - /// would come to rest outside. What a caller that needs a real path for - /// something that does not read through the trait — a runtime mounting a - /// directory — asks for; the trait's own reads also follow symlinks - /// before trusting the answer. + /// Where `path` lies under the root: joined to the root if relative, + /// with `.` dropped and `..` applied, and refused if it would come to + /// rest outside. What a caller that needs a real path for something that + /// does not read through the trait — a runtime mounting a directory — + /// asks for. pub fn resolve(&self, path: &Path) -> Result { let outside = || { FsError::new(OutsideRoot { @@ -184,92 +185,60 @@ impl Confined { } Ok(resolved) } - - /// [`resolve`](Self::resolve), then follow the symlinks of as much of the - /// path as exists and check where that leads: a link inside the root to - /// a place outside it resolves outside, however it is spelled. - fn resolve_real(&self, path: &Path) -> Result { - let resolved = self.resolve(path)?; - let mut probe = resolved.as_path(); - loop { - if let Ok(real) = dunce::canonicalize(probe) { - if !real.starts_with(&self.root) { - return Err(FsError::new(OutsideRoot { - path: path.to_path_buf(), - root: self.root.clone(), - })); - } - return Ok(resolved); - } - match probe.parent() { - Some(parent) => probe = parent, - None => return Ok(resolved), - } - } - } } #[cfg(feature = "host")] #[async_trait] impl FileSystem for Confined { async fn read(&self, path: &Path) -> Result, FsError> { - HostFileSystem.read(&self.resolve_real(path)?).await + HostFileSystem.read(&self.resolve(path)?).await } async fn read_to_string(&self, path: &Path) -> Result { - HostFileSystem - .read_to_string(&self.resolve_real(path)?) - .await + HostFileSystem.read_to_string(&self.resolve(path)?).await } async fn write(&self, path: &Path, contents: &[u8]) -> Result<(), FsError> { - HostFileSystem - .write(&self.resolve_real(path)?, contents) - .await + HostFileSystem.write(&self.resolve(path)?, contents).await } async fn create_dir_all(&self, path: &Path) -> Result<(), FsError> { - HostFileSystem - .create_dir_all(&self.resolve_real(path)?) - .await + HostFileSystem.create_dir_all(&self.resolve(path)?).await } async fn copy(&self, from: &Path, to: &Path) -> Result<(), FsError> { HostFileSystem - .copy(&self.resolve_real(from)?, &self.resolve_real(to)?) + .copy(&self.resolve(from)?, &self.resolve(to)?) .await } async fn exists(&self, path: &Path) -> bool { - match self.resolve_real(path) { - Ok(real) => HostFileSystem.exists(&real).await, + match self.resolve(path) { + Ok(path) => HostFileSystem.exists(&path).await, Err(_) => false, } } async fn is_file(&self, path: &Path) -> bool { - match self.resolve_real(path) { - Ok(real) => HostFileSystem.is_file(&real).await, + match self.resolve(path) { + Ok(path) => HostFileSystem.is_file(&path).await, Err(_) => false, } } async fn is_dir(&self, path: &Path) -> bool { - match self.resolve_real(path) { - Ok(real) => HostFileSystem.is_dir(&real).await, + match self.resolve(path) { + Ok(path) => HostFileSystem.is_dir(&path).await, Err(_) => false, } } async fn read_dir(&self, path: &Path) -> Result, FsError> { - HostFileSystem.read_dir(&self.resolve_real(path)?).await + HostFileSystem.read_dir(&self.resolve(path)?).await } async fn canonicalize(&self, path: &Path) -> Option { - HostFileSystem - .canonicalize(&self.resolve_real(path).ok()?) - .await - .filter(|real| real.starts_with(&self.root)) + HostFileSystem.canonicalize(&self.resolve(path).ok()?).await } async fn scratch_dir(&self) -> Result, FsError> { @@ -579,9 +548,9 @@ mod tests { use super::*; /// Every spelling of a path resolves under the root or is refused: a - /// relative one is joined, `.` and `..` are applied, an absolute one is - /// taken only under the root, and a symlink inside the root that points - /// out leads nowhere. + /// relative one is joined, `.` and `..` are applied, and an absolute one + /// is taken only under the root. By its spelling alone: a symlink is the + /// extractor's business, not this type's. #[cfg(feature = "host")] mod confined { use super::*; @@ -592,7 +561,7 @@ mod tests { std::fs::write(dir.path().join("project/icp.yaml"), b"canisters: []").expect("write"); std::fs::write(dir.path().join("project/canisters/a.wasm"), b"wasm").expect("write"); std::fs::write(dir.path().join("outside.txt"), b"secret").expect("write"); - let fs = Confined::new(&dir.path().join("project")).expect("confine"); + let fs = Confined::new(&dir.path().join("project")); (dir, fs) } @@ -610,7 +579,9 @@ mod tests { assert!(fs.is_file(Path::new(spelling)).await, "{spelling}"); assert_eq!( fs.canonicalize(Path::new(spelling)).await, - Some(root.join("canisters/a.wasm")), + HostFileSystem + .canonicalize(&root.join("canisters/a.wasm")) + .await, "{spelling}" ); } @@ -666,29 +637,40 @@ mod tests { assert!(!dir.path().join("planted").exists()); } + /// What broke on macOS and Windows: the temp dir is reached through + /// a symlink (`/var` to `/private/var`) or a short name (`RUNNER~1`), + /// so a root compared by what it canonicalizes to refused every + /// absolute path spelled the way the caller got it. #[cfg(unix)] #[tokio::test] - async fn a_symlink_inside_pointing_out_is_refused() { - let (dir, fs) = root(); - let root = fs.root().to_path_buf(); - std::os::unix::fs::symlink(dir.path().join("outside.txt"), root.join("link.txt")) + async fn the_root_is_matched_as_the_caller_spells_it() { + let (dir, _) = root(); + std::os::unix::fs::symlink(dir.path().join("project"), dir.path().join("alias")) .expect("symlink"); - std::os::unix::fs::symlink(dir.path(), root.join("up")).expect("symlink"); - for spelling in ["link.txt", "up/outside.txt"] { - let err = fs - .read(Path::new(spelling)) - .await - .expect_err(spelling) - .to_string(); - assert!(err.contains("is outside"), "{spelling}: {err}"); - assert!(!fs.exists(Path::new(spelling)).await, "{spelling}"); - } - // A link that leads back inside is inside: what matters is where - // the path comes to rest, not how it got there. + let spelled = dir.path().join("alias"); + let fs = Confined::new(&spelled); assert_eq!( - fs.read(Path::new("up/project/icp.yaml")).await.unwrap(), + fs.read(&spelled.join("icp.yaml")).await.unwrap(), b"canisters: []" ); + assert_eq!( + fs.read(Path::new("icp.yaml")).await.unwrap(), + b"canisters: []" + ); + assert!(fs.is_dir(&spelled).await); + } + + /// The check is lexical: a symlink is followed as the host follows + /// it. A tree someone else handed over must have had its links + /// refused on the way in; the user's own tree may link where it + /// likes. + #[cfg(unix)] + #[tokio::test] + async fn a_symlink_is_followed_as_the_host_follows_it() { + let (dir, fs) = root(); + std::os::unix::fs::symlink(dir.path().join("outside.txt"), fs.root().join("link.txt")) + .expect("symlink"); + assert_eq!(fs.read(Path::new("link.txt")).await.unwrap(), b"secret"); } #[tokio::test] diff --git a/crates/icp-project/src/project.rs b/crates/icp-project/src/project.rs index 297ccd18f..4e5b8cd38 100644 --- a/crates/icp-project/src/project.rs +++ b/crates/icp-project/src/project.rs @@ -1691,8 +1691,10 @@ pub async fn consolidate_manifest( } /// A project loaded through a [`Confined`] filesystem reads nothing outside -/// its root, whatever its manifest names: the loader resolves every path -/// through the filesystem it is given, so the one rule holds them all. +/// its root, whatever path its manifest names: the loader resolves every +/// path it reads itself through the filesystem it is given, so the one rule +/// holds them all. (A local recipe template is read by the [`Resolve`] +/// implementation instead, which is why these tests supply none.) #[cfg(all(test, feature = "host"))] mod confined_tests { use super::*; @@ -1771,7 +1773,7 @@ mod confined_tests { Some(CanisterArgs::Binary(b"secret".to_vec())) ); - let confined = Confined::new(&pdir).expect("confine"); + let confined = Confined::new(&pdir); let err = load(&confined, &pdir) .await .expect_err("confined, the read is refused"); @@ -1789,7 +1791,7 @@ mod confined_tests { .expect("the host reads anywhere"); assert!(project.canisters.contains_key("stray")); - let confined = Confined::new(&pdir).expect("confine"); + let confined = Confined::new(&pdir); let err = load(&confined, &pdir) .await .expect_err("confined, the directory is not there"); From 397bef0c12be81f5098ef920875772a9117bbbdf Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Tue, 10 Nov 2026 01:03:56 +0000 Subject: [PATCH 03/24] docs(project): say what Confined holds in fewer words Co-Authored-By: Claude --- crates/icp-project/src/files.rs | 51 ++++++++++----------------------- 1 file changed, 15 insertions(+), 36 deletions(-) diff --git a/crates/icp-project/src/files.rs b/crates/icp-project/src/files.rs index b08c0f5c2..e392c5de0 100644 --- a/crates/icp-project/src/files.rs +++ b/crates/icp-project/src/files.rs @@ -89,41 +89,21 @@ pub trait Scratch: Send + Sync { } /// This machine's filesystem, held to one directory by the spelling of each -/// path alone. +/// path alone, for a project that is not the user's own tree: a bundle +/// handed over by someone else. /// -/// Every path is resolved against the root before the disk sees it: a -/// relative path is joined to the root, `.` is dropped, `..` is applied, and -/// a path that comes to rest outside the root — by climbing above it, or by -/// being absolute somewhere else — is refused with [`OutsideRoot`]. The -/// predicates answer `false` for such a path, as they do for any other -/// failure, and scratch directories are made under the root, so what a build -/// writes and reads back stays inside. +/// A relative path is joined to the root, `.` is dropped, `..` is applied, +/// and a path that comes to rest outside the root is refused with +/// [`OutsideRoot`]; the predicates answer `false` for one, and scratch +/// directories are made under the root. The loader reads every path through +/// the [`FileSystem`] it is given, so this holds all of them. A local recipe +/// template is the exception: the +/// [`Resolve`](crate::canister::recipe::Resolve) implementation reads it, and +/// must confine or refuse it itself. /// -/// For a project whose files are not the user's own tree: a bundle handed -/// over by someone else, which `icp project bundle` wrote with every path -/// held to the project it came from, but which the loader would otherwise -/// take at its word. The loader resolves every path it reads itself through -/// the [`FileSystem`] it is given, so confining that one seam holds all of -/// them — the canister directories, the manifests in them, the argument and -/// variable files, the dependency projects — without the loader enumerating -/// its fields. A local recipe template is the one file the loader has read -/// for it rather than reading: the [`Resolve`](crate::canister::recipe::Resolve) -/// implementation fetches it, and one holding a tree it does not trust must -/// confine or refuse that itself. -/// -/// The check is lexical, so it holds only for a tree without symlinks. That -/// is the shape a bundle has: `icp project bundle` writes an archive of -/// regular files and directories, and a consumer unpacking one it does not -/// trust refuses any entry that is not, so nothing in the tree can lead -/// elsewhere. A tree that does carry symlinks is followed as the host -/// follows them, and that is deliberate — the user's own project may link -/// wherever it likes. The tree is also taken to be the caller's own while -/// the project is loaded, with nothing else rewriting it underneath. -/// -/// Paths are matched against the root as the caller spelled it, not as it -/// canonicalizes, so a caller that names the root through a symlink (macOS -/// keeps `/var` as a link to `/private/var`) or by its short name (Windows' -/// `RUNNER~1`) is understood as long as it keeps to the same spelling. +/// The check is lexical, so it holds only for a tree without symlinks, which +/// is what a bundle is; a link is followed as the host follows it. The root +/// is matched as the caller spelled it, not as it canonicalizes. #[cfg(feature = "host")] #[derive(Debug, Clone)] pub struct Confined { @@ -637,9 +617,8 @@ mod tests { assert!(!dir.path().join("planted").exists()); } - /// What broke on macOS and Windows: the temp dir is reached through - /// a symlink (`/var` to `/private/var`) or a short name (`RUNNER~1`), - /// so a root compared by what it canonicalizes to refused every + /// A temp dir on macOS is reached through a symlink (`/var`), so a + /// root compared by what it canonicalizes to would refuse every /// absolute path spelled the way the caller got it. #[cfg(unix)] #[tokio::test] From 659b354478a10541a8f0dc182860293283c0ccf0 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Tue, 10 Nov 2026 13:25:45 +0000 Subject: [PATCH 04/24] feat(project): Confined takes an absolute root and refuses every symlink A path through a symlink below the root is refused wherever the link leads, so the type is a boundary on its own rather than only for a tree someone else has already held to shape. The root must be absolute, since every path is matched against its spelling. Co-Authored-By: Claude --- crates/icp-project/src/files.rs | 158 +++++++++++++++++++++++------- crates/icp-project/src/project.rs | 4 +- 2 files changed, 127 insertions(+), 35 deletions(-) diff --git a/crates/icp-project/src/files.rs b/crates/icp-project/src/files.rs index e392c5de0..b93ec5be1 100644 --- a/crates/icp-project/src/files.rs +++ b/crates/icp-project/src/files.rs @@ -88,28 +88,43 @@ pub trait Scratch: Send + Sync { fn path(&self) -> &Path; } -/// This machine's filesystem, held to one directory by the spelling of each -/// path alone, for a project that is not the user's own tree: a bundle -/// handed over by someone else. +/// This machine's filesystem, held to one directory, for a project that is +/// not the user's own tree: one handed over by someone else. /// -/// A relative path is joined to the root, `.` is dropped, `..` is applied, -/// and a path that comes to rest outside the root is refused with -/// [`OutsideRoot`]; the predicates answer `false` for one, and scratch -/// directories are made under the root. The loader reads every path through -/// the [`FileSystem`] it is given, so this holds all of them. A local recipe -/// template is the exception: the +/// Every path must name something under the root, reached through no +/// symlink. A relative path is joined to the root, `.` is dropped, `..` is +/// applied, and a path that comes to rest outside the root is refused with +/// [`OutsideRoot`]. Each component below the root is then looked at as it is +/// on disk, and a path that passes through a symlink is refused with +/// [`ThroughLink`], wherever the link leads. The predicates answer `false` +/// for a refused path, and scratch directories are made under the root. +/// +/// The loader reads every path through the [`FileSystem`] it is given, so +/// this holds all of them. A local recipe template is the exception: the /// [`Resolve`](crate::canister::recipe::Resolve) implementation reads it, and -/// must confine or refuse it itself. +/// must read it through this filesystem or refuse it. /// -/// The check is lexical, so it holds only for a tree without symlinks, which -/// is what a bundle is; a link is followed as the host follows it. The root -/// is matched as the caller spelled it, not as it canonicalizes. +/// The root is absolute, and is matched as the caller spelled it, not as it +/// canonicalizes. It and everything above it are the caller's, and may be +/// reached through a link; only what lies below is held to shape. The tree +/// is taken to be the caller's own while it is used, with nothing else +/// rewriting it between the look and the read. #[cfg(feature = "host")] #[derive(Debug, Clone)] pub struct Confined { root: PathBuf, } +/// A root a [`Confined`] filesystem was asked to hold to that is not absolute. +#[cfg(feature = "host")] +#[derive(Debug, Snafu)] +#[snafu(display( + "'{root}' is not an absolute path, which the directory a project is confined to must be" +))] +pub struct RootNotAbsolute { + pub root: PathBuf, +} + /// A path a [`Confined`] filesystem was asked for that is not under its root. #[cfg(feature = "host")] #[derive(Debug, Snafu)] @@ -119,13 +134,27 @@ pub struct OutsideRoot { pub root: PathBuf, } +/// A path a [`Confined`] filesystem was asked for that passes through a +/// symlink. +#[cfg(feature = "host")] +#[derive(Debug, Snafu)] +#[snafu(display( + "'{path}' passes through '{link}', which is a symlink, and every path in this project must \ + name a regular file or directory under its root" +))] +pub struct ThroughLink { + pub path: PathBuf, + pub link: PathBuf, +} + #[cfg(feature = "host")] impl Confined { - /// Confine the host filesystem to `root`. - pub fn new(root: &Path) -> Self { - Self { + /// Confine the host filesystem to `root`, which must be absolute. + pub fn new(root: &Path) -> Result { + snafu::ensure!(root.is_absolute(), RootNotAbsoluteSnafu { root }); + Ok(Self { root: root.to_path_buf(), - } + }) } /// The directory everything is held to. @@ -135,9 +164,9 @@ impl Confined { /// Where `path` lies under the root: joined to the root if relative, /// with `.` dropped and `..` applied, and refused if it would come to - /// rest outside. What a caller that needs a real path for something that - /// does not read through the trait — a runtime mounting a directory — - /// asks for. + /// rest outside, or passes through a symlink on the way. What a caller + /// that needs a real path for something that does not read through the + /// trait — a runtime mounting a directory — asks for. pub fn resolve(&self, path: &Path) -> Result { let outside = || { FsError::new(OutsideRoot { @@ -163,6 +192,28 @@ impl Confined { Utf8Component::RootDir | Utf8Component::Prefix(_) => return Err(outside()), } } + + // Each component as it is on disk, not what it points at, so a link + // is seen wherever it leads, dangling or not. The first component + // that is not there ends the walk: nothing beyond it can be a link. + let mut at = self.root.clone(); + for component in resolved + .strip_prefix(&self.root) + .expect("resolved under the root") + .components() + { + at.push(component.as_str()); + match std::fs::symlink_metadata(&at) { + Ok(meta) if meta.file_type().is_symlink() => { + return Err(FsError::new(ThroughLink { + path: path.to_path_buf(), + link: at, + })); + } + Ok(_) => {} + Err(_) => break, + } + } Ok(resolved) } } @@ -528,9 +579,8 @@ mod tests { use super::*; /// Every spelling of a path resolves under the root or is refused: a - /// relative one is joined, `.` and `..` are applied, and an absolute one - /// is taken only under the root. By its spelling alone: a symlink is the - /// extractor's business, not this type's. + /// relative one is joined, `.` and `..` are applied, an absolute one is + /// taken only under the root, and none may pass through a symlink. #[cfg(feature = "host")] mod confined { use super::*; @@ -541,7 +591,7 @@ mod tests { std::fs::write(dir.path().join("project/icp.yaml"), b"canisters: []").expect("write"); std::fs::write(dir.path().join("project/canisters/a.wasm"), b"wasm").expect("write"); std::fs::write(dir.path().join("outside.txt"), b"secret").expect("write"); - let fs = Confined::new(&dir.path().join("project")); + let fs = Confined::new(&dir.path().join("project")).expect("absolute"); (dir, fs) } @@ -627,7 +677,7 @@ mod tests { std::os::unix::fs::symlink(dir.path().join("project"), dir.path().join("alias")) .expect("symlink"); let spelled = dir.path().join("alias"); - let fs = Confined::new(&spelled); + let fs = Confined::new(&spelled).expect("absolute"); assert_eq!( fs.read(&spelled.join("icp.yaml")).await.unwrap(), b"canisters: []" @@ -639,17 +689,59 @@ mod tests { assert!(fs.is_dir(&spelled).await); } - /// The check is lexical: a symlink is followed as the host follows - /// it. A tree someone else handed over must have had its links - /// refused on the way in; the user's own tree may link where it - /// likes. + #[test] + fn a_root_that_is_not_absolute_is_refused() { + let err = Confined::new(Path::new("project")).unwrap_err(); + assert!(err.to_string().contains("not an absolute path"), "{err}"); + } + + /// A path through a symlink is refused wherever the link leads: out + /// of the root, back inside it, or nowhere. A link in the middle of + /// a path counts as much as one at its end. #[cfg(unix)] #[tokio::test] - async fn a_symlink_is_followed_as_the_host_follows_it() { + async fn a_path_through_a_symlink_is_refused_wherever_it_leads() { + use std::os::unix::fs::symlink; let (dir, fs) = root(); - std::os::unix::fs::symlink(dir.path().join("outside.txt"), fs.root().join("link.txt")) - .expect("symlink"); - assert_eq!(fs.read(Path::new("link.txt")).await.unwrap(), b"secret"); + let project = fs.root().to_path_buf(); + symlink(dir.path().join("outside.txt"), project.join("out.txt")).expect("symlink"); + symlink(project.join("icp.yaml"), project.join("in.yaml")).expect("symlink"); + symlink(project.join("nowhere"), project.join("dangling")).expect("symlink"); + symlink(project.join("canisters"), project.join("dir-link")).expect("symlink"); + + for (spelling, link) in [ + ("out.txt", "out.txt"), + ("in.yaml", "in.yaml"), + ("dangling", "dangling"), + ("dir-link/a.wasm", "dir-link"), + ("./dir-link/../dir-link/a.wasm", "dir-link"), + ] { + let err = fs + .read(Path::new(spelling)) + .await + .expect_err(spelling) + .to_string(); + assert!(err.contains("passes through"), "{spelling}: {err}"); + assert!(err.contains(link), "{spelling}: {err}"); + assert!(!fs.exists(Path::new(spelling)).await, "{spelling}"); + assert!(!fs.is_file(Path::new(spelling)).await, "{spelling}"); + assert!(!fs.is_dir(Path::new(spelling)).await, "{spelling}"); + assert!( + fs.canonicalize(Path::new(spelling)).await.is_none(), + "{spelling}" + ); + } + // Nor may a link be written through. + let err = fs + .write(Path::new("out.txt"), b"x") + .await + .unwrap_err() + .to_string(); + assert!(err.contains("passes through"), "{err}"); + assert_eq!( + std::fs::read(dir.path().join("outside.txt")).unwrap(), + b"secret" + ); } #[tokio::test] diff --git a/crates/icp-project/src/project.rs b/crates/icp-project/src/project.rs index 4e5b8cd38..83eb475f7 100644 --- a/crates/icp-project/src/project.rs +++ b/crates/icp-project/src/project.rs @@ -1773,7 +1773,7 @@ mod confined_tests { Some(CanisterArgs::Binary(b"secret".to_vec())) ); - let confined = Confined::new(&pdir); + let confined = Confined::new(&pdir).expect("absolute"); let err = load(&confined, &pdir) .await .expect_err("confined, the read is refused"); @@ -1791,7 +1791,7 @@ mod confined_tests { .expect("the host reads anywhere"); assert!(project.canisters.contains_key("stray")); - let confined = Confined::new(&pdir); + let confined = Confined::new(&pdir).expect("absolute"); let err = load(&confined, &pdir) .await .expect_err("confined, the directory is not there"); From cbf9e0735915fff1d008fcfae798034ec10f2b56 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Tue, 10 Nov 2026 14:36:24 +0000 Subject: [PATCH 05/24] docs(project): note that a path Confined refuses can be reported as missing Co-Authored-By: Claude --- crates/icp-project/src/files.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/icp-project/src/files.rs b/crates/icp-project/src/files.rs index b93ec5be1..fa55285fe 100644 --- a/crates/icp-project/src/files.rs +++ b/crates/icp-project/src/files.rs @@ -104,6 +104,12 @@ pub trait Scratch: Send + Sync { /// [`Resolve`](crate::canister::recipe::Resolve) implementation reads it, and /// must read it through this filesystem or refuse it. /// +/// Known issue: a predicate can only answer `true` or `false`, so the reason +/// a path was refused is lost there, and a caller that checks before it +/// reads reports a refused path as missing. The loader does this for a +/// canister directory and for a dependency's manifest: `canisters: +/// [../elsewhere]` fails as "not found" rather than as outside the root. +/// /// The root is absolute, and is matched as the caller spelled it, not as it /// canonicalizes. It and everything above it are the caller's, and may be /// reached through a link; only what lies below is held to shape. The tree From 8a1df3df3aa5c88af87ddee74f95fbf73ebf3f0e Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Tue, 10 Nov 2026 14:40:22 +0000 Subject: [PATCH 06/24] fix(project): Confined refuses a path that mentions a symlink, before any '..' after it The link check ran on the path after '..' was applied, so 'link/../file' cancelled the link out by its spelling while the host, handed the path as written, would follow the link and take '..' from where it leads. Each component is now looked at as it is named. The doc says that Confined holds reads and writes and nothing else. Co-Authored-By: Claude --- crates/icp-project/src/files.rs | 51 +++++++++++++++++---------------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/crates/icp-project/src/files.rs b/crates/icp-project/src/files.rs index fa55285fe..e9b4936d8 100644 --- a/crates/icp-project/src/files.rs +++ b/crates/icp-project/src/files.rs @@ -96,8 +96,13 @@ pub trait Scratch: Send + Sync { /// applied, and a path that comes to rest outside the root is refused with /// [`OutsideRoot`]. Each component below the root is then looked at as it is /// on disk, and a path that passes through a symlink is refused with -/// [`ThroughLink`], wherever the link leads. The predicates answer `false` -/// for a refused path, and scratch directories are made under the root. +/// [`ThroughLink`], wherever the link leads, even where a later `..` steps +/// back out of it. The predicates answer `false` for a refused path, and +/// scratch directories are made under the root. +/// +/// This confines what is read and written through it, and nothing else: a +/// `script` build step in a manifest still runs any command, so whoever +/// holds a tree they do not trust must also refuse such steps or sandbox them. /// /// The loader reads every path through the [`FileSystem`] it is given, so /// this holds all of them. A local recipe template is the exception: the @@ -185,10 +190,24 @@ impl Confined { } else { path }; + // Each component is looked at as it is on disk the moment it is + // named, before any `..` after it is applied: the host would follow a + // link and take `..` from where it leads, so a path that so much as + // mentions one is refused, dangling or not. let mut resolved = self.root.clone(); for component in relative.components() { match component { - Utf8Component::Normal(part) => resolved.push(part), + Utf8Component::Normal(part) => { + resolved.push(part); + if std::fs::symlink_metadata(&resolved) + .is_ok_and(|m| m.file_type().is_symlink()) + { + return Err(FsError::new(ThroughLink { + path: path.to_path_buf(), + link: resolved, + })); + } + } Utf8Component::CurDir => {} Utf8Component::ParentDir => { if resolved == self.root || !resolved.pop() { @@ -198,28 +217,6 @@ impl Confined { Utf8Component::RootDir | Utf8Component::Prefix(_) => return Err(outside()), } } - - // Each component as it is on disk, not what it points at, so a link - // is seen wherever it leads, dangling or not. The first component - // that is not there ends the walk: nothing beyond it can be a link. - let mut at = self.root.clone(); - for component in resolved - .strip_prefix(&self.root) - .expect("resolved under the root") - .components() - { - at.push(component.as_str()); - match std::fs::symlink_metadata(&at) { - Ok(meta) if meta.file_type().is_symlink() => { - return Err(FsError::new(ThroughLink { - path: path.to_path_buf(), - link: at, - })); - } - Ok(_) => {} - Err(_) => break, - } - } Ok(resolved) } } @@ -721,6 +718,10 @@ mod tests { ("dangling", "dangling"), ("dir-link/a.wasm", "dir-link"), ("./dir-link/../dir-link/a.wasm", "dir-link"), + // The host would take `..` from where the link leads; the + // spelling alone would cancel the link out and find the file. + ("dir-link/../canisters/a.wasm", "dir-link"), + ("dangling/../icp.yaml", "dangling"), ] { let err = fs .read(Path::new(spelling)) From 44a10f746aa590a23d8da6330b5222f59ad2d28f Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Wed, 4 Nov 2026 19:11:35 +0000 Subject: [PATCH 07/24] fix(bundle): hold a bundle to regular files and directories A symlink inside a plugin's `files:` or `dirs:` directory was recorded as a link entry with its target unchecked, so a link to a file outside the project reached that file on whatever machine unpacked the bundle. A `files:` entry that was itself a symlink to a file outside the project was read straight into the bundle: it was validated lexically before the build, when it need not have existed, and the read followed the link. The archive walk is now the bundler's own rather than tar's. Every entry met is canonicalized and must land under the canonical project directory: one that does is appended as what it points at, so a project may link to its own files freely, and one that does not fails the bundle, naming the link and where it leads. A dangling link, a link back to a directory being copied, and anything that is neither a file nor a directory fail the same way. A `files:` entry is held to the project again once the build has run and it exists. The walk appends entries in sorted order, which makes the archive reproducible for a given tree where tar's own walk left the order to the filesystem. This is the bundling side of the rule that `files::Confined` (#866) relies on: a bundle holds no symlinks, a consumer refuses any archive entry that is not a regular file or directory (#863), and so the tree a confined loader reads has nothing in it that leads elsewhere. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 4 + crates/icp-cli/tests/bundle_tests.rs | 190 ++++++++++++++++++++ crates/icp-project/src/operations/bundle.rs | 180 ++++++++++++++++--- 3 files changed, 350 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a5bafb1b..63fd255bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ air-gapped signing, extensions # Unreleased +## Experimental + +* fix(bundle): `icp project bundle` holds only regular files and directories. A symlink inside a plugin's `files:` or `dirs:` directory used to be recorded as a link with its target unchecked, so a link to a file outside the project reached that file on whatever machine unpacked the bundle. A link to somewhere inside the project is now copied as what it points at, so a project may link to its own files freely; one that leads outside the project, dangles, or leads back to a directory being copied fails the bundle, naming the link and where it leads. A `files:` entry that is itself a symlink to a file outside the project is refused the same way, where it used to be read into the bundle. + * 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. # v1.8.0 diff --git a/crates/icp-cli/tests/bundle_tests.rs b/crates/icp-cli/tests/bundle_tests.rs index 669c38556..e9d153cd9 100644 --- a/crates/icp-cli/tests/bundle_tests.rs +++ b/crates/icp-cli/tests/bundle_tests.rs @@ -964,6 +964,196 @@ fn bundle_rejects_source_outside_project() { .stderr(contains("my-canister").and(contains("outside the project directory"))); } +/// A project with one canister whose plugin sync step declares `files: +/// seed: `, for the symlink tests: what `data/` holds is up to each. +#[cfg(unix)] +fn plugin_project_with_files_entry(ctx: &TestContext, entry: &str) -> PathBuf { + let project_dir = ctx.create_project_dir("icp"); + let wasm_src = ctx.make_asset("example_icp_mo.wasm"); + write(&project_dir.join("plugin.wasm"), b"\x00asm\x01\x00\x00\x00") + .expect("failed to write plugin wasm"); + let pm = formatdoc! {r#" + canisters: + - name: my-canister + build: + steps: + - type: script + command: cp '{wasm_src}' "$ICP_WASM_OUTPUT_PATH" + sync: + steps: + - type: plugin + path: plugin.wasm + files: + seed: {entry} + "#}; + write_string(&project_dir.join("icp.yaml"), &pm).expect("failed to write project manifest"); + project_dir +} + +/// Every entry of the archive at `path`, as its archive path, entry type and +/// contents. +#[cfg(unix)] +fn archive_entries(path: &Path) -> Vec<(String, tar::EntryType, Vec)> { + let bytes = fs::read(path.as_std_path()).expect("failed to read bundle"); + let gz = GzDecoder::new(BufReader::new(bytes.as_slice())); + let mut archive = Archive::new(gz); + let mut out = Vec::new(); + for entry in archive.entries().expect("failed to read archive entries") { + let mut entry = entry.expect("failed to read archive entry"); + let path = entry + .path() + .expect("failed to get entry path") + .to_string_lossy() + .into_owned(); + let kind = entry.header().entry_type(); + let mut data = Vec::new(); + entry.read_to_end(&mut data).expect("failed to read entry"); + out.push((path, kind, data)); + } + out +} + +/// A symlink inside a plugin directory that points somewhere inside the +/// project is copied into the bundle as what it points at — a file, or a whole +/// directory — so the archive holds regular files only and a project may link +/// to its own files freely. +#[cfg(unix)] +#[test] +fn bundle_copies_symlinks_inside_the_project_as_what_they_point_at() { + let ctx = TestContext::new(); + let project_dir = plugin_project_with_files_entry(&ctx, "data"); + + create_dir_all(&project_dir.join("data")).expect("failed to create dir"); + create_dir_all(&project_dir.join("common")).expect("failed to create dir"); + write_string(&project_dir.join("data/real.txt"), "real").expect("failed to write file"); + write_string(&project_dir.join("common/c.txt"), "common").expect("failed to write file"); + std::os::unix::fs::symlink("real.txt", project_dir.join("data/link.txt")) + .expect("failed to create symlink"); + std::os::unix::fs::symlink("../common", project_dir.join("data/shared")) + .expect("failed to create symlink"); + + let bundle_path = project_dir.join("bundle.tar.gz"); + ctx.icp() + .current_dir(&project_dir) + .args(["project", "bundle", "--output", bundle_path.as_str()]) + .assert() + .success(); + + let entries = archive_entries(&bundle_path); + assert!( + entries + .iter() + .all(|(_, kind, _)| !kind.is_symlink() && !kind.is_hard_link()), + "the archive holds a link: {:?}", + entries.iter().map(|(p, k, _)| (p, *k)).collect::>() + ); + for (path, contents) in [ + ("plugins/my-canister/0/files/data/real.txt", "real"), + ("plugins/my-canister/0/files/data/link.txt", "real"), + ("plugins/my-canister/0/files/data/shared/c.txt", "common"), + ] { + let (_, kind, data) = entries + .iter() + .find(|(p, _, _)| p == path) + .unwrap_or_else(|| panic!("{path} missing from {entries:?}")); + assert!(kind.is_file(), "{path} is a {kind:?}"); + assert_eq!(data, contents.as_bytes(), "{path}"); + } +} + +/// A symlink inside a plugin directory that leads outside the project fails +/// the bundle, naming the link and where it leads, rather than being recorded +/// as a link for the unpacking machine to follow. +#[cfg(unix)] +#[test] +fn bundle_rejects_symlink_pointing_outside_the_project() { + let ctx = TestContext::new(); + let project_dir = plugin_project_with_files_entry(&ctx, "data"); + + let outside = ctx.home_path().join("outside-secret.txt"); + write_string(&outside, "secret").expect("failed to write outside file"); + create_dir_all(&project_dir.join("data")).expect("failed to create dir"); + std::os::unix::fs::symlink(&outside, project_dir.join("data/leak.txt")) + .expect("failed to create symlink"); + + ctx.icp() + .current_dir(&project_dir) + .args(["project", "bundle", "--output", "bundle.tar.gz"]) + .assert() + .failure() + .stderr( + contains("leak.txt") + .and(contains("outside-secret.txt")) + .and(contains("outside the project directory")), + ); +} + +/// A symlink to nothing cannot be copied as what it points at, and is named +/// rather than skipped. +#[cfg(unix)] +#[test] +fn bundle_rejects_dangling_symlink() { + let ctx = TestContext::new(); + let project_dir = plugin_project_with_files_entry(&ctx, "data"); + + create_dir_all(&project_dir.join("data")).expect("failed to create dir"); + std::os::unix::fs::symlink("nowhere.txt", project_dir.join("data/gone.txt")) + .expect("failed to create symlink"); + + ctx.icp() + .current_dir(&project_dir) + .args(["project", "bundle", "--output", "bundle.tar.gz"]) + .assert() + .failure() + .stderr( + contains("gone.txt") + .and(contains("nowhere.txt")) + .and(contains("does not exist")), + ); +} + +/// A symlink back to a directory the bundle is in the middle of copying would +/// copy it without end; it is refused, naming the link. +#[cfg(unix)] +#[test] +fn bundle_rejects_symlink_cycle() { + let ctx = TestContext::new(); + let project_dir = plugin_project_with_files_entry(&ctx, "data"); + + create_dir_all(&project_dir.join("data/inner")).expect("failed to create dir"); + std::os::unix::fs::symlink("../../data", project_dir.join("data/inner/back")) + .expect("failed to create symlink"); + + ctx.icp() + .current_dir(&project_dir) + .args(["project", "bundle", "--output", "bundle.tar.gz"]) + .assert() + .failure() + .stderr(contains("back").and(contains("cycle"))); +} + +/// A `files:` entry that is itself a symlink to a file outside the project is +/// refused like any other source outside the project, where the lexical check +/// before the build would have let it through and the read followed it. +#[cfg(unix)] +#[test] +fn bundle_rejects_symlinked_plugin_file_outside_the_project() { + let ctx = TestContext::new(); + let project_dir = plugin_project_with_files_entry(&ctx, "config.txt"); + + let outside = ctx.home_path().join("outside-secret.txt"); + write_string(&outside, "secret").expect("failed to write outside file"); + std::os::unix::fs::symlink(&outside, project_dir.join("config.txt")) + .expect("failed to create symlink"); + + ctx.icp() + .current_dir(&project_dir) + .args(["project", "bundle", "--output", "bundle.tar.gz"]) + .assert() + .failure() + .stderr(contains("my-canister").and(contains("outside the project directory"))); +} + /// The environment reaching build steps as `ICP_CLI_ENVIRONMENT` defaults to `ic` /// for a bundle, rather than the `local` the rest of the CLI defaults to. #[test] diff --git a/crates/icp-project/src/operations/bundle.rs b/crates/icp-project/src/operations/bundle.rs index e561f9641..9309bfb97 100644 --- a/crates/icp-project/src/operations/bundle.rs +++ b/crates/icp-project/src/operations/bundle.rs @@ -248,6 +248,39 @@ pub enum BundleError { path: PathBuf, source: crate::files::FsError, }, + + #[snafu(display( + "'{path}' resolves to '{target}', outside the project directory '{root}'; a symlink \ + there points out of the project, and a bundle holds only what is inside it" + ))] + LinkEscapesProject { + path: PathBuf, + target: PathBuf, + root: PathBuf, + }, + + #[snafu(display("'{path}' is a symlink to '{target}', which does not exist"))] + DanglingLink { path: PathBuf, target: String }, + + #[snafu(display( + "'{path}' is a symlink back to '{target}', a directory the bundle is already copying; \ + a bundle cannot hold a cycle" + ))] + LinkCycle { path: PathBuf, target: PathBuf }, + + #[snafu(display("cannot resolve '{path}' to add it to the bundle"))] + ResolveArchiveEntry { + path: PathBuf, + source: std::io::Error, + }, + + #[snafu(display( + "'{path}' is neither a file nor a directory, which are all a bundle can hold" + ))] + UnsupportedEntry { path: PathBuf }, + + #[snafu(transparent)] + ListDir { source: crate::fs::IoError }, } /// In-memory bytes destined for a single tar entry. @@ -510,6 +543,7 @@ pub async fn create_bundle( write_archive( files, output, + &canonical_project_dir, &manifests, &bundle_artifacts, &args_files, @@ -1444,13 +1478,13 @@ struct ArchiveWriter { impl ArchiveWriter { fn new(inner: W) -> Self { let mut builder = Builder::new(inner); - // Record symlinks as Symlink entries rather than slurping their targets — keeps secrets - // outside the project from leaking via a symlinked asset dir. + // Every path handed to the builder is canonical, so there is no link left for it to + // follow; this is the backstop that keeps a link out of the archive should one get + // through, where it would otherwise be slurped as whatever it reaches. builder.follow_symlinks(false); - // Strip mtime/uid/gid from entry headers so they are metadata-normalized across machines. - // Note this does not make the archive fully byte-reproducible: `append_dir` relies on - // `append_dir_all`, which walks `read_dir` in the filesystem's order, so entry ordering - // within a directory can still differ between machines. + // Strip mtime/uid/gid from entry headers so they are metadata-normalized across + // machines, and `dir` appends a directory's entries in sorted order, so the same tree + // gives the same archive wherever it is bundled. builder.mode(tar::HeaderMode::Deterministic); Self { builder } } @@ -1468,19 +1502,74 @@ impl ArchiveWriter { } /// Appends a directory's whole contents, walking the host filesystem rather - /// than the [`FileSystem`] seam the rest of bundling reads through. + /// than the [`FileSystem`] seam the rest of bundling reads through: the seam + /// reports a link's target as the file it points at and has no way to say + /// otherwise, and a bundle holds only regular files and directories. + /// Bundling from anything but a host filesystem needs the seam to describe + /// links first. /// - /// `tar`'s walk is what keeps a symlink a symlink, per - /// [`new`](ArchiveWriter::new); the seam reports a link's target as the file - /// it points at and has no way to say otherwise, so reading through it would - /// slurp whatever the link reaches instead. Bundling from anything but a - /// host filesystem needs the seam to describe links first. - fn dir(&mut self, src_path: &Path, archive_prefix: &str) -> Result<(), BundleError> { + /// Every entry met is resolved through its symlinks and must land under + /// `root`, the canonical project directory. One that does is appended as + /// what it points at, so a project may link to its own files freely; one + /// that does not is refused, naming the link and where it leads, as is a + /// link that dangles or leads back to a directory already being copied. + /// Entries go in sorted order, so the same tree gives the same archive. + fn dir( + &mut self, + src_path: &Path, + archive_prefix: &str, + root: &Path, + ) -> Result<(), BundleError> { + self.walk(src_path, archive_prefix, root, &mut Vec::new()) + } + + /// `open` holds the canonical directories on the way down from `dir`'s + /// start, which is how a link back to one of them is told from a second + /// link to a directory already copied and finished with. + fn walk( + &mut self, + src_path: &Path, + archive_path: &str, + root: &Path, + open: &mut Vec, + ) -> Result<(), BundleError> { + let real = resolve_within_root(src_path, root)?; + if open.contains(&real) { + return LinkCycleSnafu { + path: src_path.to_path_buf(), + target: real, + } + .fail(); + } self.builder - .append_dir_all(archive_prefix, src_path.as_std_path()) + .append_dir(archive_path, real.as_std_path()) .context(WriteArchiveEntrySnafu { - path: PathBuf::from(archive_prefix), - }) + path: PathBuf::from(archive_path), + })?; + open.push(real.clone()); + for entry in crate::fs::read_dir(&real)? { + let Some(name) = entry.file_name() else { + continue; + }; + let entry_archive_path = format!("{archive_path}/{name}"); + let target = resolve_within_root(&entry, root)?; + let meta = std::fs::metadata(&target).context(ResolveArchiveEntrySnafu { + path: entry.clone(), + })?; + if meta.is_dir() { + self.walk(&entry, &entry_archive_path, root, open)?; + } else if meta.is_file() { + self.builder + .append_path_with_name(target.as_std_path(), &entry_archive_path) + .context(WriteArchiveEntrySnafu { + path: PathBuf::from(entry_archive_path), + })?; + } else { + return UnsupportedEntrySnafu { path: entry }.fail(); + } + } + open.pop(); + Ok(()) } fn into_inner(self) -> Result { @@ -1491,6 +1580,7 @@ impl ArchiveWriter { async fn write_archive( files: &dyn FileSystem, output: &Path, + canonical_project_dir: &Path, manifests: &[InstanceManifest], artifacts: &BundleArtifacts, args_files: &[ArgsFile], @@ -1564,17 +1654,24 @@ async fn write_archive( } for d in &artifacts.plugin_dirs { - archive.dir(&d.src_path, &d.archive_prefix)?; + archive.dir(&d.src_path, &d.archive_prefix, canonical_project_dir)?; } for pf in &artifacts.plugin_files { - let data = files - .read(&pf.src_path) - .await - .context(ReadPluginFileSnafu { - canister: pf.canister_name.clone(), - file: pf.orig_file.clone(), - })?; + // Validated lexically before the build, when it need not have existed; + // now it does, so a symlink in it is followed and held to the project + // like every other entry. + let src = canonicalize_within_project( + files, + &pf.src_path, + canonical_project_dir, + &pf.canister_name, + ) + .await?; + let data = files.read(&src).await.context(ReadPluginFileSnafu { + canister: pf.canister_name.clone(), + file: pf.orig_file.clone(), + })?; archive.bytes(&pf.archive_path, &data)?; } @@ -1819,6 +1916,41 @@ fn is_absolute_bind_mount_host(mount: &str) -> bool { !h.is_empty() && (h[0] == b'/' || h[0] == b'\\') } +/// Where `path` comes to rest with every symlink followed, which must be under +/// `root`. Walks the host directly, as [`ArchiveWriter::dir`] does: it is the +/// one place bundling has to know whether a path is a link, and the +/// [`FileSystem`] seam cannot say. +fn resolve_within_root(path: &Path, root: &Path) -> Result { + let real = match dunce::canonicalize(path) { + Ok(real) => real, + Err(source) => { + if let Ok(target) = std::fs::read_link(path) { + return DanglingLinkSnafu { + path: path.to_path_buf(), + target: target.display().to_string(), + } + .fail(); + } + return Err(BundleError::ResolveArchiveEntry { + path: path.to_path_buf(), + source, + }); + } + }; + let real = PathBuf::from_path_buf(real).map_err(|_| BundleError::CanonicalizePath { + path: path.to_path_buf(), + })?; + if !real.starts_with(root) { + return LinkEscapesProjectSnafu { + path: path.to_path_buf(), + target: real, + root: root.to_path_buf(), + } + .fail(); + } + Ok(real) +} + async fn canonicalize(files: &dyn FileSystem, path: &Path) -> Result { files .canonicalize(path) From 0ec22ec8572ac93a1105461a0d37b0d87f797b31 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Wed, 4 Nov 2026 21:13:13 +0000 Subject: [PATCH 08/24] fix(bundle): refuse the output, a non-UTF-8 name and a looping link by name Three holes in the archive walk, from review: - The output path is checked against the copied directories by its spelling, which a symlink inside one of them gets around: with `assets/shared -> ../shared` and `--output shared/bundle.tar.gz`, the walk met the archive it was writing and copied a partial copy of it into itself. The canonical output now travels with the walk, and an entry that resolves to it is refused, named by the path it was reached by. - `crate::fs::read_dir` skips a name that is not UTF-8, which for a bundle meant a file silently left out where tar's walk had carried it. The walk lists directories itself and refuses such a name, since the archive cannot carry it either way. - A link was called dangling whenever canonicalizing it failed and `read_link` succeeded, which also covers a link that loops or leads somewhere unreadable. Only a `NotFound` is dangling now; the rest keep their own cause. Entries met under a link are listed where the directory really is but named by the path they were reached by, so every message says what the user wrote rather than where it led. Co-Authored-By: Claude Fable 5.1 --- crates/icp-cli/tests/bundle_tests.rs | 79 ++++++++++++++++++++ crates/icp-project/src/operations/bundle.rs | 81 ++++++++++++++++++--- 2 files changed, 148 insertions(+), 12 deletions(-) diff --git a/crates/icp-cli/tests/bundle_tests.rs b/crates/icp-cli/tests/bundle_tests.rs index e9d153cd9..e9f5ae874 100644 --- a/crates/icp-cli/tests/bundle_tests.rs +++ b/crates/icp-cli/tests/bundle_tests.rs @@ -1132,6 +1132,85 @@ fn bundle_rejects_symlink_cycle() { .stderr(contains("back").and(contains("cycle"))); } +/// A symlink that loops cannot be resolved at all, and that is the reason +/// given, rather than the link being called dangling. +#[cfg(unix)] +#[test] +fn bundle_rejects_symlink_loop() { + let ctx = TestContext::new(); + let project_dir = plugin_project_with_files_entry(&ctx, "data"); + + create_dir_all(&project_dir.join("data")).expect("failed to create dir"); + std::os::unix::fs::symlink("self", project_dir.join("data/self")) + .expect("failed to create symlink"); + + ctx.icp() + .current_dir(&project_dir) + .args(["project", "bundle", "--output", "bundle.tar.gz"]) + .assert() + .failure() + .stderr( + contains("cannot resolve") + .and(contains("data/self")) + .and(contains("does not exist").not()), + ); +} + +/// The output path is checked against the copied directories by its spelling, +/// which a symlink inside one of them can get around: the walk then meets the +/// bundle it is writing, and refuses it rather than copying a partial archive +/// into itself. +#[cfg(unix)] +#[test] +fn bundle_rejects_output_reached_through_symlink() { + let ctx = TestContext::new(); + let project_dir = plugin_project_with_files_entry(&ctx, "assets"); + + create_dir_all(&project_dir.join("assets")).expect("failed to create dir"); + create_dir_all(&project_dir.join("shared")).expect("failed to create dir"); + write_string(&project_dir.join("shared/s.txt"), "s").expect("failed to write file"); + std::os::unix::fs::symlink("../shared", project_dir.join("assets/shared")) + .expect("failed to create symlink"); + + ctx.icp() + .current_dir(&project_dir) + .args(["project", "bundle", "--output", "shared/bundle.tar.gz"]) + .assert() + .failure() + .stderr( + contains("assets/shared/bundle.tar.gz") + .and(contains("own output file")) + .and(contains("still being written")), + ); +} + +/// A file whose name is not UTF-8 cannot be named in the archive. The bundle +/// says so rather than leaving the file out, as the glob machinery elsewhere +/// does when it lists a directory. +#[cfg(target_os = "linux")] +#[test] +fn bundle_rejects_a_name_that_is_not_utf8() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + let ctx = TestContext::new(); + let project_dir = plugin_project_with_files_entry(&ctx, "data"); + + create_dir_all(&project_dir.join("data")).expect("failed to create dir"); + let stray = project_dir + .join("data") + .into_std_path_buf() + .join(OsStr::from_bytes(b"caf\xe9.txt")); + fs::write(&stray, b"x").expect("failed to write file"); + + ctx.icp() + .current_dir(&project_dir) + .args(["project", "bundle", "--output", "bundle.tar.gz"]) + .assert() + .failure() + .stderr(contains("not UTF-8").and(contains("data"))); +} + /// A `files:` entry that is itself a symlink to a file outside the project is /// refused like any other source outside the project, where the lexical check /// before the build would have let it through and the read followed it. diff --git a/crates/icp-project/src/operations/bundle.rs b/crates/icp-project/src/operations/bundle.rs index 9309bfb97..e7cec57f7 100644 --- a/crates/icp-project/src/operations/bundle.rs +++ b/crates/icp-project/src/operations/bundle.rs @@ -279,8 +279,22 @@ pub enum BundleError { ))] UnsupportedEntry { path: PathBuf }, - #[snafu(transparent)] - ListDir { source: crate::fs::IoError }, + #[snafu(display("failed to list '{path}' to add it to the bundle"))] + ListDir { + path: PathBuf, + source: std::io::Error, + }, + + #[snafu(display( + "'{dir}' holds an entry named '{name}' that is not UTF-8, which a bundle cannot carry" + ))] + NameNotUtf8 { dir: PathBuf, name: String }, + + #[snafu(display( + "'{path}' is the bundle's own output file '{output}', reached through a symlink, and \ + it is still being written; choose an output path outside what the bundle copies" + ))] + OutputReachedThroughLink { path: PathBuf, output: PathBuf }, } /// In-memory bytes destined for a single tar entry. @@ -464,7 +478,7 @@ pub async fn create_bundle( let canonical_sync_dirs = validate_source_paths(project_dir, &canisters, &canonical_project_dir)?; validate_env_var_files(files, &canisters, &canonical_project_dir).await?; - validate_output_path(files, output, &canonical_sync_dirs).await?; + let canonical_output = validate_output_path(files, output, &canonical_sync_dirs).await?; build_many( canisters.clone(), @@ -544,6 +558,7 @@ pub async fn create_bundle( files, output, &canonical_project_dir, + &canonical_output, &manifests, &bundle_artifacts, &args_files, @@ -1512,15 +1527,17 @@ impl ArchiveWriter { /// `root`, the canonical project directory. One that does is appended as /// what it points at, so a project may link to its own files freely; one /// that does not is refused, naming the link and where it leads, as is a - /// link that dangles or leads back to a directory already being copied. + /// link that dangles, leads back to a directory already being copied, or + /// reaches `output`, the bundle itself, which is being written meanwhile. /// Entries go in sorted order, so the same tree gives the same archive. fn dir( &mut self, src_path: &Path, archive_prefix: &str, root: &Path, + output: &Path, ) -> Result<(), BundleError> { - self.walk(src_path, archive_prefix, root, &mut Vec::new()) + self.walk(src_path, archive_prefix, root, output, &mut Vec::new()) } /// `open` holds the canonical directories on the way down from `dir`'s @@ -1531,6 +1548,7 @@ impl ArchiveWriter { src_path: &Path, archive_path: &str, root: &Path, + output: &Path, open: &mut Vec, ) -> Result<(), BundleError> { let real = resolve_within_root(src_path, root)?; @@ -1547,17 +1565,26 @@ impl ArchiveWriter { path: PathBuf::from(archive_path), })?; open.push(real.clone()); - for entry in crate::fs::read_dir(&real)? { - let Some(name) = entry.file_name() else { + // Listed where the directory really is, but each entry is named by the + // path it was reached by, which is what a message about it should say. + for listed in list_dir(&real)? { + let Some(name) = listed.file_name() else { continue; }; + let entry = src_path.join(name); let entry_archive_path = format!("{archive_path}/{name}"); let target = resolve_within_root(&entry, root)?; let meta = std::fs::metadata(&target).context(ResolveArchiveEntrySnafu { path: entry.clone(), })?; if meta.is_dir() { - self.walk(&entry, &entry_archive_path, root, open)?; + self.walk(&entry, &entry_archive_path, root, output, open)?; + } else if target == output { + return OutputReachedThroughLinkSnafu { + path: entry, + output: output.to_path_buf(), + } + .fail(); } else if meta.is_file() { self.builder .append_path_with_name(target.as_std_path(), &entry_archive_path) @@ -1581,6 +1608,7 @@ async fn write_archive( files: &dyn FileSystem, output: &Path, canonical_project_dir: &Path, + canonical_output: &Path, manifests: &[InstanceManifest], artifacts: &BundleArtifacts, args_files: &[ArgsFile], @@ -1654,7 +1682,12 @@ async fn write_archive( } for d in &artifacts.plugin_dirs { - archive.dir(&d.src_path, &d.archive_prefix, canonical_project_dir)?; + archive.dir( + &d.src_path, + &d.archive_prefix, + canonical_project_dir, + canonical_output, + )?; } for pf in &artifacts.plugin_files { @@ -1857,11 +1890,14 @@ fn resolve_within_project( /// Refuse to write the bundle output into a directory we are about to recursively archive — /// otherwise the partial bundle file would be included in itself. +/// Refuse an output path inside a directory the bundle copies, and hand back +/// its canonical form for the walk, which can reach the output through a +/// symlink that this lexical check cannot see. async fn validate_output_path( files: &dyn FileSystem, output: &Path, canonical_sync_dirs: &[PathBuf], -) -> Result<(), BundleError> { +) -> Result { let canonical_output = canonicalize_output(files, output).await?; for sync_dir in canonical_sync_dirs { if canonical_output.starts_with(sync_dir) { @@ -1872,7 +1908,7 @@ async fn validate_output_path( .fail(); } } - Ok(()) + Ok(canonical_output) } fn validate_network_for_bundle(net: &NetworkManifest) -> Result<(), BundleError> { @@ -1916,6 +1952,23 @@ fn is_absolute_bind_mount_host(mount: &str) -> bool { !h.is_empty() && (h[0] == b'/' || h[0] == b'\\') } +/// A directory's entries as full paths, sorted. [`crate::fs::read_dir`] skips a +/// name that is not UTF-8, which for a bundle would mean a file silently left +/// out; here it is refused instead, since the archive cannot name it. +fn list_dir(dir: &Path) -> Result, BundleError> { + let mut out = Vec::new(); + for entry in std::fs::read_dir(dir).context(ListDirSnafu { path: dir })? { + let entry = entry.context(ListDirSnafu { path: dir })?; + let path = PathBuf::from_path_buf(entry.path()).map_err(|_| BundleError::NameNotUtf8 { + dir: dir.to_path_buf(), + name: entry.file_name().to_string_lossy().into_owned(), + })?; + out.push(path); + } + out.sort(); + Ok(out) +} + /// Where `path` comes to rest with every symlink followed, which must be under /// `root`. Walks the host directly, as [`ArchiveWriter::dir`] does: it is the /// one place bundling has to know whether a path is a link, and the @@ -1924,7 +1977,11 @@ fn resolve_within_root(path: &Path, root: &Path) -> Result let real = match dunce::canonicalize(path) { Ok(real) => real, Err(source) => { - if let Ok(target) = std::fs::read_link(path) { + // A link whose target is missing is dangling; a link that loops, + // or leads somewhere unreadable, fails for its own reason, kept. + if source.kind() == std::io::ErrorKind::NotFound + && let Ok(target) = std::fs::read_link(path) + { return DanglingLinkSnafu { path: path.to_path_buf(), target: target.display().to_string(), From 777ddb33ce105aece45ea756df1116ce83aad444 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Thu, 5 Nov 2026 19:57:39 +0000 Subject: [PATCH 09/24] docs(bundle): keep the Intel macOS chore out of Experimental, drop a stale doc comment The new `## Experimental` heading sat above the existing Intel macOS chore entry, which made that general change read as experimental. The heading and the bundle entry now follow it, as in v1.8.0. `validate_output_path` kept its old doc comment above the new one. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 4 ++-- crates/icp-project/src/operations/bundle.rs | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63fd255bf..b8a91fdd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,12 @@ air-gapped signing, extensions # Unreleased +* 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 * fix(bundle): `icp project bundle` holds only regular files and directories. A symlink inside a plugin's `files:` or `dirs:` directory used to be recorded as a link with its target unchecked, so a link to a file outside the project reached that file on whatever machine unpacked the bundle. A link to somewhere inside the project is now copied as what it points at, so a project may link to its own files freely; one that leads outside the project, dangles, or leads back to a directory being copied fails the bundle, naming the link and where it leads. A `files:` entry that is itself a symlink to a file outside the project is refused the same way, where it used to be read into the bundle. -* 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. - # v1.8.0 * fix: `icp sync` no longer needs controller rights. Its check that each canister is running used `canister_status`, which only a controller or a listed status viewer may read, so an identity the canister merely authorizes (a developer preparing an SNS-owned frontend for a commit proposal, say) was refused before anything was uploaded. It now probes with a query, as `icp deploy` already does after starting a canister. A stopped canister is still refused with the same advice, after the probe's 30-second wait rather than at once. diff --git a/crates/icp-project/src/operations/bundle.rs b/crates/icp-project/src/operations/bundle.rs index e7cec57f7..24b7827f4 100644 --- a/crates/icp-project/src/operations/bundle.rs +++ b/crates/icp-project/src/operations/bundle.rs @@ -1888,8 +1888,6 @@ fn resolve_within_project( Ok(resolved) } -/// Refuse to write the bundle output into a directory we are about to recursively archive — -/// otherwise the partial bundle file would be included in itself. /// Refuse an output path inside a directory the bundle copies, and hand back /// its canonical form for the walk, which can reach the output through a /// symlink that this lexical check cannot see. From 624dbca0e8b4af3806bf2a246c901f621759d548 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Tue, 10 Nov 2026 01:03:56 +0000 Subject: [PATCH 10/24] refactor(bundle): resolve each directory once on the walk, and list names The walk resolved a directory as an entry and again on entering it; the resolved path is now handed down. list_dir returns names rather than paths the caller took the names back out of. Doc comments and the changelog entry say the same in fewer words. Co-Authored-By: Claude --- CHANGELOG.md | 2 +- crates/icp-project/src/operations/bundle.rs | 74 +++++++++++---------- 2 files changed, 39 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8a91fdd2..bd34d7523 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ air-gapped signing, extensions ## Experimental -* fix(bundle): `icp project bundle` holds only regular files and directories. A symlink inside a plugin's `files:` or `dirs:` directory used to be recorded as a link with its target unchecked, so a link to a file outside the project reached that file on whatever machine unpacked the bundle. A link to somewhere inside the project is now copied as what it points at, so a project may link to its own files freely; one that leads outside the project, dangles, or leads back to a directory being copied fails the bundle, naming the link and where it leads. A `files:` entry that is itself a symlink to a file outside the project is refused the same way, where it used to be read into the bundle. +* fix(bundle): `icp project bundle` holds only regular files and directories. A symlink inside a plugin's `files:` or `dirs:` directory used to be recorded as a link with its target unchecked, so one pointing outside the project reached that file on whatever machine unpacked the bundle. A link to somewhere inside the project is now copied as what it points at; one that leads outside the project, dangles, or leads back to a directory being copied fails the bundle, naming the link and where it leads. The same holds for a `files:` entry that is itself a symlink. # v1.8.0 diff --git a/crates/icp-project/src/operations/bundle.rs b/crates/icp-project/src/operations/bundle.rs index 24b7827f4..f8bfed73d 100644 --- a/crates/icp-project/src/operations/bundle.rs +++ b/crates/icp-project/src/operations/bundle.rs @@ -1517,19 +1517,13 @@ impl ArchiveWriter { } /// Appends a directory's whole contents, walking the host filesystem rather - /// than the [`FileSystem`] seam the rest of bundling reads through: the seam - /// reports a link's target as the file it points at and has no way to say - /// otherwise, and a bundle holds only regular files and directories. - /// Bundling from anything but a host filesystem needs the seam to describe - /// links first. + /// than the [`FileSystem`] seam the rest of bundling reads through, which + /// cannot say whether a path is a link. /// - /// Every entry met is resolved through its symlinks and must land under - /// `root`, the canonical project directory. One that does is appended as - /// what it points at, so a project may link to its own files freely; one - /// that does not is refused, naming the link and where it leads, as is a - /// link that dangles, leads back to a directory already being copied, or - /// reaches `output`, the bundle itself, which is being written meanwhile. - /// Entries go in sorted order, so the same tree gives the same archive. + /// Every entry is resolved through its symlinks and must land under `root`, + /// the canonical project directory: it is then appended as what it points + /// at, in sorted order. A link that leads outside, dangles, leads back to + /// a directory being copied, or reaches `output` is refused by name. fn dir( &mut self, src_path: &Path, @@ -1537,21 +1531,29 @@ impl ArchiveWriter { root: &Path, output: &Path, ) -> Result<(), BundleError> { - self.walk(src_path, archive_prefix, root, output, &mut Vec::new()) + let real = resolve_within_root(src_path, root)?; + self.walk( + src_path, + real, + archive_prefix, + root, + output, + &mut Vec::new(), + ) } - /// `open` holds the canonical directories on the way down from `dir`'s - /// start, which is how a link back to one of them is told from a second - /// link to a directory already copied and finished with. + /// `real` is where `src_path` resolves, and `open` the canonical + /// directories on the way down to it, which tells a link back to one of + /// them from a second link to a directory already copied. fn walk( &mut self, src_path: &Path, + real: PathBuf, archive_path: &str, root: &Path, output: &Path, open: &mut Vec, ) -> Result<(), BundleError> { - let real = resolve_within_root(src_path, root)?; if open.contains(&real) { return LinkCycleSnafu { path: src_path.to_path_buf(), @@ -1564,21 +1566,19 @@ impl ArchiveWriter { .context(WriteArchiveEntrySnafu { path: PathBuf::from(archive_path), })?; - open.push(real.clone()); // Listed where the directory really is, but each entry is named by the // path it was reached by, which is what a message about it should say. - for listed in list_dir(&real)? { - let Some(name) = listed.file_name() else { - continue; - }; - let entry = src_path.join(name); + let names = list_dir(&real)?; + open.push(real); + for name in names { + let entry = src_path.join(&name); let entry_archive_path = format!("{archive_path}/{name}"); let target = resolve_within_root(&entry, root)?; let meta = std::fs::metadata(&target).context(ResolveArchiveEntrySnafu { path: entry.clone(), })?; if meta.is_dir() { - self.walk(&entry, &entry_archive_path, root, output, open)?; + self.walk(&entry, target, &entry_archive_path, root, output, open)?; } else if target == output { return OutputReachedThroughLinkSnafu { path: entry, @@ -1950,21 +1950,23 @@ fn is_absolute_bind_mount_host(mount: &str) -> bool { !h.is_empty() && (h[0] == b'/' || h[0] == b'\\') } -/// A directory's entries as full paths, sorted. [`crate::fs::read_dir`] skips a -/// name that is not UTF-8, which for a bundle would mean a file silently left -/// out; here it is refused instead, since the archive cannot name it. -fn list_dir(dir: &Path) -> Result, BundleError> { - let mut out = Vec::new(); +/// The names in a directory, sorted. [`crate::fs::read_dir`] skips a name that +/// is not UTF-8, which would leave a file out of the bundle; here it is refused. +fn list_dir(dir: &Path) -> Result, BundleError> { + let mut names = Vec::new(); for entry in std::fs::read_dir(dir).context(ListDirSnafu { path: dir })? { let entry = entry.context(ListDirSnafu { path: dir })?; - let path = PathBuf::from_path_buf(entry.path()).map_err(|_| BundleError::NameNotUtf8 { - dir: dir.to_path_buf(), - name: entry.file_name().to_string_lossy().into_owned(), - })?; - out.push(path); + let name = entry + .file_name() + .into_string() + .map_err(|name| BundleError::NameNotUtf8 { + dir: dir.to_path_buf(), + name: name.to_string_lossy().into_owned(), + })?; + names.push(name); } - out.sort(); - Ok(out) + names.sort(); + Ok(names) } /// Where `path` comes to rest with every symlink followed, which must be under From 0e1dc02bafc972de776623c80c35541221679917 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Tue, 10 Nov 2026 02:33:19 +0000 Subject: [PATCH 11/24] fix(bundle): resolve the output once it exists, for the walk to refuse The canonical output was worked out before the build, when an output path that is a dangling symlink resolves to its own spelling. Creating the file follows the link, so the walk was told to refuse a path the bundle was not written to, and one linked into a copied directory was read into itself without end. The output is now resolved after it is created. Co-Authored-By: Claude --- crates/icp-cli/tests/bundle_tests.rs | 24 +++++++++++++++++++++ crates/icp-project/src/operations/bundle.rs | 19 ++++++++-------- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/crates/icp-cli/tests/bundle_tests.rs b/crates/icp-cli/tests/bundle_tests.rs index e9f5ae874..3b2bb89c6 100644 --- a/crates/icp-cli/tests/bundle_tests.rs +++ b/crates/icp-cli/tests/bundle_tests.rs @@ -1184,6 +1184,30 @@ fn bundle_rejects_output_reached_through_symlink() { ); } +/// The same when the output path is itself a dangling symlink into such a +/// directory: where the bundle is really written is known only once it is +/// created, and that is what the walk is told to refuse. +#[cfg(unix)] +#[test] +fn bundle_rejects_output_that_is_a_link_into_a_copied_directory() { + let ctx = TestContext::new(); + let project_dir = plugin_project_with_files_entry(&ctx, "assets"); + + create_dir_all(&project_dir.join("assets")).expect("failed to create dir"); + create_dir_all(&project_dir.join("shared")).expect("failed to create dir"); + std::os::unix::fs::symlink("../shared", project_dir.join("assets/shared")) + .expect("failed to create symlink"); + std::os::unix::fs::symlink("shared/actual.tar.gz", project_dir.join("bundle.tar.gz")) + .expect("failed to create symlink"); + + ctx.icp() + .current_dir(&project_dir) + .args(["project", "bundle", "--output", "bundle.tar.gz"]) + .assert() + .failure() + .stderr(contains("assets/shared/actual.tar.gz").and(contains("own output file"))); +} + /// A file whose name is not UTF-8 cannot be named in the archive. The bundle /// says so rather than leaving the file out, as the glob machinery elsewhere /// does when it lists a directory. diff --git a/crates/icp-project/src/operations/bundle.rs b/crates/icp-project/src/operations/bundle.rs index f8bfed73d..df459c365 100644 --- a/crates/icp-project/src/operations/bundle.rs +++ b/crates/icp-project/src/operations/bundle.rs @@ -478,7 +478,7 @@ pub async fn create_bundle( let canonical_sync_dirs = validate_source_paths(project_dir, &canisters, &canonical_project_dir)?; validate_env_var_files(files, &canisters, &canonical_project_dir).await?; - let canonical_output = validate_output_path(files, output, &canonical_sync_dirs).await?; + validate_output_path(files, output, &canonical_sync_dirs).await?; build_many( canisters.clone(), @@ -558,7 +558,6 @@ pub async fn create_bundle( files, output, &canonical_project_dir, - &canonical_output, &manifests, &bundle_artifacts, &args_files, @@ -1608,7 +1607,6 @@ async fn write_archive( files: &dyn FileSystem, output: &Path, canonical_project_dir: &Path, - canonical_output: &Path, manifests: &[InstanceManifest], artifacts: &BundleArtifacts, args_files: &[ArgsFile], @@ -1646,6 +1644,10 @@ async fn write_archive( let file = File::create(output.as_std_path()).context(CreateOutputSnafu { path: output.to_path_buf(), })?; + // Resolved now that it exists, for the walk, which can reach the output + // through a symlink: before this it may itself have been a dangling link, + // whose target only creating it brought about. + let canonical_output = canonicalize(files, output).await?; let gz = GzEncoder::new(BufWriter::new(file), Compression::default()); let mut archive = ArchiveWriter::new(gz); @@ -1686,7 +1688,7 @@ async fn write_archive( &d.src_path, &d.archive_prefix, canonical_project_dir, - canonical_output, + &canonical_output, )?; } @@ -1888,14 +1890,13 @@ fn resolve_within_project( Ok(resolved) } -/// Refuse an output path inside a directory the bundle copies, and hand back -/// its canonical form for the walk, which can reach the output through a -/// symlink that this lexical check cannot see. +/// Refuse to write the bundle output into a directory we are about to recursively archive — +/// otherwise the partial bundle file would be included in itself. async fn validate_output_path( files: &dyn FileSystem, output: &Path, canonical_sync_dirs: &[PathBuf], -) -> Result { +) -> Result<(), BundleError> { let canonical_output = canonicalize_output(files, output).await?; for sync_dir in canonical_sync_dirs { if canonical_output.starts_with(sync_dir) { @@ -1906,7 +1907,7 @@ async fn validate_output_path( .fail(); } } - Ok(canonical_output) + Ok(()) } fn validate_network_for_bundle(net: &NetworkManifest) -> Result<(), BundleError> { From 5c7c8a4e822309d58c13c84e32b88ed3f9c78bc5 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Tue, 10 Nov 2026 14:43:48 +0000 Subject: [PATCH 12/24] fix(bundle): hold a files: entry to what the walk holds, and resolve from where a link led A files: entry that is itself a link to the output was read into the bundle half-written, and one that is a named pipe was read forever; both are now refused as they are on the walk. The walk resolved each entry by the path it was reached by, following every link crossed so far again, so a chain of directory links longer than the host follows in one path failed; each entry is now resolved from the directory's real path. Co-Authored-By: Claude --- crates/icp-cli/tests/bundle_tests.rs | 91 +++++++++++++++++++++ crates/icp-project/src/operations/bundle.rs | 41 +++++++--- 2 files changed, 121 insertions(+), 11 deletions(-) diff --git a/crates/icp-cli/tests/bundle_tests.rs b/crates/icp-cli/tests/bundle_tests.rs index 3b2bb89c6..31d71b4c5 100644 --- a/crates/icp-cli/tests/bundle_tests.rs +++ b/crates/icp-cli/tests/bundle_tests.rs @@ -1208,6 +1208,97 @@ fn bundle_rejects_output_that_is_a_link_into_a_copied_directory() { .stderr(contains("assets/shared/actual.tar.gz").and(contains("own output file"))); } +/// A `files:` entry that is itself a link to the output is refused like one +/// met on the walk, rather than read into the bundle half-written. +#[cfg(unix)] +#[test] +fn bundle_rejects_a_files_entry_that_links_to_the_output() { + let ctx = TestContext::new(); + let project_dir = plugin_project_with_files_entry(&ctx, "config.txt"); + + std::os::unix::fs::symlink("bundle.tar.gz", project_dir.join("config.txt")) + .expect("failed to create symlink"); + + ctx.icp() + .current_dir(&project_dir) + .args(["project", "bundle", "--output", "bundle.tar.gz"]) + .assert() + .failure() + .stderr(contains("config.txt").and(contains("own output file"))); +} + +/// Nor is a `files:` entry that is not a regular file read: a named pipe +/// nobody writes to would hold the bundle up forever. +#[cfg(unix)] +#[test] +fn bundle_rejects_a_files_entry_that_is_not_a_regular_file() { + let ctx = TestContext::new(); + let project_dir = plugin_project_with_files_entry(&ctx, "config.txt"); + + let made = std::process::Command::new("mkfifo") + .arg(project_dir.join("config.txt")) + .status() + .expect("mkfifo"); + assert!(made.success(), "mkfifo"); + + ctx.icp() + .current_dir(&project_dir) + .args(["project", "bundle", "--output", "bundle.tar.gz"]) + .timeout(std::time::Duration::from_secs(60)) + .assert() + .failure() + .stderr(contains("config.txt").and(contains("neither a file nor a directory"))); +} + +/// Each directory link is resolved from where the last one led, not from the +/// start again, so a chain of them longer than the host follows in one path +/// (40 on Linux) is still copied. +#[cfg(unix)] +#[test] +fn bundle_follows_a_long_chain_of_directory_links() { + let ctx = TestContext::new(); + let project_dir = plugin_project_with_files_entry(&ctx, "data"); + + const LINKS: usize = 45; + create_dir_all(&project_dir.join("data")).expect("failed to create dir"); + for i in 0..=LINKS { + create_dir_all(&project_dir.join(format!("chain/d{i}"))).expect("failed to create dir"); + } + std::os::unix::fs::symlink("../chain/d0", project_dir.join("data/next")) + .expect("failed to create symlink"); + for i in 0..LINKS { + std::os::unix::fs::symlink( + format!("../d{}", i + 1), + project_dir.join(format!("chain/d{i}/next")), + ) + .expect("failed to create symlink"); + } + write_string( + &project_dir.join(format!("chain/d{LINKS}/leaf.txt")), + "leaf", + ) + .expect("failed to write file"); + + let bundle_path = project_dir.join("bundle.tar.gz"); + ctx.icp() + .current_dir(&project_dir) + .args(["project", "bundle", "--output", bundle_path.as_str()]) + .assert() + .success(); + + let leaf = format!( + "plugins/my-canister/0/files/data/{}leaf.txt", + "next/".repeat(LINKS + 1) + ); + let entries = archive_entries(&bundle_path); + assert!( + entries + .iter() + .any(|(path, _, data)| *path == leaf && data == b"leaf"), + "{leaf} missing" + ); +} + /// A file whose name is not UTF-8 cannot be named in the archive. The bundle /// says so rather than leaving the file out, as the glob machinery elsewhere /// does when it lists a directory. diff --git a/crates/icp-project/src/operations/bundle.rs b/crates/icp-project/src/operations/bundle.rs index df459c365..af18a8f5a 100644 --- a/crates/icp-project/src/operations/bundle.rs +++ b/crates/icp-project/src/operations/bundle.rs @@ -1530,7 +1530,7 @@ impl ArchiveWriter { root: &Path, output: &Path, ) -> Result<(), BundleError> { - let real = resolve_within_root(src_path, root)?; + let real = resolve_within_root(src_path, src_path, root)?; self.walk( src_path, real, @@ -1565,14 +1565,17 @@ impl ArchiveWriter { .context(WriteArchiveEntrySnafu { path: PathBuf::from(archive_path), })?; - // Listed where the directory really is, but each entry is named by the - // path it was reached by, which is what a message about it should say. + // Listed and resolved from where the directory really is, so the links + // crossed to get here are not followed again for every entry — a long + // enough chain of them is more than the host follows in one path. Each + // entry is still named by the path it was reached by, which is what a + // message about it should say. let names = list_dir(&real)?; - open.push(real); + open.push(real.clone()); for name in names { let entry = src_path.join(&name); let entry_archive_path = format!("{archive_path}/{name}"); - let target = resolve_within_root(&entry, root)?; + let target = resolve_within_root(&real.join(&name), &entry, root)?; let meta = std::fs::metadata(&target).context(ResolveArchiveEntrySnafu { path: entry.clone(), })?; @@ -1703,6 +1706,21 @@ async fn write_archive( &pf.canister_name, ) .await?; + // Held to what the walk holds an entry to: not the bundle itself, and + // a regular file, since a pipe nobody writes to is read forever. + if src == canonical_output { + return OutputReachedThroughLinkSnafu { + path: pf.src_path.clone(), + output: canonical_output, + } + .fail(); + } + if !files.is_file(&src).await { + return UnsupportedEntrySnafu { + path: pf.src_path.clone(), + } + .fail(); + } let data = files.read(&src).await.context(ReadPluginFileSnafu { canister: pf.canister_name.clone(), file: pf.orig_file.clone(), @@ -1971,10 +1989,11 @@ fn list_dir(dir: &Path) -> Result, BundleError> { } /// Where `path` comes to rest with every symlink followed, which must be under -/// `root`. Walks the host directly, as [`ArchiveWriter::dir`] does: it is the +/// `root`. `named` is the path a message about it gives: the one it was reached +/// by. Walks the host directly, as [`ArchiveWriter::dir`] does: it is the /// one place bundling has to know whether a path is a link, and the /// [`FileSystem`] seam cannot say. -fn resolve_within_root(path: &Path, root: &Path) -> Result { +fn resolve_within_root(path: &Path, named: &Path, root: &Path) -> Result { let real = match dunce::canonicalize(path) { Ok(real) => real, Err(source) => { @@ -1984,23 +2003,23 @@ fn resolve_within_root(path: &Path, root: &Path) -> Result && let Ok(target) = std::fs::read_link(path) { return DanglingLinkSnafu { - path: path.to_path_buf(), + path: named.to_path_buf(), target: target.display().to_string(), } .fail(); } return Err(BundleError::ResolveArchiveEntry { - path: path.to_path_buf(), + path: named.to_path_buf(), source, }); } }; let real = PathBuf::from_path_buf(real).map_err(|_| BundleError::CanonicalizePath { - path: path.to_path_buf(), + path: named.to_path_buf(), })?; if !real.starts_with(root) { return LinkEscapesProjectSnafu { - path: path.to_path_buf(), + path: named.to_path_buf(), target: real, root: root.to_path_buf(), } From 58af0d89c05d4039ee6b882d6fd519bad1af8ad0 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Thu, 5 Nov 2026 21:41:21 +0000 Subject: [PATCH 13/24] feat(project): an untrusted project is held to the shape a bundle has A project the CLI loads is trusted the way the user's own checkout is: a build step may run any command, a recipe may come from any file or URL, a module may be downloaded from anywhere, and a manifest may name any path on the machine, through any symlink. A project from elsewhere (a bundle an agent produced, a checkout nobody has read) should get none of that, and until now nothing in the crate said what it should get instead; a consumer such as the imcp2 deploy server had to assemble the rule itself from the seams. `icp_project::untrusted` now states the rule, which is the shape `icp project bundle` writes, and supplies the pieces that hold a project to it: - `files::Confined::refusing_links` is the confined filesystem of #866 with one more refusal: a path that passes through a symlink anywhere under the root, wherever the link leads. The lexical `Confined::new` stays as it is, for a tree whose links were refused on the way in. Each component below the root is looked at as it is on disk, so no canonicalizing, and the root is still matched as the caller spells it. - `untrusted::NoRecipes` is the `Resolve` that fetches nothing, so a project naming a recipe fails to load rather than loading and then being refused: the recipe is build instructions read from wherever the manifest points, and the loader reads it before anyone can look. - `untrusted::vet` refuses a loaded project with a script build or sync step, or a module or sync plugin fetched from a URL, and `untrusted::Vetted` wraps a `ProjectLoad` so every load is vetted. The CLI gets a global `--untrusted` flag that composes the three: the project root is located up front, the filesystem is confined to it and refuses links, no recipe is fetched, and the loader is vetted. Nothing changes without the flag. A checksum on a module is not asked for: the same untrusted hand wrote the module and the checksum, so it says nothing about what the project can reach. Ref: #863, #865 Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + crates/icp-app/src/context/init.rs | 53 ++++- crates/icp-cli/src/complete.rs | 1 + crates/icp-cli/src/main.rs | 8 + crates/icp-cli/tests/untrusted_tests.rs | 222 ++++++++++++++++++ crates/icp-project/src/lib.rs | 4 + crates/icp-project/src/untrusted.rs | 294 ++++++++++++++++++++++++ docs/reference/cli.md | 3 + 8 files changed, 576 insertions(+), 10 deletions(-) create mode 100644 crates/icp-cli/tests/untrusted_tests.rs create mode 100644 crates/icp-project/src/untrusted.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index bd34d7523..b05eb685f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ air-gapped signing, extensions # Unreleased +* feat: `--untrusted`, a global flag, treats the project as one someone else handed over rather than the user's own: a bundle an agent produced, a checkout nobody has read. The project is loaded through a filesystem held to the project root that refuses a symlink anywhere under it, so a manifest can name no file outside the project and no link to one; no recipe is fetched; and a project with a script build or sync step, or a module or sync plugin fetched from a URL, is refused before any command acts on it. What is left is the shape `icp project bundle` writes. Without the flag nothing changes: the user's own project may still run scripts, use recipes, and link or reach wherever it likes. * 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..ea13813fa 100644 --- a/crates/icp-app/src/context/init.rs +++ b/crates/icp-app/src/context/init.rs @@ -12,7 +12,14 @@ 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, + canister::recipe::Resolve, + files::{Confined, FileSystem, HostFileSystem}, + host::Host, + manifest::{self, ProjectRootLocate as _}, + store_id, untrusted, +}; #[derive(Debug, Snafu)] pub enum ContextInitError { @@ -36,13 +43,25 @@ pub enum ContextInitError { PackageCache { source: icp_project::fs::lock::LockError, }, + + #[snafu(display("failed to locate the project to treat as untrusted"))] + UntrustedRoot { + source: manifest::ProjectRootLocateError, + }, } +/// `untrusted` treats the project as one someone else handed over: it is +/// loaded through a filesystem confined to its root that refuses symlinks, no +/// recipe is fetched, and a project with a script step or a remote module or +/// plugin is refused before any command acts on it. See +/// [`icp_project::untrusted`]. The root is located up front, since the +/// filesystem is held to it from the start, so there has to be a project. 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)?); @@ -100,14 +119,25 @@ pub fn initialize( pkg_cache.clone(), )); - // Recipes - let recipe = Arc::new(RecipeFetcher { - http_client, - pkg_cache, - }); + // Recipes: fetched from wherever the manifest points, unless the project + // is untrusted, in which case none is. + let recipe: Arc = if untrusted { + Arc::new(untrusted::NoRecipes) + } else { + Arc::new(RecipeFetcher { + http_client, + pkg_cache, + }) + }; - // The project's files come from this machine. - let files = Arc::new(icp_project::files::HostFileSystem); + // The project's files come from this machine: the whole of it for the + // user's own project, the project directory alone for an untrusted one. + let files: Arc = if untrusted { + let root = project_root_locate.locate().context(UntrustedRootSnafu)?; + Arc::new(Confined::refusing_links(&root)) + } else { + Arc::new(HostFileSystem) + }; // Canister builder let builder = Arc::new(Builder::new(wasm.clone(), files.clone())); @@ -125,8 +155,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))) + } 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/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..0a0f1342c 100644 --- a/crates/icp-cli/src/main.rs +++ b/crates/icp-cli/src/main.rs @@ -88,6 +88,12 @@ struct Cli { #[arg(long, global = true, value_name = "FILE", help_heading = heading::GLOBAL_PARAMETERS, value_hint = ValueHint::FilePath)] identity_password_file: Option, + /// Treat the project as one someone else handed over: refuse a script + /// build or sync step, a recipe, a module or plugin fetched from a URL, + /// a path outside the project root, and a symlink under it + #[arg(long, default_value = "false", global = true, help_heading = heading::GLOBAL_PARAMETERS)] + untrusted: bool, + /// Generate markdown documentation for all commands and exit #[arg(long, hide = true)] markdown_help: bool, @@ -135,6 +141,7 @@ async fn run() -> Result<(), Error> { identity_password_file: matches .get_one::("identity_password_file") .cloned(), + untrusted: matches.get_flag("untrusted"), markdown_help: false, command: None, }, @@ -214,6 +221,7 @@ async fn run() -> Result<(), Error> { cli.debug, password_func, pem_session_duration, + cli.untrusted, )?; // Extension commands are left out of telemetry: telemetry parses against diff --git a/crates/icp-cli/tests/untrusted_tests.rs b/crates/icp-cli/tests/untrusted_tests.rs new file mode 100644 index 000000000..4064a800f --- /dev/null +++ b/crates/icp-cli/tests/untrusted_tests.rs @@ -0,0 +1,222 @@ +//! `--untrusted` holds a project to what one from elsewhere may do: 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. The one tree the flag accepts is the shape a bundle has. + +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 the +/// project must not reach. +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"); + std::fs::copy(&wasm, project_dir.join("backend.wasm")).expect("copy wasm"); + std::fs::write(project_dir.parent().unwrap().join("outside.bin"), b"secret") + .expect("write outside"); + write_string(&project_dir.join("icp.yaml"), manifest).expect("write manifest"); + project_dir +} + +#[test] +fn a_bundle_shaped_project_builds_as_untrusted() { + let ctx = TestContext::new(); + let project_dir = project( + &ctx, + indoc! {r#" + canisters: + - name: backend + build: + steps: + - type: pre-built + path: backend.wasm + "#}, + ); + + 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, + indoc! {r#" + canisters: + - name: backend + build: + steps: + - type: script + command: cp backend.wasm "$ICP_WASM_OUTPUT_PATH" + "#}, + ); + + 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 script build step")), + ); +} + +#[test] +fn a_recipe_is_not_fetched_as_untrusted() { + let ctx = TestContext::new(); + let project_dir = project( + &ctx, + indoc! {r#" + canisters: + - name: backend + recipe: + type: file://recipe.hbs + "#}, + ); + std::fs::write( + project_dir.join("recipe.hbs"), + "build:\n steps:\n - type: pre-built\n path: backend.wasm\n", + ) + .expect("write recipe"); + + ctx.icp() + .current_dir(&project_dir) + .args(["build"]) + .assert() + .success(); + + ctx.icp() + .current_dir(&project_dir) + .args(["build", "--untrusted"]) + .assert() + .failure() + .stderr(contains("recipe 'file://recipe.hbs' is not fetched")); +} + +#[test] +fn a_path_outside_the_project_is_refused_as_untrusted() { + 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"]) + .assert() + .success(); + + ctx.icp() + .current_dir(&project_dir) + .args(["build", "--untrusted"]) + .assert() + .failure() + .stderr(contains("outside.bin").and(contains("is outside"))); +} + +#[cfg(unix)] +#[test] +fn a_symlink_under_the_project_is_refused_as_untrusted() { + let ctx = TestContext::new(); + let project_dir = project( + &ctx, + indoc! {r#" + canisters: + - name: backend + init_args: + path: args.bin + format: bin + build: + steps: + - type: pre-built + path: backend.wasm + "#}, + ); + std::os::unix::fs::symlink( + project_dir.parent().unwrap().join("outside.bin"), + project_dir.join("args.bin"), + ) + .expect("symlink"); + + ctx.icp() + .current_dir(&project_dir) + .args(["build"]) + .assert() + .success(); + + ctx.icp() + .current_dir(&project_dir) + .args(["build", "--untrusted"]) + .assert() + .failure() + .stderr( + contains("args.bin") + .and(contains("passes through")) + .and(contains("symlink")), + ); +} + +#[test] +fn untrusted_needs_a_project_to_hold_to() { + let ctx = TestContext::new(); + let empty = ctx.home_path().join("nowhere"); + std::fs::create_dir_all(&empty).expect("mkdir"); + + ctx.icp() + .current_dir(&empty) + .args(["identity", "list", "--untrusted"]) + .assert() + .failure() + .stderr(contains( + "failed to locate the project to treat as untrusted", + )); +} + +#[test] +fn the_flag_is_global() { + let ctx = TestContext::new(); + let project_dir = project( + &ctx, + indoc! {r#" + canisters: + - name: backend + build: + steps: + - type: pre-built + path: backend.wasm + "#}, + ); + + ctx.icp() + .current_dir(&project_dir) + .args(["--untrusted", "build", "backend"]) + .assert() + .success(); +} diff --git a/crates/icp-project/src/lib.rs b/crates/icp-project/src/lib.rs index ec17458fc..5c2517d7d 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,9 @@ pub enum ProjectLoadError { Project { source: project::ConsolidateManifestError, }, + + #[snafu(display("the project asks for more than an untrusted project may"))] + Untrusted { source: untrusted::Refused }, } #[async_trait] diff --git a/crates/icp-project/src/untrusted.rs b/crates/icp-project/src/untrusted.rs new file mode 100644 index 000000000..17f9ec4c3 --- /dev/null +++ b/crates/icp-project/src/untrusted.rs @@ -0,0 +1,294 @@ +//! Holding a project someone else handed over to what it may do. +//! +//! The CLI trusts a project the way it trusts the user's own checkout: a +//! build step may run any command, a recipe may come from any file or URL, a +//! module may be downloaded from anywhere, and a manifest may name any path on +//! the machine. A project from elsewhere — an archive an agent passed to a +//! deploy service, a checkout the user has not read — gets none of that. What +//! it may do is the shape `icp project bundle` writes, since a bundle is what +//! such a project usually is: +//! +//! - no script step, for building or syncing: those are host subprocesses; +//! - no recipe: a recipe is build instructions fetched from a file or a URL +//! at load, and a bundle has expanded every one of them; +//! - no remote module or plugin: the project carries what it installs; +//! - no path outside the project root, and no symlink under it. +//! +//! The last is the business of the [`FileSystem`](crate::files::FileSystem) +//! the project is loaded through — a [`Confined`](crate::files::Confined) +//! that refuses links — because the loader reads argument and variable files +//! into the model as it loads, before any caller could look. The recipe rule +//! is the business of the [`Resolve`] seam for the same reason: the loader +//! asks it for the template, and [`NoRecipes`] says no. The rest can be seen +//! in the loaded project, and [`vet`] refuses it there. [`Vetted`] wraps a +//! loader so that every load is vetted before the project is handed out, and +//! a consumer composing its own host holds the three together: +//! +//! ```ignore +//! let files = Arc::new(Confined::refusing_links(&root)); +//! let project = Vetted::new(ProjectLoadImpl { +//! project_root_locate, +//! recipe: Arc::new(NoRecipes), +//! files: files.clone(), +//! }); +//! ``` +//! +//! A checksum on a module is not asked for. It is the bundler's promise about +//! its own archive, and the same untrusted hand wrote both, so it says nothing +//! about what the project can reach. + +use async_trait::async_trait; +use snafu::{ResultExt, Snafu}; + +use crate::{ + Project, ProjectLoad, ProjectLoadError, UntrustedSnafu, + canister::recipe::{Fetched, Resolve, ResolveError}, + manifest::{ + adapter::prebuilt::SourceField, + canister::{BuildStep, SyncStep}, + recipe::Recipe, + }, + prelude::*, +}; + +/// Why a loaded project may not be acted on as untrusted. +#[derive(Debug, Snafu)] +pub enum Refused { + #[snafu(display( + "canister '{canister}' has a script build step, which an untrusted project may not run" + ))] + ScriptBuildStep { canister: String }, + + #[snafu(display( + "canister '{canister}' has a script sync step, which an untrusted project may not run" + ))] + ScriptSyncStep { canister: String }, + + #[snafu(display( + "canister '{canister}' fetches its module from '{url}'; an untrusted project must carry \ + its modules" + ))] + RemoteModule { canister: String, url: String }, + + #[snafu(display( + "canister '{canister}' fetches a sync plugin from '{url}'; an untrusted project must \ + carry its plugins" + ))] + RemotePlugin { canister: String, url: String }, +} + +/// Refuse a loaded project that asks for more than an untrusted one may. +/// +/// Looks at every canister's build and sync steps. What the loader has +/// already read or fetched — argument files, recipe templates — is not here +/// to look at, and is held by the seams it was read through instead. +pub fn vet(project: &Project) -> Result<(), Refused> { + for (_, (_, canister)) in &project.canisters { + let name = &canister.name; + for step in &canister.build.steps { + match step { + BuildStep::Script(_) => { + return ScriptBuildStepSnafu { canister: name }.fail(); + } + BuildStep::Prebuilt(adapter) => { + if let SourceField::Remote(remote) = &adapter.source { + return RemoteModuleSnafu { + canister: name, + url: &remote.url, + } + .fail(); + } + } + } + } + for step in &canister.sync.steps { + match step { + SyncStep::Script(_) => { + return ScriptSyncStepSnafu { canister: name }.fail(); + } + SyncStep::Plugin(adapter) => { + if let SourceField::Remote(remote) = &adapter.source { + return RemotePluginSnafu { + canister: name, + url: &remote.url, + } + .fail(); + } + } + } + } + } + Ok(()) +} + +/// The [`Resolve`] that fetches nothing. +/// +/// A recipe is resolved while the project loads, so a project that names one +/// fails to load rather than loading and then being refused — the one place +/// the rule can be applied before the template is read from wherever the +/// manifest points. +pub struct NoRecipes; + +/// A recipe named by a project loaded as untrusted. +#[derive(Debug, Snafu)] +#[snafu(display( + "recipe '{recipe}' is not fetched: an untrusted project must spell out its build and sync \ + steps, as a bundle does" +))] +pub struct RecipeRefused { + pub recipe: String, +} + +#[async_trait] +impl Resolve for NoRecipes { + async fn resolve(&self, recipe: &Recipe) -> Result { + Err(ResolveError::new(RecipeRefused { + recipe: recipe.recipe_type.to_string(), + })) + } +} + +/// A [`ProjectLoad`] that hands out only a project [`vet`] accepts. +pub struct Vetted(L); + +impl Vetted { + pub fn new(loader: L) -> Self { + Self(loader) + } +} + +#[async_trait] +impl ProjectLoad for Vetted { + async fn load(&self) -> Result { + let project = self.0.load().await?; + vet(&project).context(UntrustedSnafu)?; + Ok(project) + } + + async fn exists(&self) -> Result { + self.0.exists().await + } + + fn member_dir(&self) -> Option { + self.0.member_dir() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + MockProjectLoader, + manifest::{ + adapter::{prebuilt, script}, + canister::{BuildSteps, SyncSteps}, + }, + }; + + 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 + } + + 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() { + let project = project_with(vec![local("backend.wasm")], vec![plugin("path: p.wasm")]).await; + vet(&project).expect("pre-built from the project, plugin from the project"); + } + + #[tokio::test] + async fn a_script_step_is_refused() { + let err = + vet(&project_with(vec![BuildStep::Script(script("make"))], vec![]).await).unwrap_err(); + assert!(matches!(err, Refused::ScriptBuildStep { ref canister } if canister == "backend")); + assert!(err.to_string().contains("script build step"), "{err}"); + + let err = vet(&project_with( + vec![local("backend.wasm")], + vec![SyncStep::Script(script("upload"))], + ) + .await) + .unwrap_err(); + assert!(matches!(err, Refused::ScriptSyncStep { .. })); + assert!(err.to_string().contains("script sync step"), "{err}"); + } + + #[tokio::test] + async fn a_remote_module_or_plugin_is_refused() { + let remote = BuildStep::Prebuilt(prebuilt::Adapter { + source: SourceField::Remote(prebuilt::RemoteSource { + url: "https://example.com/backend.wasm".into(), + }), + sha256: Some("abc".into()), + }); + let err = vet(&project_with(vec![remote], vec![]).await).unwrap_err(); + assert!(matches!(err, Refused::RemoteModule { .. })); + assert!( + err.to_string().contains("example.com/backend.wasm"), + "{err}" + ); + + let err = vet(&project_with( + vec![local("backend.wasm")], + vec![plugin("url: https://example.com/plugin.wasm")], + ) + .await) + .unwrap_err(); + assert!(matches!(err, Refused::RemotePlugin { .. })); + assert!(err.to_string().contains("example.com/plugin.wasm"), "{err}"); + } + + #[tokio::test] + async fn no_recipe_is_fetched_whatever_it_names() { + for spec in [ + "file://recipe.hbs", + "https://example.com/recipe.hbs", + "@dfinity/rust@v1", + ] { + let recipe: Recipe = serde_yaml::from_str(&format!("type: \"{spec}\"")).unwrap(); + let err = match NoRecipes.resolve(&recipe).await { + Ok(_) => panic!("{spec} was fetched"), + Err(err) => err.to_string(), + }; + assert!(err.contains("not fetched"), "{spec}: {err}"); + assert!(err.contains(spec), "{spec}: {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)); + assert!(loader.exists().await.unwrap()); + let err = loader.load().await.unwrap_err(); + assert!(matches!(err, ProjectLoadError::Untrusted { .. }), "{err}"); + + let loader = Vetted::new(MockProjectLoader::minimal()); + loader + .load() + .await + .expect("the minimal project is pre-built"); + } +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 9c14edebb..89243e29e 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -120,6 +120,9 @@ This document contains the help content for the `icp` command-line program. Default value: `false` * `--identity-password-file ` — Read identity password from a file instead of prompting +* `--untrusted` — Treat the project as one someone else handed over: refuse a script build or sync step, a recipe, a module or plugin fetched from a URL, a path outside the project root, and a symlink under it + + Default value: `false` From c490cb053ac0dc1c519bda18a26116c24e0a995d Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Thu, 5 Nov 2026 22:00:30 +0000 Subject: [PATCH 14/24] refactor(project): say what an untrusted step may be, not what it may not `untrusted::vet` listed the steps it refused: a script, a module from a URL, a plugin from a URL. A kind of step added later would have passed unexamined. It now holds every step to the two an untrusted project may have, `build_step_allowed` (a pre-built module read from a file in the project) and `sync_step_allowed` (a plugin read from a file in the project), and names the first step that is neither, with the step itself on one line so the reader sees what it was. Co-Authored-By: Claude Fable 5.1 --- crates/icp-cli/tests/untrusted_tests.rs | 5 +- crates/icp-project/src/untrusted.rs | 191 +++++++++++++++--------- 2 files changed, 122 insertions(+), 74 deletions(-) diff --git a/crates/icp-cli/tests/untrusted_tests.rs b/crates/icp-cli/tests/untrusted_tests.rs index 4064a800f..462b02d48 100644 --- a/crates/icp-cli/tests/untrusted_tests.rs +++ b/crates/icp-cli/tests/untrusted_tests.rs @@ -73,7 +73,10 @@ fn a_script_step_is_refused_as_untrusted_but_not_otherwise() { .failure() .stderr( contains("more than an untrusted project may") - .and(contains("canister 'backend' has a script build step")), + .and(contains( + "canister 'backend' has a build step that is not a pre-built module", + )) + .and(contains("(script) cp backend.wasm")), ); } diff --git a/crates/icp-project/src/untrusted.rs b/crates/icp-project/src/untrusted.rs index 17f9ec4c3..db821e42e 100644 --- a/crates/icp-project/src/untrusted.rs +++ b/crates/icp-project/src/untrusted.rs @@ -6,23 +6,28 @@ //! the machine. A project from elsewhere — an archive an agent passed to a //! deploy service, a checkout the user has not read — gets none of that. What //! it may do is the shape `icp project bundle` writes, since a bundle is what -//! such a project usually is: +//! such a project usually is, and it is said as what is allowed rather than +//! what is not, so that a kind of step added later is refused until someone +//! decides otherwise: //! -//! - no script step, for building or syncing: those are host subprocesses; -//! - no recipe: a recipe is build instructions fetched from a file or a URL -//! at load, and a bundle has expanded every one of them; -//! - no remote module or plugin: the project carries what it installs; -//! - no path outside the project root, and no symlink under it. +//! - a build step is a pre-built module read from a file in the project +//! ([`build_step_allowed`]); +//! - a sync step is a plugin read from a file in the project +//! ([`sync_step_allowed`]); +//! - a recipe is never fetched: it is build instructions read from a file or +//! a URL at load, and a bundle has expanded every one of them; +//! - a path names something under the project root, through no symlink. //! //! The last is the business of the [`FileSystem`](crate::files::FileSystem) //! the project is loaded through — a [`Confined`](crate::files::Confined) //! that refuses links — because the loader reads argument and variable files //! into the model as it loads, before any caller could look. The recipe rule //! is the business of the [`Resolve`] seam for the same reason: the loader -//! asks it for the template, and [`NoRecipes`] says no. The rest can be seen -//! in the loaded project, and [`vet`] refuses it there. [`Vetted`] wraps a -//! loader so that every load is vetted before the project is handed out, and -//! a consumer composing its own host holds the three together: +//! asks it for the template, and [`NoRecipes`] says no. The steps can be seen +//! in the loaded project, and [`vet`] holds them to the list there. +//! [`Vetted`] wraps a loader so that every load is vetted before the project +//! is handed out, and a consumer composing its own host holds the three +//! together: //! //! ```ignore //! let files = Arc::new(Confined::refusing_links(&root)); @@ -55,72 +60,84 @@ use crate::{ #[derive(Debug, Snafu)] pub enum Refused { #[snafu(display( - "canister '{canister}' has a script build step, which an untrusted project may not run" + "canister '{canister}' has a build step that is not a pre-built module from a file in \ + the project, which is all an untrusted project may have: {step}" ))] - ScriptBuildStep { canister: String }, + BuildStep { canister: String, step: String }, #[snafu(display( - "canister '{canister}' has a script sync step, which an untrusted project may not run" + "canister '{canister}' has a sync step that is not a plugin from a file in the project, \ + which is all an untrusted project may have: {step}" ))] - ScriptSyncStep { canister: String }, + SyncStep { canister: String, step: String }, +} - #[snafu(display( - "canister '{canister}' fetches its module from '{url}'; an untrusted project must carry \ - its modules" - ))] - RemoteModule { canister: String, url: String }, +/// Whether `step` is the one build step an untrusted project may have: a +/// pre-built module read from a file in the project. A script is a host +/// subprocess, and a module fetched from a URL reaches the network and the +/// package cache on the strength of an untrusted manifest. +pub fn build_step_allowed(step: &BuildStep) -> bool { + matches!(step, BuildStep::Prebuilt(adapter) if matches!(adapter.source, SourceField::Local(_))) +} - #[snafu(display( - "canister '{canister}' fetches a sync plugin from '{url}'; an untrusted project must \ - carry its plugins" - ))] - RemotePlugin { canister: String, url: String }, +/// Whether `step` is the one sync step an untrusted project may have: a +/// plugin read from a file in the project. It runs sandboxed either way; it +/// is the download that is refused. +pub fn sync_step_allowed(step: &SyncStep) -> bool { + matches!(step, SyncStep::Plugin(adapter) if matches!(adapter.source, SourceField::Local(_))) } -/// Refuse a loaded project that asks for more than an untrusted one may. +/// Hold a loaded project's steps to [`build_step_allowed`] and +/// [`sync_step_allowed`], naming the first that is neither. /// -/// Looks at every canister's build and sync steps. What the loader has -/// already read or fetched — argument files, recipe templates — is not here -/// to look at, and is held by the seams it was read through instead. +/// What the loader has already read or fetched — argument files, recipe +/// templates — is not here to look at, and is held by the seams it was read +/// through instead. pub fn vet(project: &Project) -> Result<(), Refused> { for (_, (_, canister)) in &project.canisters { - let name = &canister.name; - for step in &canister.build.steps { - match step { - BuildStep::Script(_) => { - return ScriptBuildStepSnafu { canister: name }.fail(); - } - BuildStep::Prebuilt(adapter) => { - if let SourceField::Remote(remote) = &adapter.source { - return RemoteModuleSnafu { - canister: name, - url: &remote.url, - } - .fail(); - } - } + if let Some(step) = canister + .build + .steps + .iter() + .find(|step| !build_step_allowed(step)) + { + return BuildStepSnafu { + canister: &canister.name, + step: one_line(step), } + .fail(); } - for step in &canister.sync.steps { - match step { - SyncStep::Script(_) => { - return ScriptSyncStepSnafu { canister: name }.fail(); - } - SyncStep::Plugin(adapter) => { - if let SourceField::Remote(remote) = &adapter.source { - return RemotePluginSnafu { - canister: name, - url: &remote.url, - } - .fail(); - } - } + if let Some(step) = canister + .sync + .steps + .iter() + .find(|step| !sync_step_allowed(step)) + { + return SyncStepSnafu { + canister: &canister.name, + step: one_line(step), } + .fail(); } } Ok(()) } +/// A step as it displays, on one line: a step displays its kind and then +/// its fields or commands one per line, and an error message has room for +/// one, so the lines after the first are 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}") + } +} + /// The [`Resolve`] that fetches nothing. /// /// A recipe is resolved while the project loads, so a project that names one @@ -218,25 +235,27 @@ mod tests { vet(&project).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_script_step_is_refused() { + async fn a_build_step_that_is_not_a_local_prebuilt_module_is_refused() { let err = vet(&project_with(vec![BuildStep::Script(script("make"))], vec![]).await).unwrap_err(); - assert!(matches!(err, Refused::ScriptBuildStep { ref canister } if canister == "backend")); - assert!(err.to_string().contains("script build step"), "{err}"); + assert!(matches!(err, Refused::BuildStep { ref canister, .. } if canister == "backend")); + let text = err.to_string(); + assert!( + text.contains("not a pre-built module from a file in the project"), + "{text}" + ); + assert!(text.contains("(script) make"), "{text}"); - let err = vet(&project_with( - vec![local("backend.wasm")], - vec![SyncStep::Script(script("upload"))], - ) - .await) - .unwrap_err(); - assert!(matches!(err, Refused::ScriptSyncStep { .. })); - assert!(err.to_string().contains("script sync step"), "{err}"); - } + let several = script::Adapter { + command: script::CommandField::Commands(vec!["make".into(), "cp a b".into()]), + }; + let err = vet(&project_with(vec![BuildStep::Script(several)], vec![]).await).unwrap_err(); + assert!(err.to_string().contains("(script) make; cp a b"), "{err}"); - #[tokio::test] - async fn a_remote_module_or_plugin_is_refused() { let remote = BuildStep::Prebuilt(prebuilt::Adapter { source: SourceField::Remote(prebuilt::RemoteSource { url: "https://example.com/backend.wasm".into(), @@ -244,19 +263,45 @@ mod tests { sha256: Some("abc".into()), }); let err = vet(&project_with(vec![remote], vec![]).await).unwrap_err(); - assert!(matches!(err, Refused::RemoteModule { .. })); + 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 = vet(&project_with( + 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 = vet(&project_with( + 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 from a file in the project"), + "{text}" + ); + assert!(text.contains("script"), "{text}"); + let err = vet(&project_with( vec![local("backend.wasm")], vec![plugin("url: https://example.com/plugin.wasm")], ) .await) .unwrap_err(); - assert!(matches!(err, Refused::RemotePlugin { .. })); + assert!(matches!(err, Refused::SyncStep { .. })); assert!(err.to_string().contains("example.com/plugin.wasm"), "{err}"); } From 4953f0d92ed872608704606833954c4eabb65198 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Thu, 5 Nov 2026 23:59:18 +0000 Subject: [PATCH 15/24] fix(untrusted): read every module through the confined filesystem, and anchor a relative root Two gaps a review turned up in what `--untrusted` holds a project to. The module a pre-built step or a plugin step names was fetched by the CLI's shared fetcher, which reads a local source straight off the host: its checksum was taken of a file outside the root or through a symlink, and for a plugin the unchecked path went on to Wasmtime, which opens it for itself. `untrusted::LocalModules` is the `wasm::Fetch` of an untrusted project: every module is read through the confined filesystem first, so the refusal happens before any checksum and before the path goes anywhere, and its message is the filesystem's own; a module named by URL is not fetched. A relative `--project-root-override` or `ICP_PROJECT_ROOT`, such as `bundle`, came back from the locator as spelled, and the confined filesystem then held `bundle/icp.yaml` to `bundle/bundle/icp.yaml`, so a valid project failed to load. The override is now joined to the working directory before the locator is built, spelled as the user spelled it, with nothing canonicalized. Co-Authored-By: Claude Fable 5.1 --- crates/icp-app/src/context/init.rs | 51 ++++-- crates/icp-cli/tests/untrusted_tests.rs | 109 ++++++++++++ crates/icp-project/src/untrusted.rs | 222 +++++++++++++++++++++++- 3 files changed, 359 insertions(+), 23 deletions(-) diff --git a/crates/icp-app/src/context/init.rs b/crates/icp-app/src/context/init.rs index ea13813fa..9e779f88c 100644 --- a/crates/icp-app/src/context/init.rs +++ b/crates/icp-app/src/context/init.rs @@ -14,7 +14,7 @@ use std::time::Duration; use crate::{agent, identity, identity::PasswordFunc}; use icp_project::{ Lazy, ProjectLoad, ProjectLoadImpl, - canister::recipe::Resolve, + canister::{recipe::Resolve, wasm}, files::{Confined, FileSystem, HostFileSystem}, host::Host, manifest::{self, ProjectRootLocate as _}, @@ -52,10 +52,11 @@ pub enum ContextInitError { /// `untrusted` treats the project as one someone else handed over: it is /// loaded through a filesystem confined to its root that refuses symlinks, no -/// recipe is fetched, and a project with a script step or a remote module or -/// plugin is refused before any command acts on it. See -/// [`icp_project::untrusted`]. The root is located up front, since the -/// filesystem is held to it from the start, so there has to be a project. +/// recipe is fetched, every module is read from the project through that +/// filesystem, and a project with a script step or a remote module or plugin +/// is refused before any command acts on it. See [`icp_project::untrusted`]. +/// The root is located up front, since the filesystem is held to it from the +/// start, so there has to be a project. pub fn initialize( project_root_override: Option, debug: bool, @@ -94,6 +95,12 @@ pub fn initialize( let cwd: PathBuf = PathBuf::try_from(current_dir().context(CwdSnafu)?).context(Utf8PathSnafu)?; + // A relative override is taken from that working directory, spelled as + // the user spelled it. Everything below joins paths to the root the + // locator hands back, and the filesystem an untrusted project is confined + // to holds them to that root by its spelling, so the root has to be a + // whole path before anything is built on it. + let project_root_override = project_root_override.map(|dir| cwd.join(dir)); let project_root_locate = Arc::new(manifest::ProjectRootLocateImpl::new( cwd, project_root_override, @@ -113,11 +120,26 @@ pub fn initialize( // holder. let pkg_cache = Arc::new(dirs.package_cache().context(PackageCacheSnafu)?); - // Wasm modules named by a manifest but not contained in the project - let wasm = Arc::new(crate::wasm::Fetcher::new( - http_client.clone(), - pkg_cache.clone(), - )); + // The project's files come from this machine: the whole of it for the + // user's own project, the project directory alone for an untrusted one. + let files: Arc = if untrusted { + let root = project_root_locate.locate().context(UntrustedRootSnafu)?; + Arc::new(Confined::refusing_links(&root)) + } else { + Arc::new(HostFileSystem) + }; + + // Wasm modules named by a manifest: fetched and cached when not contained + // in the project, unless the project is untrusted, in which case each is + // read from the project through the filesystem confined to it. + let wasm: Arc = if untrusted { + Arc::new(untrusted::LocalModules::new(files.clone())) + } else { + Arc::new(crate::wasm::Fetcher::new( + http_client.clone(), + pkg_cache.clone(), + )) + }; // Recipes: fetched from wherever the manifest points, unless the project // is untrusted, in which case none is. @@ -130,15 +152,6 @@ pub fn initialize( }) }; - // The project's files come from this machine: the whole of it for the - // user's own project, the project directory alone for an untrusted one. - let files: Arc = if untrusted { - let root = project_root_locate.locate().context(UntrustedRootSnafu)?; - Arc::new(Confined::refusing_links(&root)) - } else { - Arc::new(HostFileSystem) - }; - // Canister builder let builder = Arc::new(Builder::new(wasm.clone(), files.clone())); diff --git a/crates/icp-cli/tests/untrusted_tests.rs b/crates/icp-cli/tests/untrusted_tests.rs index 462b02d48..fa7d8e4a4 100644 --- a/crates/icp-cli/tests/untrusted_tests.rs +++ b/crates/icp-cli/tests/untrusted_tests.rs @@ -186,6 +186,115 @@ fn a_symlink_under_the_project_is_refused_as_untrusted() { ); } +/// The module a step names goes through the confined filesystem too, before +/// its path is handed to whatever opens it, so a module outside the project +/// is refused as its own path and not merely once the build tries to copy it. +#[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 + "#}, + ); + std::fs::copy( + ctx.make_asset("example_icp_mo.wasm"), + project_dir.parent().unwrap().join("outside.wasm"), + ) + .expect("copy wasm outside"); + + ctx.icp() + .current_dir(&project_dir) + .args(["build"]) + .assert() + .success(); + + ctx.icp() + .current_dir(&project_dir) + .args(["build", "--untrusted"]) + .assert() + .failure() + .stderr(contains("outside.wasm").and(contains("is outside"))); +} + +#[cfg(unix)] +#[test] +fn a_module_through_a_symlink_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 + "#}, + ); + std::os::unix::fs::symlink( + project_dir.join("backend.wasm"), + project_dir.join("linked.wasm"), + ) + .expect("symlink"); + + 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("passes through")) + .and(contains("symlink")), + ); +} + +/// A relative project root is taken from the working directory. The confined +/// filesystem holds paths to the root by its spelling, so a root spelled +/// `icp` would hold `icp/icp.yaml` to `icp/icp/icp.yaml` if it were not. +#[test] +fn a_relative_project_root_is_taken_from_the_working_directory() { + let ctx = TestContext::new(); + let project_dir = project( + &ctx, + indoc! {r#" + canisters: + - name: backend + build: + steps: + - type: pre-built + path: backend.wasm + "#}, + ); + 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(); +} + #[test] fn untrusted_needs_a_project_to_hold_to() { let ctx = TestContext::new(); diff --git a/crates/icp-project/src/untrusted.rs b/crates/icp-project/src/untrusted.rs index db821e42e..9fcf71931 100644 --- a/crates/icp-project/src/untrusted.rs +++ b/crates/icp-project/src/untrusted.rs @@ -26,8 +26,11 @@ //! asks it for the template, and [`NoRecipes`] says no. The steps can be seen //! in the loaded project, and [`vet`] holds them to the list there. //! [`Vetted`] wraps a loader so that every load is vetted before the project -//! is handed out, and a consumer composing its own host holds the three -//! together: +//! is handed out. The module a step names is the business of the +//! [`wasm::Fetch`] seam, since a step hands the path it gets back to whatever +//! opens the module — the plugin runtime opens it for itself — so +//! [`LocalModules`] reads each one through the confined filesystem first. A +//! consumer composing its own host holds the four together: //! //! ```ignore //! let files = Arc::new(Confined::refusing_links(&root)); @@ -36,18 +39,27 @@ //! recipe: Arc::new(NoRecipes), //! files: files.clone(), //! }); +//! let wasm = LocalModules::new(files.clone()); //! ``` //! //! A checksum on a module is not asked for. It is the bundler's promise about //! its own archive, and the same untrusted hand wrote both, so it says nothing //! about what the project can reach. +use std::sync::Arc; + use async_trait::async_trait; -use snafu::{ResultExt, Snafu}; +use icp_events::StepReporter; +use sha2::{Digest, Sha256}; +use snafu::{ResultExt, Snafu, ensure}; use crate::{ Project, ProjectLoad, ProjectLoadError, UntrustedSnafu, - canister::recipe::{Fetched, Resolve, ResolveError}, + canister::{ + recipe::{Fetched, Resolve, ResolveError}, + wasm, + }, + files::{FileSystem, FsError}, manifest::{ adapter::prebuilt::SourceField, canister::{BuildStep, SyncStep}, @@ -165,6 +177,96 @@ impl Resolve for NoRecipes { } } +/// The [`wasm::Fetch`] of an untrusted project: every module is a file in it. +/// +/// A module is read through the project's filesystem — the [`Confined`] that +/// refuses links — so one outside the root, or reached through a symlink, is +/// refused here: before a checksum is taken of it, and before its path goes +/// back to a step that hands it to something that opens the file for itself, +/// as the plugin runtime does. A module named by URL is not fetched. [`vet`] +/// has already refused the step that names one; this is the seam saying the +/// same to a caller that never loaded the project. +/// +/// [`Confined`]: crate::files::Confined +pub struct LocalModules { + files: Arc, +} + +impl LocalModules { + pub fn new(files: Arc) -> Self { + Self { files } + } + + async fn module( + &self, + source: &SourceField, + base_dir: &Path, + sha256: Option<&str>, + reporter: &StepReporter, + ) -> Result { + let local = match source { + SourceField::Local(local) => local, + SourceField::Remote(remote) => return RemoteSnafu { url: &remote.url }.fail(), + }; + let path = base_dir.join(&local.path); + reporter.info(format!("Reading wasm: {}", local.path)); + let bytes = self.files.read(&path).await?; + if let Some(expected) = sha256 { + reporter.info("Verifying checksum"); + let actual = hex::encode(Sha256::digest(&bytes)); + ensure!( + actual == expected, + ChecksumSnafu { + path, + expected, + actual, + } + ); + } + Ok(path) + } +} + +/// A module an untrusted project names that it is not given. +#[derive(Debug, Snafu)] +pub enum ModuleRefused { + #[snafu(display( + "module '{url}' is not fetched: an untrusted project's modules are files in it, as a \ + bundle's are" + ))] + Remote { url: String }, + + /// The filesystem the project is confined to would not read it: it lies + /// outside the root, passes through a link, or is not there. Its message + /// names the path and says which, so it is passed through as it is. + #[snafu(transparent)] + ReadModule { source: FsError }, + + #[snafu(display( + "module '{path}' is not the one the manifest names: its sha256 is {actual}, not {expected}" + ))] + Checksum { + path: PathBuf, + expected: String, + actual: String, + }, +} + +#[async_trait] +impl wasm::Fetch for LocalModules { + async fn wasm( + &self, + source: &SourceField, + base_dir: &Path, + sha256: Option<&str>, + reporter: &StepReporter, + ) -> Result { + self.module(source, base_dir, sha256, reporter) + .await + .map_err(wasm::FetchError::new) + } +} + /// A [`ProjectLoad`] that hands out only a project [`vet`] accepts. pub struct Vetted(L); @@ -336,4 +438,116 @@ mod tests { .await .expect("the minimal project is pre-built"); } + + /// The modules go through a [`Confined`](crate::files::Confined) that + /// refuses links, which is a host filesystem, so this is held to the + /// `host` feature as that is. + #[cfg(feature = "host")] + mod modules { + use super::*; + use crate::canister::wasm::Fetch as _; + use crate::files::Confined; + use crate::manifest::adapter::prebuilt::{LocalSource, RemoteSource}; + + /// A project holding `backend.wasm`, beside a file it must not + /// reach, and on unix a link in the project to that file. + fn project() -> (camino_tempfile::Utf8TempDir, LocalModules, PathBuf) { + 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("outside.wasm"), b"secret").unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(dir.path().join("outside.wasm"), root.join("link.wasm")) + .unwrap(); + let files = Arc::new(Confined::refusing_links(&root)); + (dir, LocalModules::new(files), root) + } + + async fn fetch( + modules: &LocalModules, + base_dir: &Path, + source: SourceField, + sha256: Option<&str>, + ) -> Result { + modules + .wasm(&source, base_dir, sha256, &StepReporter::null()) + .await + .map_err(|err| err.to_string()) + } + + fn local(path: &str) -> SourceField { + SourceField::Local(LocalSource { path: path.into() }) + } + + #[tokio::test] + async fn a_module_in_the_project_is_read_and_its_checksum_checked() { + let (_dir, modules, root) = project(); + let sha = hex::encode(Sha256::digest(b"module")); + + let path = fetch(&modules, &root, local("backend.wasm"), None) + .await + .expect("no checksum to check"); + assert_eq!(path, root.join("backend.wasm")); + fetch(&modules, &root, local("backend.wasm"), Some(&sha)) + .await + .expect("the right checksum"); + + let err = fetch(&modules, &root, local("backend.wasm"), Some("00")) + .await + .unwrap_err(); + assert!(err.contains("backend.wasm"), "{err}"); + assert!(err.contains(&sha), "{err}"); + assert!(err.contains("not 00"), "{err}"); + } + + /// Refused on the read, before any checksum is taken and before the + /// path is returned to be opened by something else. + #[tokio::test] + async fn a_module_outside_the_project_is_refused() { + let (dir, modules, root) = project(); + for source in [ + local("../outside.wasm"), + local(dir.path().join("outside.wasm").as_str()), + ] { + let err = fetch(&modules, &root, source, None).await.unwrap_err(); + assert!(err.contains("is outside"), "{err}"); + assert!(err.contains("outside.wasm"), "{err}"); + } + // Nor does naming it from a canister directory below the root help. + let err = fetch( + &modules, + &root.join("src"), + local("../../outside.wasm"), + None, + ) + .await + .unwrap_err(); + assert!(err.contains("outside.wasm"), "{err}"); + } + + #[cfg(unix)] + #[tokio::test] + async fn a_module_through_a_link_is_refused() { + let (_dir, modules, root) = project(); + let err = fetch(&modules, &root, local("link.wasm"), None) + .await + .unwrap_err(); + assert!(err.contains("passes through"), "{err}"); + assert!(err.contains("link.wasm"), "{err}"); + } + + #[tokio::test] + async fn a_module_named_by_url_is_not_fetched() { + let (_dir, modules, root) = project(); + let remote = SourceField::Remote(RemoteSource { + url: "https://example.com/backend.wasm".into(), + }); + let err = fetch(&modules, &root, remote, Some("abc")) + .await + .unwrap_err(); + assert!(err.contains("is not fetched"), "{err}"); + assert!(err.contains("example.com/backend.wasm"), "{err}"); + } + } } From e3c2b23d3c6e83fab892b2c0009750b593a4bf49 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Fri, 6 Nov 2026 14:38:04 +0000 Subject: [PATCH 16/24] refactor(cli): put --untrusted on build, deploy and sync alone A deploy service handed a project by an agent is what asks for the flag, and those are the commands it runs; nothing else acts on what a project says. The flag is read off the command before the context is composed, since it is the context that applies it. On every other command it is now an unexpected argument. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- crates/icp-cli/src/commands/build.rs | 5 ++++- crates/icp-cli/src/commands/deploy.rs | 5 ++++- crates/icp-cli/src/commands/sync.rs | 5 ++++- crates/icp-cli/src/main.rs | 24 ++++++++++++++++-------- crates/icp-cli/src/options.rs | 12 ++++++++++++ crates/icp-cli/tests/untrusted_tests.rs | 24 ++++++++++++++++++------ docs/reference/cli.md | 6 +++--- 8 files changed, 62 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b05eb685f..1afea4bb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ air-gapped signing, extensions # Unreleased -* feat: `--untrusted`, a global flag, treats the project as one someone else handed over rather than the user's own: a bundle an agent produced, a checkout nobody has read. The project is loaded through a filesystem held to the project root that refuses a symlink anywhere under it, so a manifest can name no file outside the project and no link to one; no recipe is fetched; and a project with a script build or sync step, or a module or sync plugin fetched from a URL, is refused before any command acts on it. What is left is the shape `icp project bundle` writes. Without the flag nothing changes: the user's own project may still run scripts, use recipes, and link or reach wherever it likes. +* feat: `--untrusted`, on `build`, `deploy` and `sync`, treats the project as one someone else handed over rather than the user's own: a bundle an agent produced, a checkout nobody has read. The project is loaded through a filesystem held to the project root that refuses a symlink anywhere under it, so a manifest can name no file outside the project and no link to one; no recipe is fetched; and a project with a script build or sync step, or a module or sync plugin fetched from a URL, is refused before the command acts on it. What is left is the shape `icp project bundle` writes. Without the flag nothing changes: the user's own project may still run scripts, use recipes, and link or reach wherever it likes. * 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-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/main.rs b/crates/icp-cli/src/main.rs index 0a0f1342c..c449b1f1a 100644 --- a/crates/icp-cli/src/main.rs +++ b/crates/icp-cli/src/main.rs @@ -88,12 +88,6 @@ struct Cli { #[arg(long, global = true, value_name = "FILE", help_heading = heading::GLOBAL_PARAMETERS, value_hint = ValueHint::FilePath)] identity_password_file: Option, - /// Treat the project as one someone else handed over: refuse a script - /// build or sync step, a recipe, a module or plugin fetched from a URL, - /// a path outside the project root, and a symlink under it - #[arg(long, default_value = "false", global = true, help_heading = heading::GLOBAL_PARAMETERS)] - untrusted: bool, - /// Generate markdown documentation for all commands and exit #[arg(long, hide = true)] markdown_help: bool, @@ -141,7 +135,6 @@ async fn run() -> Result<(), Error> { identity_password_file: matches .get_one::("identity_password_file") .cloned(), - untrusted: matches.get_flag("untrusted"), markdown_help: false, command: None, }, @@ -221,7 +214,7 @@ async fn run() -> Result<(), Error> { cli.debug, password_func, pem_session_duration, - cli.untrusted, + command.untrusted(), )?; // Extension commands are left out of telemetry: telemetry parses against @@ -274,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..a1bb844de 100644 --- a/crates/icp-cli/src/options.rs +++ b/crates/icp-cli/src/options.rs @@ -40,6 +40,18 @@ 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 { + /// Treat the project as one someone else handed over: refuse a script + /// build or sync step, a recipe, a module or plugin fetched from a URL, + /// a path outside the project root, and a symlink under it + #[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 index fa7d8e4a4..b9817c158 100644 --- a/crates/icp-cli/tests/untrusted_tests.rs +++ b/crates/icp-cli/tests/untrusted_tests.rs @@ -303,7 +303,7 @@ fn untrusted_needs_a_project_to_hold_to() { ctx.icp() .current_dir(&empty) - .args(["identity", "list", "--untrusted"]) + .args(["build", "--untrusted"]) .assert() .failure() .stderr(contains( @@ -311,8 +311,10 @@ fn untrusted_needs_a_project_to_hold_to() { )); } +/// 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_global() { +fn the_flag_is_on_build_deploy_and_sync_alone() { let ctx = TestContext::new(); let project_dir = project( &ctx, @@ -321,14 +323,24 @@ fn the_flag_is_global() { - name: backend build: steps: - - type: pre-built - path: backend.wasm + - type: script + command: cp backend.wasm "$ICP_WASM_OUTPUT_PATH" "#}, ); + 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(["--untrusted", "build", "backend"]) + .args(["identity", "list", "--untrusted"]) .assert() - .success(); + .failure() + .stderr(contains("unexpected argument '--untrusted'")); } diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 89243e29e..14ba613fd 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -120,9 +120,6 @@ This document contains the help content for the `icp` command-line program. Default value: `false` * `--identity-password-file ` — Read identity password from a file instead of prompting -* `--untrusted` — Treat the project as one someone else handed over: refuse a script build or sync step, a recipe, a module or plugin fetched from a URL, a path outside the project root, and a symlink under it - - Default value: `false` @@ -139,6 +136,7 @@ Build canisters ###### **Options:** * `-e`, `--environment ` — Override the environment to build for. By default, the local environment is used +* `--untrusted` — Treat the project as one someone else handed over: refuse a script build or sync step, a recipe, a module or plugin fetched from a URL, a path outside the project root, and a symlink under it @@ -1074,6 +1072,7 @@ using --args or --args-file: - `bin`: Raw binary (only valid for file references) +* `--untrusted` — Treat the project as one someone else handed over: refuse a script build or sync step, a recipe, a module or plugin fetched from a URL, a path outside the project root, and a symlink under it @@ -1922,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` — Treat the project as one someone else handed over: refuse a script build or sync step, a recipe, a module or plugin fetched from a URL, a path outside the project root, and a symlink under it From 77f4633f132c55d8168c83dde2e43309eaa7dcc2 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Fri, 6 Nov 2026 14:45:13 +0000 Subject: [PATCH 17/24] docs(untrusted): say what an untrusted project may have, not what it may not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vet has been a list of what is allowed since ae3367ef; the flag's help, the changelog, the context's doc comment and the symlink message still read as a list of refusals. Each now states the list — a pre-built module from a file in the project, a plugin from a file in the project, no recipe, every path a regular file or directory under the root — and that anything else is refused. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- crates/icp-app/src/context/init.rs | 15 ++++++++------- crates/icp-cli/src/options.rs | 8 +++++--- docs/reference/cli.md | 6 +++--- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1afea4bb1..a4b254dd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ air-gapped signing, extensions # Unreleased -* feat: `--untrusted`, on `build`, `deploy` and `sync`, treats the project as one someone else handed over rather than the user's own: a bundle an agent produced, a checkout nobody has read. The project is loaded through a filesystem held to the project root that refuses a symlink anywhere under it, so a manifest can name no file outside the project and no link to one; no recipe is fetched; and a project with a script build or sync step, or a module or sync plugin fetched from a URL, is refused before the command acts on it. What is left is the shape `icp project bundle` writes. Without the flag nothing changes: the user's own project may still run scripts, use recipes, and link or reach wherever it likes. +* feat: `--untrusted`, on `build`, `deploy` and `sync`, holds the project to what one someone else handed over may have — a bundle an agent produced, a checkout nobody has read. That is the shape `icp project bundle` writes, stated as what is allowed so that a kind of step added later is refused until someone decides otherwise: each build step is a pre-built module from a file in the project, each sync step is a plugin from a file in the project, no recipe is used, and every path names a regular file or directory under the project root, through no symlink. Anything else is refused before the command acts on it, naming what was found. Without the flag nothing changes: the user's own project may still run scripts, use recipes, fetch modules, and link or reach wherever it likes. * 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 9e779f88c..5010666cb 100644 --- a/crates/icp-app/src/context/init.rs +++ b/crates/icp-app/src/context/init.rs @@ -50,13 +50,14 @@ pub enum ContextInitError { }, } -/// `untrusted` treats the project as one someone else handed over: it is -/// loaded through a filesystem confined to its root that refuses symlinks, no -/// recipe is fetched, every module is read from the project through that -/// filesystem, and a project with a script step or a remote module or plugin -/// is refused before any command acts on it. See [`icp_project::untrusted`]. -/// The root is located up front, since the filesystem is held to it from the -/// start, so there has to be a project. +/// `untrusted` holds the project to what one someone else handed over may +/// have, the shape a bundle has — see [`icp_project::untrusted`] for the +/// list: it is loaded through a filesystem confined to its root on which +/// every path is a regular file or directory, no recipe is used, every +/// module is read from the project through that filesystem, and the loaded +/// project is vetted for the steps on the list before any command acts on +/// it. The root is located up front, since the filesystem is held to it from +/// the start, so there has to be a project. pub fn initialize( project_root_override: Option, debug: bool, diff --git a/crates/icp-cli/src/options.rs b/crates/icp-cli/src/options.rs index a1bb844de..a331e6cf6 100644 --- a/crates/icp-cli/src/options.rs +++ b/crates/icp-cli/src/options.rs @@ -45,9 +45,11 @@ impl From for IdentitySelection { /// for it, and those are the commands it runs. #[derive(Args, Clone, Debug, Default)] pub(crate) struct UntrustedOpt { - /// Treat the project as one someone else handed over: refuse a script - /// build or sync step, a recipe, a module or plugin fetched from a URL, - /// a path outside the project root, and a symlink under it + /// Hold the project to what one someone else handed over may have, which + /// is the shape `icp project bundle` writes: each build step a pre-built + /// module from a file in the project, each sync step a plugin from a file + /// in the project, no recipe, and every path a regular file or directory + /// under the project root. Anything else is refused #[arg(long)] pub(crate) untrusted: bool, } diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 14ba613fd..9a51be2e7 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -136,7 +136,7 @@ Build canisters ###### **Options:** * `-e`, `--environment ` — Override the environment to build for. By default, the local environment is used -* `--untrusted` — Treat the project as one someone else handed over: refuse a script build or sync step, a recipe, a module or plugin fetched from a URL, a path outside the project root, and a symlink under it +* `--untrusted` — Hold the project to what one someone else handed over may have, which is the shape `icp project bundle` writes: each build step a pre-built module from a file in the project, each sync step a plugin from a file in the project, no recipe, and every path a regular file or directory under the project root. Anything else is refused @@ -1072,7 +1072,7 @@ using --args or --args-file: - `bin`: Raw binary (only valid for file references) -* `--untrusted` — Treat the project as one someone else handed over: refuse a script build or sync step, a recipe, a module or plugin fetched from a URL, a path outside the project root, and a symlink under it +* `--untrusted` — Hold the project to what one someone else handed over may have, which is the shape `icp project bundle` writes: each build step a pre-built module from a file in the project, each sync step a plugin from a file in the project, no recipe, and every path a regular file or directory under the project root. Anything else is refused @@ -1921,7 +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` — Treat the project as one someone else handed over: refuse a script build or sync step, a recipe, a module or plugin fetched from a URL, a path outside the project root, and a symlink under it +* `--untrusted` — Hold the project to what one someone else handed over may have, which is the shape `icp project bundle` writes: each build step a pre-built module from a file in the project, each sync step a plugin from a file in the project, no recipe, and every path a regular file or directory under the project root. Anything else is refused From f3ba1008a15af59d62ce81b01d3cdfbd4f67e66a Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Fri, 6 Nov 2026 15:27:36 +0000 Subject: [PATCH 18/24] refactor(untrusted): hold what a project runs, not what it reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The line is between reading and running. Reading is not restricted: the loader reads argument files, manifests and recipe templates from wherever the manifest points, through the ordinary host filesystem and fetchers, and a recipe's rendered steps are held like any other. What runs is held to a list: a build step is a pre-built module, a sync step is a plugin, and the file each names is within the project — judged by where it really is, root and file both canonicalized, so a link is seen for where it leads and a link within the project is let be. That makes the vet the whole rule, applied to the loaded project, and retires the link-refusing filesystem, the recipe refusal, the module fetch wrapper, and the up-front root locate and relative-root anchoring they needed. `Refused` is boxed on the load error, which it would otherwise push past clippy's size threshold. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- crates/icp-app/src/context/init.rs | 73 +-- crates/icp-cli/src/options.rs | 9 +- crates/icp-cli/tests/untrusted_tests.rs | 210 +++----- crates/icp-project/src/lib.rs | 5 +- crates/icp-project/src/untrusted.rs | 630 +++++++++++------------- docs/reference/cli.md | 6 +- 7 files changed, 378 insertions(+), 557 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4b254dd0..a76399037 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ air-gapped signing, extensions # Unreleased -* feat: `--untrusted`, on `build`, `deploy` and `sync`, holds the project to what one someone else handed over may have — a bundle an agent produced, a checkout nobody has read. That is the shape `icp project bundle` writes, stated as what is allowed so that a kind of step added later is refused until someone decides otherwise: each build step is a pre-built module from a file in the project, each sync step is a plugin from a file in the project, no recipe is used, and every path names a regular file or directory under the project root, through no symlink. Anything else is refused before the command acts on it, naming what was found. Without the flag nothing changes: the user's own project may still run scripts, use recipes, fetch modules, and link or reach wherever it likes. +* feat: `--untrusted`, on `build`, `deploy` and `sync`, holds the project to what one someone else handed over may run — a bundle an agent produced, a checkout nobody has read. The list is stated as what is allowed, so that a kind of step added later is refused until someone decides otherwise: each build step is a pre-built module, each sync step is a plugin, and the file each names is within the project, judged by where it really is once every link is followed and every `..` applied. 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. Without the flag nothing changes: the user's own project may still run scripts and take its modules from anywhere. * 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 5010666cb..b340dc998 100644 --- a/crates/icp-app/src/context/init.rs +++ b/crates/icp-app/src/context/init.rs @@ -13,12 +13,8 @@ use std::time::Duration; use crate::{agent, identity, identity::PasswordFunc}; use icp_project::{ - Lazy, ProjectLoad, ProjectLoadImpl, - canister::{recipe::Resolve, wasm}, - files::{Confined, FileSystem, HostFileSystem}, - host::Host, - manifest::{self, ProjectRootLocate as _}, - store_id, untrusted, + Lazy, ProjectLoad, ProjectLoadImpl, files::HostFileSystem, host::Host, manifest, store_id, + untrusted, }; #[derive(Debug, Snafu)] @@ -43,21 +39,13 @@ pub enum ContextInitError { PackageCache { source: icp_project::fs::lock::LockError, }, - - #[snafu(display("failed to locate the project to treat as untrusted"))] - UntrustedRoot { - source: manifest::ProjectRootLocateError, - }, } /// `untrusted` holds the project to what one someone else handed over may -/// have, the shape a bundle has — see [`icp_project::untrusted`] for the -/// list: it is loaded through a filesystem confined to its root on which -/// every path is a regular file or directory, no recipe is used, every -/// module is read from the project through that filesystem, and the loaded -/// project is vetted for the steps on the list before any command acts on -/// it. The root is located up front, since the filesystem is held to it from -/// the start, so there has to be a project. +/// run — see [`icp_project::untrusted`] for the list: the loaded project is +/// vetted for its steps and for where the file each names resolves, before +/// any command acts on it. Reading is not restricted, so the project is +/// loaded through the same filesystem and fetchers as the user's own. pub fn initialize( project_root_override: Option, debug: bool, @@ -96,12 +84,6 @@ pub fn initialize( let cwd: PathBuf = PathBuf::try_from(current_dir().context(CwdSnafu)?).context(Utf8PathSnafu)?; - // A relative override is taken from that working directory, spelled as - // the user spelled it. Everything below joins paths to the root the - // locator hands back, and the filesystem an untrusted project is confined - // to holds them to that root by its spelling, so the root has to be a - // whole path before anything is built on it. - let project_root_override = project_root_override.map(|dir| cwd.join(dir)); let project_root_locate = Arc::new(manifest::ProjectRootLocateImpl::new( cwd, project_root_override, @@ -121,37 +103,20 @@ pub fn initialize( // holder. let pkg_cache = Arc::new(dirs.package_cache().context(PackageCacheSnafu)?); - // The project's files come from this machine: the whole of it for the - // user's own project, the project directory alone for an untrusted one. - let files: Arc = if untrusted { - let root = project_root_locate.locate().context(UntrustedRootSnafu)?; - Arc::new(Confined::refusing_links(&root)) - } else { - Arc::new(HostFileSystem) - }; + // The project's files come from this machine + let files = Arc::new(HostFileSystem); - // Wasm modules named by a manifest: fetched and cached when not contained - // in the project, unless the project is untrusted, in which case each is - // read from the project through the filesystem confined to it. - let wasm: Arc = if untrusted { - Arc::new(untrusted::LocalModules::new(files.clone())) - } else { - Arc::new(crate::wasm::Fetcher::new( - http_client.clone(), - pkg_cache.clone(), - )) - }; + // Wasm modules named by a manifest but not contained in the project + let wasm = Arc::new(crate::wasm::Fetcher::new( + http_client.clone(), + pkg_cache.clone(), + )); - // Recipes: fetched from wherever the manifest points, unless the project - // is untrusted, in which case none is. - let recipe: Arc = if untrusted { - Arc::new(untrusted::NoRecipes) - } else { - Arc::new(RecipeFetcher { - http_client, - pkg_cache, - }) - }; + // Recipes, fetched from wherever the manifest points + let recipe = Arc::new(RecipeFetcher { + http_client, + pkg_cache, + }); // Canister builder let builder = Arc::new(Builder::new(wasm.clone(), files.clone())); @@ -170,7 +135,7 @@ pub fn initialize( }; let pload: Arc = if untrusted { - Arc::new(Lazy::new(untrusted::Vetted::new(pload))) + Arc::new(Lazy::new(untrusted::Vetted::new(pload, files.clone()))) } else { Arc::new(Lazy::new(pload)) }; diff --git a/crates/icp-cli/src/options.rs b/crates/icp-cli/src/options.rs index a331e6cf6..69b810a9e 100644 --- a/crates/icp-cli/src/options.rs +++ b/crates/icp-cli/src/options.rs @@ -45,11 +45,10 @@ impl From for IdentitySelection { /// 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 have, which - /// is the shape `icp project bundle` writes: each build step a pre-built - /// module from a file in the project, each sync step a plugin from a file - /// in the project, no recipe, and every path a regular file or directory - /// under the project root. Anything else is refused + /// 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 the file + /// each names within the project once links are followed. Anything else + /// is refused. Reading is not restricted #[arg(long)] pub(crate) untrusted: bool, } diff --git a/crates/icp-cli/tests/untrusted_tests.rs b/crates/icp-cli/tests/untrusted_tests.rs index b9817c158..7574a98f1 100644 --- a/crates/icp-cli/tests/untrusted_tests.rs +++ b/crates/icp-cli/tests/untrusted_tests.rs @@ -1,7 +1,7 @@ -//! `--untrusted` holds a project to what one from elsewhere may do: see +//! `--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. The one tree the flag accepts is the shape a bundle has. +//! refused — or letting it be, where the project only reads. use indoc::indoc; use predicates::{prelude::PredicateBooleanExt, str::contains}; @@ -11,32 +11,41 @@ use icp_project::fs::write_string; mod common; -/// A project directory holding a module of its own, and beside it a file the -/// project must not reach. +/// 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::write(project_dir.parent().unwrap().join("outside.bin"), b"secret") - .expect("write outside"); + 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, - indoc! {r#" - canisters: - - name: backend - build: - steps: - - type: pre-built - path: backend.wasm - "#}, - ); + let project_dir = project(&ctx, PREBUILT); ctx.icp() .current_dir(&project_dir) @@ -48,17 +57,7 @@ fn a_bundle_shaped_project_builds_as_untrusted() { #[test] fn a_script_step_is_refused_as_untrusted_but_not_otherwise() { let ctx = TestContext::new(); - let project_dir = project( - &ctx, - indoc! {r#" - canisters: - - name: backend - build: - steps: - - type: script - command: cp backend.wasm "$ICP_WASM_OUTPUT_PATH" - "#}, - ); + let project_dir = project(&ctx, SCRIPT); ctx.icp() .current_dir(&project_dir) @@ -80,8 +79,10 @@ fn a_script_step_is_refused_as_untrusted_but_not_otherwise() { ); } +/// 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_not_fetched_as_untrusted() { +fn a_recipe_is_rendered_and_its_steps_held_to_the_list() { let ctx = TestContext::new(); let project_dir = project( &ctx, @@ -92,28 +93,36 @@ fn a_recipe_is_not_fetched_as_untrusted() { type: file://recipe.hbs "#}, ); + let recipe = project_dir.join("recipe.hbs"); + std::fs::write( - project_dir.join("recipe.hbs"), + &recipe, "build:\n steps:\n - type: pre-built\n path: backend.wasm\n", ) .expect("write recipe"); - ctx.icp() .current_dir(&project_dir) - .args(["build"]) + .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("recipe 'file://recipe.hbs' is not fetched")); + .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 a_path_outside_the_project_is_refused_as_untrusted() { +fn reading_outside_the_project_is_not_restricted() { let ctx = TestContext::new(); let project_dir = project( &ctx, @@ -130,65 +139,13 @@ fn a_path_outside_the_project_is_refused_as_untrusted() { "#}, ); - ctx.icp() - .current_dir(&project_dir) - .args(["build"]) - .assert() - .success(); - ctx.icp() .current_dir(&project_dir) .args(["build", "--untrusted"]) .assert() - .failure() - .stderr(contains("outside.bin").and(contains("is outside"))); -} - -#[cfg(unix)] -#[test] -fn a_symlink_under_the_project_is_refused_as_untrusted() { - let ctx = TestContext::new(); - let project_dir = project( - &ctx, - indoc! {r#" - canisters: - - name: backend - init_args: - path: args.bin - format: bin - build: - steps: - - type: pre-built - path: backend.wasm - "#}, - ); - std::os::unix::fs::symlink( - project_dir.parent().unwrap().join("outside.bin"), - project_dir.join("args.bin"), - ) - .expect("symlink"); - - ctx.icp() - .current_dir(&project_dir) - .args(["build"]) - .assert() .success(); - - ctx.icp() - .current_dir(&project_dir) - .args(["build", "--untrusted"]) - .assert() - .failure() - .stderr( - contains("args.bin") - .and(contains("passes through")) - .and(contains("symlink")), - ); } -/// The module a step names goes through the confined filesystem too, before -/// its path is handed to whatever opens it, so a module outside the project -/// is refused as its own path and not merely once the build tries to copy it. #[test] fn a_module_outside_the_project_is_refused_as_untrusted() { let ctx = TestContext::new(); @@ -203,11 +160,6 @@ fn a_module_outside_the_project_is_refused_as_untrusted() { path: ../outside.wasm "#}, ); - std::fs::copy( - ctx.make_asset("example_icp_mo.wasm"), - project_dir.parent().unwrap().join("outside.wasm"), - ) - .expect("copy wasm outside"); ctx.icp() .current_dir(&project_dir) @@ -220,12 +172,18 @@ fn a_module_outside_the_project_is_refused_as_untrusted() { .args(["build", "--untrusted"]) .assert() .failure() - .stderr(contains("outside.wasm").and(contains("is outside"))); + .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, within +/// it is fine. #[cfg(unix)] #[test] -fn a_module_through_a_symlink_is_refused_as_untrusted() { +fn a_module_linked_out_of_the_project_is_refused_and_one_linked_within_is_not() { let ctx = TestContext::new(); let project_dir = project( &ctx, @@ -238,18 +196,15 @@ fn a_module_through_a_symlink_is_refused_as_untrusted() { path: linked.wasm "#}, ); - std::os::unix::fs::symlink( - project_dir.join("backend.wasm"), - project_dir.join("linked.wasm"), - ) - .expect("symlink"); + 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"]) @@ -257,28 +212,25 @@ fn a_module_through_a_symlink_is_refused_as_untrusted() { .failure() .stderr( contains("linked.wasm") - .and(contains("passes through")) - .and(contains("symlink")), + .and(contains("resolves to")) + .and(contains("outside.wasm")) + .and(contains("outside the project")), ); + + std::fs::remove_file(&link).expect("remove link"); + std::os::unix::fs::symlink(project_dir.join("backend.wasm"), &link).expect("symlink within"); + ctx.icp() + .current_dir(&project_dir) + .args(["build", "--untrusted"]) + .assert() + .success(); } -/// A relative project root is taken from the working directory. The confined -/// filesystem holds paths to the root by its spelling, so a root spelled -/// `icp` would hold `icp/icp.yaml` to `icp/icp/icp.yaml` if it were not. +/// 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, - indoc! {r#" - canisters: - - name: backend - build: - steps: - - type: pre-built - path: backend.wasm - "#}, - ); + let project_dir = project(&ctx, PREBUILT); let parent = project_dir.parent().unwrap(); ctx.icp() @@ -295,38 +247,12 @@ fn a_relative_project_root_is_taken_from_the_working_directory() { .success(); } -#[test] -fn untrusted_needs_a_project_to_hold_to() { - let ctx = TestContext::new(); - let empty = ctx.home_path().join("nowhere"); - std::fs::create_dir_all(&empty).expect("mkdir"); - - ctx.icp() - .current_dir(&empty) - .args(["build", "--untrusted"]) - .assert() - .failure() - .stderr(contains( - "failed to locate the project to treat as untrusted", - )); -} - /// 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, - indoc! {r#" - canisters: - - name: backend - build: - steps: - - type: script - command: cp backend.wasm "$ICP_WASM_OUTPUT_PATH" - "#}, - ); + let project_dir = project(&ctx, SCRIPT); for command in ["build", "deploy", "sync"] { ctx.icp() diff --git a/crates/icp-project/src/lib.rs b/crates/icp-project/src/lib.rs index 5c2517d7d..831620667 100644 --- a/crates/icp-project/src/lib.rs +++ b/crates/icp-project/src/lib.rs @@ -222,7 +222,10 @@ pub enum ProjectLoadError { }, #[snafu(display("the project asks for more than an untrusted project may"))] - Untrusted { source: untrusted::Refused }, + 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 index 9fcf71931..d13a05294 100644 --- a/crates/icp-project/src/untrusted.rs +++ b/crates/icp-project/src/untrusted.rs @@ -1,69 +1,49 @@ -//! Holding a project someone else handed over to what it may do. +//! 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 recipe may come from any file or URL, a -//! module may be downloaded from anywhere, and a manifest may name any path on -//! the machine. A project from elsewhere — an archive an agent passed to a -//! deploy service, a checkout the user has not read — gets none of that. What -//! it may do is the shape `icp project bundle` writes, since a bundle is what -//! such a project usually is, and it is said as what is allowed rather than -//! what is not, so that a kind of step added later is refused until someone -//! decides otherwise: +//! 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: the loader reads argument files, environment +//! files, manifests and recipe templates from wherever the manifest points, +//! and a plugin reads the files it declares, through the ordinary filesystem. +//! 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 read from a file in the project -//! ([`build_step_allowed`]); -//! - a sync step is a plugin read from a file in the project -//! ([`sync_step_allowed`]); -//! - a recipe is never fetched: it is build instructions read from a file or -//! a URL at load, and a bundle has expanded every one of them; -//! - a path names something under the project root, through no symlink. +//! - a build step is a pre-built module ([`build_step_allowed`]); +//! - a sync step is a plugin ([`sync_step_allowed`]); +//! - the module or plugin a step names is a file within the project: its +//! path, with every link followed and every `..` applied, lies under the +//! project root resolved the same way ([`vet`]). //! -//! The last is the business of the [`FileSystem`](crate::files::FileSystem) -//! the project is loaded through — a [`Confined`](crate::files::Confined) -//! that refuses links — because the loader reads argument and variable files -//! into the model as it loads, before any caller could look. The recipe rule -//! is the business of the [`Resolve`] seam for the same reason: the loader -//! asks it for the template, and [`NoRecipes`] says no. The steps can be seen -//! in the loaded project, and [`vet`] holds them to the list there. -//! [`Vetted`] wraps a loader so that every load is vetted before the project -//! is handed out. The module a step names is the business of the -//! [`wasm::Fetch`] seam, since a step hands the path it gets back to whatever -//! opens the module — the plugin runtime opens it for itself — so -//! [`LocalModules`] reads each one through the confined filesystem first. A -//! consumer composing its own host holds the four together: +//! A recipe is read and rendered as the project loads, and the steps it +//! produces are on the list or not like any other. A module or plugin named +//! by URL is not on it: a download at build time is what a bundle has already +//! done. The loaded [`Project`] shows every step, so the list is applied to +//! it: [`vet`] holds a project to it, and [`Vetted`] wraps a loader so that +//! every load is vetted before the project is handed out. A consumer +//! composing its own host wraps its loader the same way: //! //! ```ignore -//! let files = Arc::new(Confined::refusing_links(&root)); -//! let project = Vetted::new(ProjectLoadImpl { -//! project_root_locate, -//! recipe: Arc::new(NoRecipes), -//! files: files.clone(), -//! }); -//! let wasm = LocalModules::new(files.clone()); +//! let project = Vetted::new(ProjectLoadImpl { project_root_locate, recipe, files: files.clone() }, files); //! ``` //! //! A checksum on a module is not asked for. It is the bundler's promise about //! its own archive, and the same untrusted hand wrote both, so it says nothing -//! about what the project can reach. +//! about what the project runs. use std::sync::Arc; use async_trait::async_trait; -use icp_events::StepReporter; -use sha2::{Digest, Sha256}; -use snafu::{ResultExt, Snafu, ensure}; +use snafu::{ResultExt, Snafu}; use crate::{ Project, ProjectLoad, ProjectLoadError, UntrustedSnafu, - canister::{ - recipe::{Fetched, Resolve, ResolveError}, - wasm, - }, - files::{FileSystem, FsError}, + files::FileSystem, manifest::{ adapter::prebuilt::SourceField, canister::{BuildStep, SyncStep}, - recipe::Recipe, }, prelude::*, }; @@ -72,65 +52,122 @@ use crate::{ #[derive(Debug, Snafu)] pub enum Refused { #[snafu(display( - "canister '{canister}' has a build step that is not a pre-built module from a file in \ - the project, which is all an untrusted project may have: {step}" + "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 from a file in the project, \ - which is all an untrusted project may have: {step}" + "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 does not resolve to a file"))] + UnresolvedPath { canister: String, path: PathBuf }, + + #[snafu(display("the project root '{root}' does not resolve to a directory"))] + UnresolvedRoot { root: PathBuf }, } /// Whether `step` is the one build step an untrusted project may have: a -/// pre-built module read from a file in the project. A script is a host -/// subprocess, and a module fetched from a URL reaches the network and the -/// package cache on the strength of an untrusted manifest. +/// pre-built module from a file. A script is a host subprocess, and a module +/// fetched from a URL is a download on the strength of an untrusted manifest. pub fn build_step_allowed(step: &BuildStep) -> bool { matches!(step, BuildStep::Prebuilt(adapter) if matches!(adapter.source, SourceField::Local(_))) } /// Whether `step` is the one sync step an untrusted project may have: a -/// plugin read from a file in the project. It runs sandboxed either way; it -/// is the download that is refused. +/// plugin from a file. It runs sandboxed either way; it is a script and a +/// download that are not on the list. pub fn sync_step_allowed(step: &SyncStep) -> bool { matches!(step, SyncStep::Plugin(adapter) if matches!(adapter.source, SourceField::Local(_))) } -/// Hold a loaded project's steps to [`build_step_allowed`] and -/// [`sync_step_allowed`], naming the first that is neither. +/// Hold a loaded project to the list: every step [`build_step_allowed`] or +/// [`sync_step_allowed`], and the file each names within the project. /// -/// What the loader has already read or fetched — argument files, recipe -/// templates — is not here to look at, and is held by the seams it was read -/// through instead. -pub fn vet(project: &Project) -> Result<(), Refused> { - for (_, (_, canister)) in &project.canisters { - if let Some(step) = canister - .build - .steps - .iter() - .find(|step| !build_step_allowed(step)) - { - return BuildStepSnafu { - canister: &canister.name, - step: one_line(step), - } - .fail(); +/// Within is decided on the real paths: the file's and the root's, each with +/// every link followed and every `..` applied by `files`, so a link out of +/// the project is seen for where it leads and a link within it is let be. A +/// path that does not resolve is refused too, since nothing can be said +/// about where it would lead. +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 local = match step { + BuildStep::Prebuilt(adapter) if build_step_allowed(step) => match &adapter.source { + SourceField::Local(local) => local, + SourceField::Remote(_) => unreachable!("allowed is local"), + }, + _ => { + 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 local = match step { + SyncStep::Plugin(adapter) if sync_step_allowed(step) => match &adapter.source { + SourceField::Local(local) => local, + SourceField::Remote(_) => unreachable!("allowed is local"), + }, + _ => { + 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 somewhere under the resolved `root`. +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, } - if let Some(step) = canister - .sync - .steps - .iter() - .find(|step| !sync_step_allowed(step)) - { - return SyncStepSnafu { - canister: &canister.name, - step: one_line(step), - } - .fail(); + .build() + })?; + if !resolved.starts_with(root) { + return OutsidePathSnafu { + canister: &canister.name, + path, + resolved, + root, } + .fail(); } Ok(()) } @@ -150,160 +187,115 @@ fn one_line(step: &impl std::fmt::Display) -> String { } } -/// The [`Resolve`] that fetches nothing. -/// -/// A recipe is resolved while the project loads, so a project that names one -/// fails to load rather than loading and then being refused — the one place -/// the rule can be applied before the template is read from wherever the -/// manifest points. -pub struct NoRecipes; - -/// A recipe named by a project loaded as untrusted. -#[derive(Debug, Snafu)] -#[snafu(display( - "recipe '{recipe}' is not fetched: an untrusted project must spell out its build and sync \ - steps, as a bundle does" -))] -pub struct RecipeRefused { - pub recipe: String, -} - -#[async_trait] -impl Resolve for NoRecipes { - async fn resolve(&self, recipe: &Recipe) -> Result { - Err(ResolveError::new(RecipeRefused { - recipe: recipe.recipe_type.to_string(), - })) - } -} - -/// The [`wasm::Fetch`] of an untrusted project: every module is a file in it. -/// -/// A module is read through the project's filesystem — the [`Confined`] that -/// refuses links — so one outside the root, or reached through a symlink, is -/// refused here: before a checksum is taken of it, and before its path goes -/// back to a step that hands it to something that opens the file for itself, -/// as the plugin runtime does. A module named by URL is not fetched. [`vet`] -/// has already refused the step that names one; this is the seam saying the -/// same to a caller that never loaded the project. -/// -/// [`Confined`]: crate::files::Confined -pub struct LocalModules { +/// A [`ProjectLoad`] that hands out only a project [`vet`] accepts. +pub struct Vetted { + loader: L, files: Arc, } -impl LocalModules { - pub fn new(files: Arc) -> Self { - Self { files } - } - - async fn module( - &self, - source: &SourceField, - base_dir: &Path, - sha256: Option<&str>, - reporter: &StepReporter, - ) -> Result { - let local = match source { - SourceField::Local(local) => local, - SourceField::Remote(remote) => return RemoteSnafu { url: &remote.url }.fail(), - }; - let path = base_dir.join(&local.path); - reporter.info(format!("Reading wasm: {}", local.path)); - let bytes = self.files.read(&path).await?; - if let Some(expected) = sha256 { - reporter.info("Verifying checksum"); - let actual = hex::encode(Sha256::digest(&bytes)); - ensure!( - actual == expected, - ChecksumSnafu { - path, - expected, - actual, - } - ); - } - Ok(path) - } -} - -/// A module an untrusted project names that it is not given. -#[derive(Debug, Snafu)] -pub enum ModuleRefused { - #[snafu(display( - "module '{url}' is not fetched: an untrusted project's modules are files in it, as a \ - bundle's are" - ))] - Remote { url: String }, - - /// The filesystem the project is confined to would not read it: it lies - /// outside the root, passes through a link, or is not there. Its message - /// names the path and says which, so it is passed through as it is. - #[snafu(transparent)] - ReadModule { source: FsError }, - - #[snafu(display( - "module '{path}' is not the one the manifest names: its sha256 is {actual}, not {expected}" - ))] - Checksum { - path: PathBuf, - expected: String, - actual: String, - }, -} - -#[async_trait] -impl wasm::Fetch for LocalModules { - async fn wasm( - &self, - source: &SourceField, - base_dir: &Path, - sha256: Option<&str>, - reporter: &StepReporter, - ) -> Result { - self.module(source, base_dir, sha256, reporter) - .await - .map_err(wasm::FetchError::new) - } -} - -/// A [`ProjectLoad`] that hands out only a project [`vet`] accepts. -pub struct Vetted(L); - impl Vetted { - pub fn new(loader: L) -> Self { - Self(loader) + /// `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.0.load().await?; - vet(&project).context(UntrustedSnafu)?; + let project = self.loader.load().await?; + vet(&project, self.files.as_ref()) + .await + .context(UntrustedSnafu)?; Ok(project) } async fn exists(&self) -> Result { - self.0.exists().await + self.loader.exists().await } fn member_dir(&self) -> Option { - self.0.member_dir() + self.loader.member_dir() } } #[cfg(test)] mod tests { + use std::collections::HashMap; + 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: the vet asks for nothing but + /// where a path really is. + struct Resolves(HashMap); + + #[async_trait] + impl FileSystem for Resolves { + async fn canonicalize(&self, path: &Path) -> Option { + self.0.get(path).cloned() + } + 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_file(&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, and a climb that lands outside. + fn disk() -> Arc { + Arc::new(Resolves(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(), + ), + ]))) + } + 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(); @@ -312,6 +304,10 @@ mod tests { 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() }), @@ -333,8 +329,9 @@ mod tests { #[tokio::test] async fn a_bundle_shaped_project_is_accepted() { - let project = project_with(vec![local("backend.wasm")], vec![plugin("path: p.wasm")]).await; - vet(&project).expect("pre-built from the project, plugin from the project"); + 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 @@ -342,20 +339,20 @@ mod tests { /// and what the step was. #[tokio::test] async fn a_build_step_that_is_not_a_local_prebuilt_module_is_refused() { - let err = - vet(&project_with(vec![BuildStep::Script(script("make"))], vec![]).await).unwrap_err(); + 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 from a file in the project"), - "{text}" - ); + 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 = vet(&project_with(vec![BuildStep::Script(several)], vec![]).await).unwrap_err(); + 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 { @@ -364,7 +361,7 @@ mod tests { }), sha256: Some("abc".into()), }); - let err = vet(&project_with(vec![remote], vec![]).await).unwrap_err(); + let err = vetted(vec![remote], vec![]).await.unwrap_err(); assert!(matches!(err, Refused::BuildStep { .. })); assert!( err.to_string().contains("example.com/backend.wasm"), @@ -372,182 +369,113 @@ mod tests { ); // One good step does not carry a bad one. - let err = vet(&project_with( + let err = vetted( vec![local("backend.wasm"), BuildStep::Script(script("make"))], vec![], ) - .await) + .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 = vet(&project_with( + let err = vetted( vec![local("backend.wasm")], vec![SyncStep::Script(script("upload"))], ) - .await) + .await .unwrap_err(); assert!(matches!(err, Refused::SyncStep { .. })); let text = err.to_string(); - assert!( - text.contains("not a plugin from a file in the project"), - "{text}" - ); + assert!(text.contains("not a plugin"), "{text}"); assert!(text.contains("script"), "{text}"); - let err = vet(&project_with( + let err = vetted( vec![local("backend.wasm")], vec![plugin("url: https://example.com/plugin.wasm")], ) - .await) + .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 no_recipe_is_fetched_whatever_it_names() { - for spec in [ - "file://recipe.hbs", - "https://example.com/recipe.hbs", - "@dfinity/rust@v1", - ] { - let recipe: Recipe = serde_yaml::from_str(&format!("type: \"{spec}\"")).unwrap(); - let err = match NoRecipes.resolve(&recipe).await { - Ok(_) => panic!("{spec} was fetched"), - Err(err) => err.to_string(), - }; - assert!(err.contains("not fetched"), "{spec}: {err}"); - assert!(err.contains(spec), "{spec}: {err}"); + 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}"); } #[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)); + 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()); + let loader = Vetted::new(MockProjectLoader::minimal(), disk()); loader .load() .await - .expect("the minimal project is pre-built"); + .expect("the minimal project is pre-built from its own file"); } - /// The modules go through a [`Confined`](crate::files::Confined) that - /// refuses links, which is a host filesystem, so this is held to the - /// `host` feature as that is. - #[cfg(feature = "host")] - mod modules { - use super::*; - use crate::canister::wasm::Fetch as _; - use crate::files::Confined; - use crate::manifest::adapter::prebuilt::{LocalSource, RemoteSource}; - - /// A project holding `backend.wasm`, beside a file it must not - /// reach, and on unix a link in the project to that file. - fn project() -> (camino_tempfile::Utf8TempDir, LocalModules, PathBuf) { - 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("outside.wasm"), b"secret").unwrap(); - #[cfg(unix)] - std::os::unix::fs::symlink(dir.path().join("outside.wasm"), root.join("link.wasm")) - .unwrap(); - let files = Arc::new(Confined::refusing_links(&root)); - (dir, LocalModules::new(files), root) - } - - async fn fetch( - modules: &LocalModules, - base_dir: &Path, - source: SourceField, - sha256: Option<&str>, - ) -> Result { - modules - .wasm(&source, base_dir, sha256, &StepReporter::null()) - .await - .map_err(|err| err.to_string()) - } - - fn local(path: &str) -> SourceField { - SourceField::Local(LocalSource { path: path.into() }) - } - - #[tokio::test] - async fn a_module_in_the_project_is_read_and_its_checksum_checked() { - let (_dir, modules, root) = project(); - let sha = hex::encode(Sha256::digest(b"module")); - - let path = fetch(&modules, &root, local("backend.wasm"), None) - .await - .expect("no checksum to check"); - assert_eq!(path, root.join("backend.wasm")); - fetch(&modules, &root, local("backend.wasm"), Some(&sha)) - .await - .expect("the right checksum"); - - let err = fetch(&modules, &root, local("backend.wasm"), Some("00")) - .await - .unwrap_err(); - assert!(err.contains("backend.wasm"), "{err}"); - assert!(err.contains(&sha), "{err}"); - assert!(err.contains("not 00"), "{err}"); - } - - /// Refused on the read, before any checksum is taken and before the - /// path is returned to be opened by something else. - #[tokio::test] - async fn a_module_outside_the_project_is_refused() { - let (dir, modules, root) = project(); - for source in [ - local("../outside.wasm"), - local(dir.path().join("outside.wasm").as_str()), - ] { - let err = fetch(&modules, &root, source, None).await.unwrap_err(); - assert!(err.contains("is outside"), "{err}"); - assert!(err.contains("outside.wasm"), "{err}"); - } - // Nor does naming it from a canister directory below the root help. - let err = fetch( - &modules, - &root.join("src"), - local("../../outside.wasm"), - None, - ) + /// On a real disk the links are followed by the host, which is what the + /// CLI resolves through. + #[cfg(all(unix, feature = "host"))] + #[tokio::test] + async fn on_disk_a_link_is_followed_to_where_it_leads() { + use crate::files::HostFileSystem; + + 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(); + std::os::unix::fs::symlink(dir.path().join("secret.wasm"), root.join("out.wasm")).unwrap(); + std::os::unix::fs::symlink(root.join("backend.wasm"), root.join("in.wasm")).unwrap(); + + let mut project = project_with(vec![local("in.wasm")], vec![]).await; + project.dir = root.clone(); + project.canisters.get_mut("backend").unwrap().0 = root.clone(); + vet(&project, &HostFileSystem) .await - .unwrap_err(); - assert!(err.contains("outside.wasm"), "{err}"); - } - - #[cfg(unix)] - #[tokio::test] - async fn a_module_through_a_link_is_refused() { - let (_dir, modules, root) = project(); - let err = fetch(&modules, &root, local("link.wasm"), None) - .await - .unwrap_err(); - assert!(err.contains("passes through"), "{err}"); - assert!(err.contains("link.wasm"), "{err}"); - } + .expect("a link to the project's own module"); - #[tokio::test] - async fn a_module_named_by_url_is_not_fetched() { - let (_dir, modules, root) = project(); - let remote = SourceField::Remote(RemoteSource { - url: "https://example.com/backend.wasm".into(), - }); - let err = fetch(&modules, &root, remote, Some("abc")) - .await - .unwrap_err(); - assert!(err.contains("is not fetched"), "{err}"); - assert!(err.contains("example.com/backend.wasm"), "{err}"); - } + project.canisters.get_mut("backend").unwrap().1.build = BuildSteps { + steps: vec![local("out.wasm")], + }; + let err = vet(&project, &HostFileSystem).await.unwrap_err(); + assert!(matches!(err, Refused::OutsidePath { .. }), "{err}"); } } diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 9a51be2e7..fe50b4796 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -136,7 +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 have, which is the shape `icp project bundle` writes: each build step a pre-built module from a file in the project, each sync step a plugin from a file in the project, no recipe, and every path a regular file or directory under the project root. Anything else is refused +* `--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 the file each names within the project once links are followed. Anything else is refused. Reading is not restricted @@ -1072,7 +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 have, which is the shape `icp project bundle` writes: each build step a pre-built module from a file in the project, each sync step a plugin from a file in the project, no recipe, and every path a regular file or directory under the project root. Anything else is refused +* `--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 the file each names within the project once links are followed. Anything else is refused. Reading is not restricted @@ -1921,7 +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 have, which is the shape `icp project bundle` writes: each build step a pre-built module from a file in the project, each sync step a plugin from a file in the project, no recipe, and every path a regular file or directory under the project root. Anything else is refused +* `--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 the file each names within the project once links are followed. Anything else is refused. Reading is not restricted From c09004b959db71e00219eb5adb77a878909ebb24 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Fri, 6 Nov 2026 15:47:44 +0000 Subject: [PATCH 19/24] test(untrusted): a chain of links, and a linked directory on the way, lead where they lead Co-Authored-By: Claude Fable 5.1 --- crates/icp-project/src/untrusted.rs | 48 +++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/crates/icp-project/src/untrusted.rs b/crates/icp-project/src/untrusted.rs index d13a05294..299ebf81e 100644 --- a/crates/icp-project/src/untrusted.rs +++ b/crates/icp-project/src/untrusted.rs @@ -451,31 +451,53 @@ mod tests { } /// On a real disk the links are followed by the host, which is what the - /// CLI resolves through. + /// CLI resolves through: to the end of a chain of them, and through a + /// linked directory on the way. #[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(); - std::os::unix::fs::symlink(dir.path().join("secret.wasm"), root.join("out.wasm")).unwrap(); - std::os::unix::fs::symlink(root.join("backend.wasm"), root.join("in.wasm")).unwrap(); - - let mut project = project_with(vec![local("in.wasm")], vec![]).await; + 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(); + + let mut project = project_with(vec![], vec![]).await; project.dir = root.clone(); project.canisters.get_mut("backend").unwrap().0 = root.clone(); - vet(&project, &HostFileSystem) - .await - .expect("a link to the project's own module"); - project.canisters.get_mut("backend").unwrap().1.build = BuildSteps { - steps: vec![local("out.wasm")], - }; - let err = vet(&project, &HostFileSystem).await.unwrap_err(); - assert!(matches!(err, Refused::OutsidePath { .. }), "{err}"); + 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}" + ); + } } } From df0ca777e67c79597a9150c1f9f422d5140adc29 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Mon, 9 Nov 2026 12:46:46 +0000 Subject: [PATCH 20/24] fix(untrusted): what a step names must be a regular file Canonicalization says where a path lands, not what is there, so a directory or a named pipe within the project passed the vet. A pipe is then read from whoever writes to it, and one nobody writes to is waited on forever, at the checksum or at the build. The vet now asks the filesystem whether the resolved path is a regular file and refuses otherwise, naming what it found, with the mock, on-disk and CLI tests to show it. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- crates/icp-cli/src/options.rs | 6 +- crates/icp-cli/tests/untrusted_tests.rs | 45 ++++++++ crates/icp-project/src/untrusted.rs | 141 +++++++++++++++++++----- docs/reference/cli.md | 6 +- 5 files changed, 164 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a76399037..81c293aef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ air-gapped signing, extensions # Unreleased -* feat: `--untrusted`, on `build`, `deploy` and `sync`, holds the project to what one someone else handed over may run — a bundle an agent produced, a checkout nobody has read. The list is stated as what is allowed, so that a kind of step added later is refused until someone decides otherwise: each build step is a pre-built module, each sync step is a plugin, and the file each names is within the project, judged by where it really is once every link is followed and every `..` applied. 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. Without the flag nothing changes: the user's own project may still run scripts and take its modules from anywhere. +* feat: `--untrusted`, on `build`, `deploy` and `sync`, holds the project to what one someone else handed over may run — a bundle an agent produced, a checkout nobody has read. The list is stated as what is allowed, so that a kind of step added later is refused until someone decides otherwise: each build step is a pre-built module, each sync step is a plugin, and what each names is a regular file within the project, judged by where it really is once every link is followed and every `..` applied. 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. Without the flag nothing changes: the user's own project may still run scripts and take its modules from anywhere. * 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-cli/src/options.rs b/crates/icp-cli/src/options.rs index 69b810a9e..a691ba1af 100644 --- a/crates/icp-cli/src/options.rs +++ b/crates/icp-cli/src/options.rs @@ -46,9 +46,9 @@ impl From for IdentitySelection { #[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 the file - /// each names within the project once links are followed. Anything else - /// is refused. Reading is not restricted + /// 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, } diff --git a/crates/icp-cli/tests/untrusted_tests.rs b/crates/icp-cli/tests/untrusted_tests.rs index 7574a98f1..4711fb876 100644 --- a/crates/icp-cli/tests/untrusted_tests.rs +++ b/crates/icp-cli/tests/untrusted_tests.rs @@ -226,6 +226,51 @@ fn a_module_linked_out_of_the_project_is_refused_and_one_linked_within_is_not() .success(); } +/// What a step names must be a regular file: a directory is refused, and on +/// unix so is a named pipe, before the build would try to read it and wait +/// on a writer that never comes. +#[test] +fn a_module_that_is_not_a_regular_file_is_refused_as_untrusted() { + let ctx = TestContext::new(); + let project_dir = project( + &ctx, + indoc! {r#" + canisters: + - name: backend + build: + steps: + - type: pre-built + path: module.wasm + "#}, + ); + let module = project_dir.join("module.wasm"); + + std::fs::create_dir(&module).expect("directory"); + ctx.icp() + .current_dir(&project_dir) + .args(["build", "--untrusted"]) + .assert() + .failure() + .stderr(contains("module.wasm").and(contains("not a regular file"))); + + #[cfg(unix)] + { + std::fs::remove_dir(&module).expect("remove directory"); + let made = std::process::Command::new("mkfifo") + .arg(&module) + .status() + .expect("mkfifo"); + assert!(made.success(), "mkfifo"); + ctx.icp() + .current_dir(&project_dir) + .args(["build", "--untrusted"]) + .timeout(std::time::Duration::from_secs(60)) + .assert() + .failure() + .stderr(contains("module.wasm").and(contains("not a regular file"))); + } +} + /// 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() { diff --git a/crates/icp-project/src/untrusted.rs b/crates/icp-project/src/untrusted.rs index 299ebf81e..e2189eb9d 100644 --- a/crates/icp-project/src/untrusted.rs +++ b/crates/icp-project/src/untrusted.rs @@ -13,9 +13,10 @@ //! //! - a build step is a pre-built module ([`build_step_allowed`]); //! - a sync step is a plugin ([`sync_step_allowed`]); -//! - the module or plugin a step names is a file within the project: its -//! path, with every link followed and every `..` applied, lies under the -//! project root resolved the same way ([`vet`]). +//! - the module or plugin a step 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, and what is there is +//! a file rather than a directory or a pipe ([`vet`]). //! //! A recipe is read and rendered as the project loads, and the steps it //! produces are on the list or not like any other. A module or plugin named @@ -74,7 +75,17 @@ pub enum Refused { root: PathBuf, }, - #[snafu(display("canister '{canister}' names '{path}', which does not resolve to a file"))] + #[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"))] @@ -96,13 +107,17 @@ pub fn sync_step_allowed(step: &SyncStep) -> bool { } /// Hold a loaded project to the list: every step [`build_step_allowed`] or -/// [`sync_step_allowed`], and the file each names within the project. +/// [`sync_step_allowed`], and the file each names a regular file within the +/// project. /// /// Within is decided on the real paths: the file's and the root's, each with /// every link followed and every `..` applied by `files`, so a link out of /// the project is seen for where it leads and a link within it is let be. A /// path that does not resolve is refused too, since nothing can be said -/// about where it would lead. +/// about where it would lead. So is one that resolves to something other +/// than a regular file: a directory cannot be read as a module, and a pipe +/// is read from whoever is writing to it, which is not the project — and +/// a read of one that nobody writes to waits forever. pub async fn vet(project: &Project, files: &dyn FileSystem) -> Result<(), Refused> { let root = files .canonicalize(&project.dir) @@ -146,7 +161,8 @@ pub async fn vet(project: &Project, files: &dyn FileSystem) -> Result<(), Refuse Ok(()) } -/// Refuse `path` unless it resolves to somewhere under the resolved `root`. +/// Refuse `path` unless it resolves to a regular file under the resolved +/// `root`. async fn within( files: &dyn FileSystem, root: &Path, @@ -169,6 +185,14 @@ async fn within( } .fail(); } + if !files.is_file(&resolved).await { + return NotAFileSnafu { + canister: &canister.name, + path, + resolved, + } + .fail(); + } Ok(()) } @@ -222,7 +246,7 @@ impl ProjectLoad for Vetted { #[cfg(test)] mod tests { - use std::collections::HashMap; + use std::collections::{HashMap, HashSet}; use super::*; use crate::{ @@ -235,14 +259,21 @@ mod tests { }; /// A filesystem that resolves the paths it is told to and nothing else, - /// standing in for links and `..` on disk: the vet asks for nothing but - /// where a path really is. - struct Resolves(HashMap); + /// standing in for links and `..` on disk, and says which of the places + /// they resolve to hold a regular file: 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.0.get(path).cloned() + self.paths.get(path).cloned() + } + async fn is_file(&self, path: &Path) -> bool { + self.files.contains(path) } async fn read(&self, _: &Path) -> Result, FsError> { unreachable!("the vet reads nothing") @@ -262,9 +293,6 @@ mod tests { async fn exists(&self, _: &Path) -> bool { unreachable!("the vet looks at nothing but where a path is") } - async fn is_file(&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") } @@ -278,22 +306,31 @@ mod tests { /// 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, and a climb that lands outside. + /// within to the module, a climb that lands outside, and a directory + /// `dir` of its own. fn disk() -> Arc { - Arc::new(Resolves(HashMap::from([ - ("/project".into(), "/project".into()), - ( + 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/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(), + "/project/p.wasm".into(), "/elsewhere/secret.wasm".into(), - ), - ]))) + ]), + }) } async fn project_with(build: Vec, sync: Vec) -> Project { @@ -435,6 +472,24 @@ mod tests { 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; @@ -452,7 +507,9 @@ mod tests { /// 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. + /// 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() { @@ -474,6 +531,15 @@ mod tests { 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(); @@ -499,5 +565,22 @@ mod tests { "{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}" + ); + } } } diff --git a/docs/reference/cli.md b/docs/reference/cli.md index fe50b4796..732a082ee 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -136,7 +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 the file each names within the project once links are followed. Anything else is refused. Reading is not restricted +* `--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 @@ -1072,7 +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 the file each names within the project once links are followed. Anything else is refused. Reading is not restricted +* `--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 @@ -1921,7 +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 the file each names within the project once links are followed. Anything else is refused. Reading is not restricted +* `--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 From 602c8a34d6e26b6372b1aba0fd092a241a219fb6 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Tue, 10 Nov 2026 00:31:07 +0000 Subject: [PATCH 21/24] fix(untrusted): ask what a step names is of the path as spelled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vet judged where a module leads on its resolved path, and then asked the filesystem what was there by that same resolved path. A filesystem that knows paths by how its caller spells them — `Confined`, with the project reached through a link, as a macOS temp dir is through `/var` — resolves the module to a place not under that spelling and so has no answer, and a module within the project was refused as not a file. The CLI composes the vet over the host filesystem, where the two spellings answer alike, so nothing shipped was affected; an embedder composing it over a confined one would have been. Where a path leads is still judged resolved; what is there is now asked of the path as spelled, which a link is followed from either way. The test double follows a path to where it resolves before answering, and an on-disk test holds a root spelled through a link to accepting its own module and still refusing a directory. Co-Authored-By: Claude Fable 5.1 --- crates/icp-project/src/untrusted.rs | 52 ++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/crates/icp-project/src/untrusted.rs b/crates/icp-project/src/untrusted.rs index e2189eb9d..f15244632 100644 --- a/crates/icp-project/src/untrusted.rs +++ b/crates/icp-project/src/untrusted.rs @@ -163,6 +163,12 @@ pub async fn vet(project: &Project, files: &dyn FileSystem) -> Result<(), Refuse /// Refuse `path` unless it resolves to a regular file under the resolved /// `root`. +/// +/// Where the path leads is judged on the resolved path; what is there is +/// asked of the path as spelled. The two answers agree, since a link is +/// followed either way, and a filesystem that knows paths by how its +/// caller spells them — one confined to a root reached through a link — +/// has no answer for the resolved one. async fn within( files: &dyn FileSystem, root: &Path, @@ -185,7 +191,7 @@ async fn within( } .fail(); } - if !files.is_file(&resolved).await { + if !files.is_file(path).await { return NotAFileSnafu { canister: &canister.name, path, @@ -260,8 +266,9 @@ mod tests { /// 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: the vet asks for nothing but - /// where a path really is and what is there. + /// 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, @@ -273,7 +280,9 @@ mod tests { self.paths.get(path).cloned() } async fn is_file(&self, path: &Path) -> bool { - self.files.contains(path) + self.paths + .get(path) + .is_some_and(|resolved| self.files.contains(resolved)) } async fn read(&self, _: &Path) -> Result, FsError> { unreachable!("the vet reads nothing") @@ -583,4 +592,39 @@ mod tests { ); } } + + /// 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}"); + } } From 85852bfe70203f8274669842fe956594838cd317 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Tue, 10 Nov 2026 01:03:56 +0000 Subject: [PATCH 22/24] refactor(untrusted): one match per step kind, and less said twice vet asked build_step_allowed and then matched the step again with an unreachable arm; local_module and local_plugin now answer both at once, and match every kind so a new one must be decided on. init.rs keeps to the change it needs. The CLI tests drop the cases the unit tests already hold on disk (a directory, a pipe, a link within). Co-Authored-By: Claude --- CHANGELOG.md | 2 +- crates/icp-app/src/context/init.rs | 20 ++-- crates/icp-cli/tests/untrusted_tests.rs | 58 +--------- crates/icp-project/src/untrusted.rs | 135 ++++++++++-------------- 4 files changed, 65 insertions(+), 150 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81c293aef..6afc5400f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ air-gapped signing, extensions # Unreleased -* feat: `--untrusted`, on `build`, `deploy` and `sync`, holds the project to what one someone else handed over may run — a bundle an agent produced, a checkout nobody has read. The list is stated as what is allowed, so that a kind of step added later is refused until someone decides otherwise: each build step is a pre-built module, each sync step is a plugin, and what each names is a regular file within the project, judged by where it really is once every link is followed and every `..` applied. 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. Without the flag nothing changes: the user's own project may still run scripts and take its modules from anywhere. +* 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 b340dc998..a39edb627 100644 --- a/crates/icp-app/src/context/init.rs +++ b/crates/icp-app/src/context/init.rs @@ -12,10 +12,7 @@ use icp_project::store_artifact::ArtifactStore; use std::time::Duration; use crate::{agent, identity, identity::PasswordFunc}; -use icp_project::{ - Lazy, ProjectLoad, ProjectLoadImpl, files::HostFileSystem, host::Host, manifest, store_id, - untrusted, -}; +use icp_project::{Lazy, ProjectLoad, ProjectLoadImpl, host::Host, manifest, store_id, untrusted}; #[derive(Debug, Snafu)] pub enum ContextInitError { @@ -41,11 +38,8 @@ pub enum ContextInitError { }, } -/// `untrusted` holds the project to what one someone else handed over may -/// run — see [`icp_project::untrusted`] for the list: the loaded project is -/// vetted for its steps and for where the file each names resolves, before -/// any command acts on it. Reading is not restricted, so the project is -/// loaded through the same filesystem and fetchers as the user's own. +/// 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, @@ -103,21 +97,21 @@ pub fn initialize( // holder. let pkg_cache = Arc::new(dirs.package_cache().context(PackageCacheSnafu)?); - // The project's files come from this machine - let files = Arc::new(HostFileSystem); - // Wasm modules named by a manifest but not contained in the project let wasm = Arc::new(crate::wasm::Fetcher::new( http_client.clone(), pkg_cache.clone(), )); - // Recipes, fetched from wherever the manifest points + // Recipes let recipe = Arc::new(RecipeFetcher { http_client, pkg_cache, }); + // The project's files come from this machine. + let files = Arc::new(icp_project::files::HostFileSystem); + // Canister builder let builder = Arc::new(Builder::new(wasm.clone(), files.clone())); diff --git a/crates/icp-cli/tests/untrusted_tests.rs b/crates/icp-cli/tests/untrusted_tests.rs index 4711fb876..7d447f75f 100644 --- a/crates/icp-cli/tests/untrusted_tests.rs +++ b/crates/icp-cli/tests/untrusted_tests.rs @@ -179,11 +179,10 @@ fn a_module_outside_the_project_is_refused_as_untrusted() { ); } -/// A link is judged by where it leads: out of the project is refused, within -/// it is fine. +/// 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_and_one_linked_within_is_not() { +fn a_module_linked_out_of_the_project_is_refused_as_untrusted() { let ctx = TestContext::new(); let project_dir = project( &ctx, @@ -216,59 +215,6 @@ fn a_module_linked_out_of_the_project_is_refused_and_one_linked_within_is_not() .and(contains("outside.wasm")) .and(contains("outside the project")), ); - - std::fs::remove_file(&link).expect("remove link"); - std::os::unix::fs::symlink(project_dir.join("backend.wasm"), &link).expect("symlink within"); - ctx.icp() - .current_dir(&project_dir) - .args(["build", "--untrusted"]) - .assert() - .success(); -} - -/// What a step names must be a regular file: a directory is refused, and on -/// unix so is a named pipe, before the build would try to read it and wait -/// on a writer that never comes. -#[test] -fn a_module_that_is_not_a_regular_file_is_refused_as_untrusted() { - let ctx = TestContext::new(); - let project_dir = project( - &ctx, - indoc! {r#" - canisters: - - name: backend - build: - steps: - - type: pre-built - path: module.wasm - "#}, - ); - let module = project_dir.join("module.wasm"); - - std::fs::create_dir(&module).expect("directory"); - ctx.icp() - .current_dir(&project_dir) - .args(["build", "--untrusted"]) - .assert() - .failure() - .stderr(contains("module.wasm").and(contains("not a regular file"))); - - #[cfg(unix)] - { - std::fs::remove_dir(&module).expect("remove directory"); - let made = std::process::Command::new("mkfifo") - .arg(&module) - .status() - .expect("mkfifo"); - assert!(made.success(), "mkfifo"); - ctx.icp() - .current_dir(&project_dir) - .args(["build", "--untrusted"]) - .timeout(std::time::Duration::from_secs(60)) - .assert() - .failure() - .stderr(contains("module.wasm").and(contains("not a regular file"))); - } } /// Where the file really is decides, so the root may be spelled relatively. diff --git a/crates/icp-project/src/untrusted.rs b/crates/icp-project/src/untrusted.rs index f15244632..d87edc920 100644 --- a/crates/icp-project/src/untrusted.rs +++ b/crates/icp-project/src/untrusted.rs @@ -5,34 +5,21 @@ //! 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: the loader reads argument files, environment -//! files, manifests and recipe templates from wherever the manifest points, -//! and a plugin reads the files it declares, through the ordinary filesystem. -//! 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: +//! 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 ([`build_step_allowed`]); -//! - a sync step is a plugin ([`sync_step_allowed`]); -//! - the module or plugin a step 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, and what is there is -//! a file rather than a directory or a pipe ([`vet`]). +//! - 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 recipe is read and rendered as the project loads, and the steps it -//! produces are on the list or not like any other. A module or plugin named -//! by URL is not on it: a download at build time is what a bundle has already -//! done. The loaded [`Project`] shows every step, so the list is applied to -//! it: [`vet`] holds a project to it, and [`Vetted`] wraps a loader so that -//! every load is vetted before the project is handed out. A consumer -//! composing its own host wraps its loader the same way: -//! -//! ```ignore -//! let project = Vetted::new(ProjectLoadImpl { project_root_locate, recipe, files: files.clone() }, files); -//! ``` -//! -//! A checksum on a module is not asked for. It is the bundler's promise about -//! its own archive, and the same untrusted hand wrote both, so it says nothing -//! about what the project runs. +//! 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; @@ -43,7 +30,7 @@ use crate::{ Project, ProjectLoad, ProjectLoadError, UntrustedSnafu, files::FileSystem, manifest::{ - adapter::prebuilt::SourceField, + adapter::prebuilt::{LocalSource, SourceField}, canister::{BuildStep, SyncStep}, }, prelude::*, @@ -92,32 +79,37 @@ pub enum Refused { UnresolvedRoot { root: PathBuf }, } -/// Whether `step` is the one build step an untrusted project may have: a -/// pre-built module from a file. A script is a host subprocess, and a module -/// fetched from a URL is a download on the strength of an untrusted manifest. -pub fn build_step_allowed(step: &BuildStep) -> bool { - matches!(step, BuildStep::Prebuilt(adapter) if matches!(adapter.source, SourceField::Local(_))) +/// 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, + } } -/// Whether `step` is the one sync step an untrusted project may have: a -/// plugin from a file. It runs sandboxed either way; it is a script and a -/// download that are not on the list. -pub fn sync_step_allowed(step: &SyncStep) -> bool { - matches!(step, SyncStep::Plugin(adapter) if matches!(adapter.source, SourceField::Local(_))) +fn local_source(source: &SourceField) -> Option<&LocalSource> { + match source { + SourceField::Local(local) => Some(local), + SourceField::Remote(_) => None, + } } -/// Hold a loaded project to the list: every step [`build_step_allowed`] or -/// [`sync_step_allowed`], and the file each names a regular file within the -/// project. +/// Hold a loaded project to the list. /// -/// Within is decided on the real paths: the file's and the root's, each with -/// every link followed and every `..` applied by `files`, so a link out of -/// the project is seen for where it leads and a link within it is let be. A -/// path that does not resolve is refused too, since nothing can be said -/// about where it would lead. So is one that resolves to something other -/// than a regular file: a directory cannot be read as a module, and a pipe -/// is read from whoever is writing to it, which is not the project — and -/// a read of one that nobody writes to waits forever. +/// 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) @@ -126,34 +118,22 @@ pub async fn vet(project: &Project, files: &dyn FileSystem) -> Result<(), Refuse for (_, (canister_dir, canister)) in &project.canisters { for step in &canister.build.steps { - let local = match step { - BuildStep::Prebuilt(adapter) if build_step_allowed(step) => match &adapter.source { - SourceField::Local(local) => local, - SourceField::Remote(_) => unreachable!("allowed is local"), - }, - _ => { - return BuildStepSnafu { - canister: &canister.name, - step: one_line(step), - } - .fail(); + 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 local = match step { - SyncStep::Plugin(adapter) if sync_step_allowed(step) => match &adapter.source { - SourceField::Local(local) => local, - SourceField::Remote(_) => unreachable!("allowed is local"), - }, - _ => { - return SyncStepSnafu { - canister: &canister.name, - step: one_line(step), - } - .fail(); + 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?; } @@ -162,13 +142,9 @@ pub async fn vet(project: &Project, files: &dyn FileSystem) -> Result<(), Refuse } /// Refuse `path` unless it resolves to a regular file under the resolved -/// `root`. -/// -/// Where the path leads is judged on the resolved path; what is there is -/// asked of the path as spelled. The two answers agree, since a link is -/// followed either way, and a filesystem that knows paths by how its -/// caller spells them — one confined to a root reached through a link — -/// has no answer for the resolved one. +/// `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, @@ -202,9 +178,8 @@ async fn within( Ok(()) } -/// A step as it displays, on one line: a step displays its kind and then -/// its fields or commands one per line, and an error message has room for -/// one, so the lines after the first are run together with `;` between. +/// 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()); From dd69aceafe18576ba04fae056f2b1917e8844d41 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Tue, 10 Nov 2026 13:32:50 +0000 Subject: [PATCH 23/24] feat(untrusted): read an untrusted project through a filesystem confined to its root --untrusted held a project to what it may run and let it read anywhere. It now also loads and builds through files::Confined at the project root, so a path a manifest names must be under the root and reached through no symlink: argument and environment files, local recipe templates, which the recipe fetcher now reads through the project's filesystem, and the module or plugin a step names, which vet asks the same filesystem for. A relative root override is anchored at the working directory, since Confined takes an absolute root. Co-Authored-By: Claude --- CHANGELOG.md | 2 +- crates/icp-app/src/context/init.rs | 38 ++- crates/icp-app/src/recipe.rs | 9 +- crates/icp-cli/src/options.rs | 8 +- crates/icp-cli/tests/untrusted_tests.rs | 103 +++++-- crates/icp-project/src/untrusted.rs | 384 +++++++----------------- docs/reference/cli.md | 6 +- 7 files changed, 226 insertions(+), 324 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6afc5400f..fc4e56a07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +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. +* feat: `--untrusted`, on `build`, `deploy` and `sync`, holds a project someone else handed over to what it may read and run. Every file the project reads — manifests, argument and environment files, local recipe templates, modules — must be under the project root and reached through no symlink. Each build step must be a pre-built module and each sync step a plugin, taken from a regular file in the project; the steps a recipe renders to are held to the same list. Anything else is refused before the command acts on it, naming what was found. * 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 a39edb627..626e7a8c4 100644 --- a/crates/icp-app/src/context/init.rs +++ b/crates/icp-app/src/context/init.rs @@ -12,7 +12,13 @@ use icp_project::store_artifact::ArtifactStore; use std::time::Duration; use crate::{agent, identity, identity::PasswordFunc}; -use icp_project::{Lazy, ProjectLoad, ProjectLoadImpl, host::Host, manifest, store_id, untrusted}; +use icp_project::{ + Lazy, ProjectLoad, ProjectLoadImpl, + files::{Confined, FileSystem, HostFileSystem, RootNotAbsolute}, + host::Host, + manifest::{self, ProjectRootLocate as _}, + store_id, untrusted, +}; #[derive(Debug, Snafu)] pub enum ContextInitError { @@ -36,10 +42,19 @@ pub enum ContextInitError { PackageCache { source: icp_project::fs::lock::LockError, }, + + #[snafu(display("failed to locate the project to treat as untrusted"))] + UntrustedRoot { + source: manifest::ProjectRootLocateError, + }, + + #[snafu(display("failed to confine the untrusted project to its directory"))] + Confine { source: RootNotAbsolute }, } -/// With `untrusted`, the loaded project is vetted by [`icp_project::untrusted`] -/// before any command acts on it. +/// With `untrusted`, the project is read through a filesystem confined to +/// its root and vetted by [`icp_project::untrusted`] before any command acts +/// on it. The root is located up front for that, so there has to be a project. pub fn initialize( project_root_override: Option, debug: bool, @@ -78,6 +93,10 @@ pub fn initialize( let cwd: PathBuf = PathBuf::try_from(current_dir().context(CwdSnafu)?).context(Utf8PathSnafu)?; + // A relative override is taken from the working directory: the + // filesystem an untrusted project is confined to matches every path + // against the root as spelled, so the root has to be a whole path. + let project_root_override = project_root_override.map(|dir| cwd.join(dir)); let project_root_locate = Arc::new(manifest::ProjectRootLocateImpl::new( cwd, project_root_override, @@ -103,15 +122,22 @@ pub fn initialize( pkg_cache.clone(), )); + // The project's files come from this machine: the whole of it for the + // user's own project, the project directory alone for an untrusted one. + let files: Arc = if untrusted { + let root = project_root_locate.locate().context(UntrustedRootSnafu)?; + Arc::new(Confined::new(&root).context(ConfineSnafu)?) + } else { + Arc::new(HostFileSystem) + }; + // Recipes let recipe = Arc::new(RecipeFetcher { http_client, pkg_cache, + files: files.clone(), }); - // The project's files come from this machine. - let files = Arc::new(icp_project::files::HostFileSystem); - // Canister builder let builder = Arc::new(Builder::new(wasm.clone(), files.clone())); diff --git a/crates/icp-app/src/recipe.rs b/crates/icp-app/src/recipe.rs index 37b278e91..3bc3b1571 100644 --- a/crates/icp-app/src/recipe.rs +++ b/crates/icp-app/src/recipe.rs @@ -14,7 +14,7 @@ use crate::package::{ read_cached_uri_recipe, }; use icp_project::{ - fs::read, + files::{FileSystem, FsError}, manifest::recipe::{Recipe, RecipeType}, prelude::*, }; @@ -30,6 +30,8 @@ pub struct RecipeFetcher { pub http_client: reqwest::Client, /// Package cache for caching downloaded recipe templates pub pkg_cache: std::sync::Arc, + /// The project's files, which a local template is read through + pub files: std::sync::Arc, } /// The result of the fetch stage, as this resolver sees it. @@ -76,7 +78,7 @@ enum TemplateSource { #[derive(Debug, Snafu)] pub enum RecipeFetchError { #[snafu(display("failed to read local recipe template file"))] - ReadFile { source: icp_project::fs::IoError }, + ReadFile { source: FsError }, #[snafu(display("failed to decode UTF-8 string"))] DecodeUtf8 { source: FromUtf8Error }, @@ -133,7 +135,7 @@ impl RecipeFetcher { // Retrieve the template, using cache for remote/registry sources let (tmpl, should_cache) = match &tmpl_source { TemplateSource::LocalPath(path) => { - let bytes = read(path).context(ReadFileSnafu)?; + let bytes = self.files.read(path).await.context(ReadFileSnafu)?; (parse_bytes_to_string(bytes)?, false) } @@ -360,6 +362,7 @@ mod tests { RecipeFetcher { http_client: reqwest::Client::new(), pkg_cache: std::sync::Arc::new(crate::package::open(cache_dir.to_owned()).unwrap()), + files: std::sync::Arc::new(icp_project::files::HostFileSystem), } } diff --git a/crates/icp-cli/src/options.rs b/crates/icp-cli/src/options.rs index a691ba1af..5a477a3d5 100644 --- a/crates/icp-cli/src/options.rs +++ b/crates/icp-cli/src/options.rs @@ -45,10 +45,10 @@ impl From for IdentitySelection { /// 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 + /// Hold the project to what one someone else handed over may do: every + /// file it reads is under the project root and reached through no + /// symlink, each build step is a pre-built module and each sync step a + /// plugin. Anything else is refused #[arg(long)] pub(crate) untrusted: bool, } diff --git a/crates/icp-cli/tests/untrusted_tests.rs b/crates/icp-cli/tests/untrusted_tests.rs index 7d447f75f..db674bb67 100644 --- a/crates/icp-cli/tests/untrusted_tests.rs +++ b/crates/icp-cli/tests/untrusted_tests.rs @@ -1,7 +1,7 @@ -//! `--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. +//! `--untrusted` holds a project to what one from elsewhere may read and +//! 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. use indoc::indoc; use predicates::{prelude::PredicateBooleanExt, str::contains}; @@ -119,10 +119,10 @@ fn a_recipe_is_rendered_and_its_steps_held_to_the_list() { .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. +/// What the project reads is held to its root: an argument file outside it +/// is read for the user's own project and refused for an untrusted one. #[test] -fn reading_outside_the_project_is_not_restricted() { +fn a_file_read_from_outside_the_project_is_refused_as_untrusted() { let ctx = TestContext::new(); let project_dir = project( &ctx, @@ -141,9 +141,43 @@ fn reading_outside_the_project_is_not_restricted() { ctx.icp() .current_dir(&project_dir) - .args(["build", "--untrusted"]) + .args(["build"]) .assert() .success(); + + ctx.icp() + .current_dir(&project_dir) + .args(["build", "--untrusted"]) + .assert() + .failure() + .stderr(contains("outside.bin").and(contains("is outside"))); +} + +/// A local recipe template is read through the same filesystem. +#[test] +fn a_recipe_template_outside_the_project_is_refused_as_untrusted() { + let ctx = TestContext::new(); + let project_dir = project( + &ctx, + indoc! {r#" + canisters: + - name: backend + recipe: + type: file://../recipe.hbs + "#}, + ); + std::fs::write( + project_dir.parent().unwrap().join("recipe.hbs"), + "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() + .failure() + .stderr(contains("recipe.hbs").and(contains("is outside"))); } #[test] @@ -175,49 +209,66 @@ fn a_module_outside_the_project_is_refused_as_untrusted() { .stderr( contains("names '") .and(contains("outside.wasm")) - .and(contains("outside the project")), + .and(contains("not a regular file in the project")), ); } -/// A link is judged by where it leads: out of the project is refused. +/// A symlink is refused wherever it leads, out of the project or within it: +/// as the module a step names, and as a file the project reads. #[cfg(unix)] #[test] -fn a_module_linked_out_of_the_project_is_refused_as_untrusted() { +fn a_symlink_is_refused_as_untrusted_wherever_it_leads() { let ctx = TestContext::new(); let project_dir = project( &ctx, indoc! {r#" canisters: - name: backend + init_args: + path: args.bin + format: bin build: steps: - type: pre-built path: linked.wasm "#}, ); - let link = project_dir.join("linked.wasm"); + let module = project_dir.join("linked.wasm"); + let args = project_dir.join("args.bin"); + let outside = project_dir.parent().unwrap().to_path_buf(); + std::fs::write(&args, b"args").expect("write args"); - 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(); + for target in [ + outside.join("outside.wasm"), + project_dir.join("backend.wasm"), + ] { + std::os::unix::fs::symlink(&target, &module).expect("symlink"); + 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("not a regular file in the project"))); + std::fs::remove_file(&module).expect("remove link"); + } + + std::fs::copy(project_dir.join("backend.wasm"), &module).expect("copy module"); + std::fs::remove_file(&args).expect("remove args"); + std::os::unix::fs::symlink(outside.join("outside.bin"), &args).expect("symlink"); 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")), - ); + .stderr(contains("args.bin").and(contains("is a symlink"))); } -/// Where the file really is decides, so the root may be spelled relatively. +/// The root may be spelled relatively: it is taken from the working directory. #[test] fn a_relative_project_root_is_taken_from_the_working_directory() { let ctx = TestContext::new(); diff --git a/crates/icp-project/src/untrusted.rs b/crates/icp-project/src/untrusted.rs index d87edc920..6a3b51c94 100644 --- a/crates/icp-project/src/untrusted.rs +++ b/crates/icp-project/src/untrusted.rs @@ -1,19 +1,18 @@ -//! Holding a project someone else handed over to what it may run. +//! Holding a project someone else handed over to what it may read and 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: +//! build step may run any command, a sync step any script, and a manifest +//! may name any path 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. Two things hold it: //! -//! - 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. +//! - what it reads goes through a filesystem confined to the project root — +//! on a host, [`Confined`](crate::files::Confined) — so that every path a +//! manifest names is under the root and reached through no symlink; +//! - what it runs is on 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, a sync step is a plugin, and the file either +//! names is a regular file that filesystem will give. //! //! 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 @@ -24,7 +23,7 @@ use std::sync::Arc; use async_trait::async_trait; -use snafu::{ResultExt, Snafu}; +use snafu::{ResultExt, Snafu, ensure}; use crate::{ Project, ProjectLoad, ProjectLoadError, UntrustedSnafu, @@ -52,31 +51,10 @@ pub enum Refused { 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" + "canister '{canister}' names '{path}', which is not a regular file in the project: an \ + untrusted project runs only a file under its root, reached through no symlink" ))] - 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 }, + NotAFile { canister: String, path: PathBuf }, } /// The file `step` names, if it is the one build step an untrusted project @@ -106,74 +84,41 @@ fn local_source(source: &SourceField) -> Option<&LocalSource> { /// 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. +/// `files` is the filesystem the project is confined to, and it is what +/// says whether the file a step names is one: under the root, through no +/// symlink, and a regular file. A plugin is opened by its runtime rather +/// than read through `files`, and a read of a pipe nobody writes to waits +/// forever, so the question is asked here, before anything opens the file. 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 { + let build = canister.build.steps.iter().map(|step| { + local_module(step).ok_or_else(|| { + 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 { + .build() + }) + }); + let sync = canister.sync.steps.iter().map(|step| { + local_plugin(step).ok_or_else(|| { + 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, + .build() + }) + }); + for local in build.chain(sync) { + let path = canister_dir.join(&local?.path); + ensure!( + files.is_file(&path).await, + NotAFileSnafu { + canister: &canister.name, + path, + } + ); } - .fail(); } Ok(()) } @@ -227,7 +172,7 @@ impl ProjectLoad for Vetted { #[cfg(test)] mod tests { - use std::collections::{HashMap, HashSet}; + use std::collections::HashSet; use super::*; use crate::{ @@ -239,25 +184,14 @@ mod tests { }, }; - /// 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, - } + /// A filesystem that knows which paths are regular files, which is all + /// the vet asks of one. + struct Disk(HashSet); #[async_trait] - impl FileSystem for Resolves { - async fn canonicalize(&self, path: &Path) -> Option { - self.paths.get(path).cloned() - } + impl FileSystem for Disk { async fn is_file(&self, path: &Path) -> bool { - self.paths - .get(path) - .is_some_and(|resolved| self.files.contains(resolved)) + self.0.contains(path) } async fn read(&self, _: &Path) -> Result, FsError> { unreachable!("the vet reads nothing") @@ -275,46 +209,29 @@ mod tests { unreachable!("the vet writes nothing") } async fn exists(&self, _: &Path) -> bool { - unreachable!("the vet looks at nothing but where a path is") + unreachable!("the vet asks only for a regular file") } async fn is_dir(&self, _: &Path) -> bool { - unreachable!("the vet looks at nothing but where a path is") + unreachable!("the vet asks only for a regular file") } async fn read_dir(&self, _: &Path) -> Result, FsError> { unreachable!("the vet reads nothing") } + async fn canonicalize(&self, _: &Path) -> Option { + unreachable!("the vet asks only for a regular file") + } 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(), - ]), - }) + /// and a plugin. + fn disk() -> Arc { + Arc::new(Disk(HashSet::from([ + "/project/backend.wasm".into(), + "/project/p.wasm".into(), + ]))) } async fn project_with(build: Vec, sync: Vec) -> Project { @@ -422,184 +339,89 @@ mod tests { 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}"); + 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 err = vetted(vec![local("missing.wasm")], vec![]) + let loader = Vetted::new(MockProjectLoader::minimal(), disk()); + loader + .load() .await - .unwrap_err(); - assert!(matches!(err, Refused::UnresolvedPath { .. }), "{err}"); - assert!(err.to_string().contains("missing.wasm"), "{err}"); + .expect("the minimal project is pre-built from its own file"); } - /// 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. + /// What a step names must be a regular file the filesystem will give, + /// for a module and for a plugin alike. #[tokio::test] async fn what_a_step_names_must_be_a_regular_file() { - let err = vetted(vec![local("dir")], vec![]).await.unwrap_err(); + let err = vetted(vec![local("missing.wasm")], vec![]) + .await + .unwrap_err(); assert!( - matches!(err, Refused::NotAFile { ref resolved, .. } if resolved == "/project/dir"), + matches!(err, Refused::NotAFile { ref path, .. } if path == "/project/missing.wasm"), "{err}" ); assert!(err.to_string().contains("not a regular file"), "{err}"); - let err = vetted(vec![local("backend.wasm")], vec![plugin("path: dir")]) + let err = vetted(vec![local("backend.wasm")], vec![plugin("path: gone.wasm")]) .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. + /// On a real disk, through the filesystem the CLI confines an untrusted + /// project to: a module of the project's own is accepted, with the root + /// spelled through a link as a macOS temp dir is, and one outside the + /// root, behind a link wherever it leads, or not a regular file is + /// 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; + async fn on_disk_a_step_names_a_regular_file_under_the_root_through_no_link() { + use crate::files::Confined; use std::os::unix::fs::symlink; let dir = camino_tempfile::tempdir().unwrap(); - let root = dir.path().join("project"); - std::fs::create_dir(&root).unwrap(); + let real = dir.path().join("project"); + std::fs::create_dir(&real).unwrap(); + let root = dir.path().join("alias"); + symlink(&real, &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().join("secret.wasm"), root.join("out.wasm")).unwrap(); 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 files = Confined::new(&root).unwrap(); - let mut project = project_with(vec![], vec![]).await; + let mut project = project_with(vec![local("backend.wasm")], 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"), + vet(&project, &files) + .await + .expect("the project's own module"); + + for path in [ + "../secret.wasm", + "in.wasm", + "out.wasm", + "outdir/secret.wasm", + "dir.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}" - ); + let err = vet(&project, &files).await.unwrap_err(); + assert!(matches!(err, Refused::NotAFile { .. }), "{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 732a082ee..53f370922 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -136,7 +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 +* `--untrusted` — Hold the project to what one someone else handed over may do: every file it reads is under the project root and reached through no symlink, each build step is a pre-built module and each sync step a plugin. Anything else is refused @@ -1072,7 +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 +* `--untrusted` — Hold the project to what one someone else handed over may do: every file it reads is under the project root and reached through no symlink, each build step is a pre-built module and each sync step a plugin. Anything else is refused @@ -1921,7 +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 +* `--untrusted` — Hold the project to what one someone else handed over may do: every file it reads is under the project root and reached through no symlink, each build step is a pre-built module and each sync step a plugin. Anything else is refused From a67e243ce027ecb78b64f4d2819bf9edf87b0e0c Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Tue, 10 Nov 2026 14:48:25 +0000 Subject: [PATCH 24/24] feat(untrusted): hold what the CLI keeps under .icp to the confined filesystem The id store, the artifact store and a network's directory were written and read on the host directly, so a symlink planted under .icp in a handed-over tree sent a built module, an id mapping or a truncating lock file wherever it pointed. Each now takes the Confined an untrusted project is held to and resolves its paths through it first. Co-Authored-By: Claude --- CHANGELOG.md | 2 +- crates/icp-app/src/context/init.rs | 28 ++++++-- crates/icp-app/src/network/accessor.rs | 18 ++++- crates/icp-app/src/network/directory.rs | 15 ++++ crates/icp-cli/tests/untrusted_tests.rs | 54 ++++++++++++++ crates/icp-project/src/store_artifact.rs | 91 +++++++++++++++++++++--- crates/icp-project/src/store_id.rs | 76 +++++++++++++++++++- crates/icp-project/src/untrusted.rs | 3 +- 8 files changed, 267 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc4e56a07..309183e04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +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 read and run. Every file the project reads — manifests, argument and environment files, local recipe templates, modules — must be under the project root and reached through no symlink. Each build step must be a pre-built module and each sync step a plugin, taken from a regular file in the project; the steps a recipe renders to are held to the same list. Anything else is refused before the command acts on it, naming what was found. +* feat: `--untrusted`, on `build`, `deploy` and `sync`, holds a project someone else handed over to what it may read and run. Every file the project reads — manifests, argument and environment files, local recipe templates, modules — must be under the project root and reached through no symlink. Each build step must be a pre-built module and each sync step a plugin, taken from a regular file in the project; the steps a recipe renders to are held to the same list. Anything else is refused before the command acts on it, naming what was found. What the CLI keeps under `.icp` is held to the same rule, so a symlink planted there is refused rather than written through. * 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 626e7a8c4..b547b4964 100644 --- a/crates/icp-app/src/context/init.rs +++ b/crates/icp-app/src/context/init.rs @@ -102,11 +102,26 @@ pub fn initialize( project_root_override, )); + // An untrusted project is confined to its directory: what it reads, and + // what the CLI keeps under `.icp` on its behalf. + let confined = if untrusted { + let root = project_root_locate.locate().context(UntrustedRootSnafu)?; + Some(Confined::new(&root).context(ConfineSnafu)?) + } else { + None + }; + // Canister ID Store - let ids = Arc::new(store_id::AccessImpl::new(project_root_locate.clone())); + let ids = Arc::new(store_id::AccessImpl::new( + project_root_locate.clone(), + confined.clone(), + )); // Canister Artifact Store (wasm) - let artifacts = Arc::new(ArtifactStore::new(project_root_locate.clone())); + let artifacts = Arc::new(ArtifactStore::new( + project_root_locate.clone(), + confined.clone(), + )); // Prepare http client let http_client = reqwest::Client::new(); @@ -124,11 +139,9 @@ pub fn initialize( // The project's files come from this machine: the whole of it for the // user's own project, the project directory alone for an untrusted one. - let files: Arc = if untrusted { - let root = project_root_locate.locate().context(UntrustedRootSnafu)?; - Arc::new(Confined::new(&root).context(ConfineSnafu)?) - } else { - Arc::new(HostFileSystem) + let files: Arc = match &confined { + Some(confined) => Arc::new(confined.clone()), + None => Arc::new(HostFileSystem), }; // Recipes @@ -184,6 +197,7 @@ pub fn initialize( // Network accessor let netaccess = Arc::new(crate::network::Accessor { project_root_locate: project_root_locate.clone(), + confined, descriptors: dirs.port_descriptor(), agent: agent_creator.clone(), }); diff --git a/crates/icp-app/src/network/accessor.rs b/crates/icp-app/src/network/accessor.rs index a97d770b5..075a43ddf 100644 --- a/crates/icp-app/src/network/accessor.rs +++ b/crates/icp-app/src/network/accessor.rs @@ -8,6 +8,7 @@ use candid::Principal; use snafu::{ResultExt, Snafu}; use url::Url; +use icp_project::files::{Confined, FsError}; use icp_project::manifest::{ProjectRootLocate, ProjectRootLocateError}; use icp_project::network::{ Access, AccessError, CollectFriendlyDomains, Configuration, NetworkAccess, NetworkUrls, @@ -26,6 +27,9 @@ use crate::network::{ pub enum LocateNetworkDirectoryError { #[snafu(display("failed to find project root"))] ProjectRootLocate { source: ProjectRootLocateError }, + + #[snafu(display("network '{network}' keeps its state where an untrusted project may not"))] + Confine { source: FsError, network: String }, } /// Where a network keeps its on-disk state. @@ -57,6 +61,10 @@ pub struct Accessor { // Project root pub project_root_locate: Arc, + // What an untrusted project is confined to, which its network + // directories are then held to as well + pub confined: Option, + // Port descriptors dir pub descriptors: PathBuf, @@ -74,14 +82,20 @@ impl Accessor { .project_root_locate .locate() .context(ProjectRootLocateSnafu)?; - Ok(NetworkDirectory::new( + let nd = NetworkDirectory::new( &network.name, &dir.join(ICP_BASE) .join(CACHE_DIR) .join("networks") .join(&network.name), &self.descriptors, - )) + ); + if let Some(confined) = &self.confined { + nd.held_by(confined).context(ConfineSnafu { + network: &network.name, + })?; + } + Ok(nd) } } diff --git a/crates/icp-app/src/network/directory.rs b/crates/icp-app/src/network/directory.rs index 2018feec9..6344ea04b 100644 --- a/crates/icp-app/src/network/directory.rs +++ b/crates/icp-app/src/network/directory.rs @@ -168,6 +168,21 @@ impl NetworkDirectory { Ok(()) } + /// Refuse the files this directory is read through — its lock and its + /// descriptor — unless `confined`, the filesystem an untrusted project is + /// held to, would give them. + pub(crate) fn held_by( + &self, + confined: &icp_project::files::Confined, + ) -> Result<(), icp_project::files::FsError> { + let paths = NetworkRootPaths { + network_root: self.network_root.clone(), + }; + confined.resolve(&paths.lock_file())?; + confined.resolve(&paths.network_descriptor_path())?; + Ok(()) + } + /// Locked directory access for the local network root. pub fn root(&self) -> Result, LockError> { DirectoryStructureLock::open_or_create(NetworkRootPaths { diff --git a/crates/icp-cli/tests/untrusted_tests.rs b/crates/icp-cli/tests/untrusted_tests.rs index db674bb67..38587b163 100644 --- a/crates/icp-cli/tests/untrusted_tests.rs +++ b/crates/icp-cli/tests/untrusted_tests.rs @@ -268,6 +268,60 @@ fn a_symlink_is_refused_as_untrusted_wherever_it_leads() { .stderr(contains("args.bin").and(contains("is a symlink"))); } +/// A path that mentions a symlink is refused even where a `..` after it +/// would, by the spelling alone, step back to a file of the project's own: +/// the host follows the link and takes `..` from where it leads. +#[cfg(unix)] +#[test] +fn a_path_that_steps_back_out_of_a_symlink_is_refused_as_untrusted() { + let ctx = TestContext::new(); + let project_dir = project( + &ctx, + indoc! {r#" + canisters: + - name: backend + build: + steps: + - type: pre-built + path: jump/../backend.wasm + "#}, + ); + let elsewhere = project_dir.parent().unwrap().join("elsewhere/child"); + std::fs::create_dir_all(&elsewhere).expect("mkdir"); + std::os::unix::fs::symlink(&elsewhere, project_dir.join("jump")).expect("symlink"); + + ctx.icp() + .current_dir(&project_dir) + .args(["build", "--untrusted"]) + .assert() + .failure() + .stderr( + contains("jump/../backend.wasm").and(contains("not a regular file in the project")), + ); +} + +/// What the CLI keeps under `.icp` is held to the project too: a symlink +/// planted where the built module is stored is refused, not written through. +#[cfg(unix)] +#[test] +fn a_symlink_under_dot_icp_is_refused_as_untrusted() { + let ctx = TestContext::new(); + let project_dir = project(&ctx, PREBUILT); + let victim = project_dir.parent().unwrap().join("victim"); + std::fs::write(&victim, b"mine").expect("write victim"); + let artifacts = project_dir.join(".icp/cache/artifacts"); + std::fs::create_dir_all(&artifacts).expect("mkdir"); + std::os::unix::fs::symlink(&victim, artifacts.join("backend")).expect("symlink"); + + ctx.icp() + .current_dir(&project_dir) + .args(["build", "--untrusted"]) + .assert() + .failure() + .stderr(contains("failed to save wasm artifact")); + assert_eq!(std::fs::read(&victim).expect("read victim"), b"mine"); +} + /// The root may be spelled relatively: it is taken from the working directory. #[test] fn a_relative_project_root_is_taken_from_the_working_directory() { diff --git a/crates/icp-project/src/store_artifact.rs b/crates/icp-project/src/store_artifact.rs index 707fe734c..30488a0db 100644 --- a/crates/icp-project/src/store_artifact.rs +++ b/crates/icp-project/src/store_artifact.rs @@ -6,6 +6,7 @@ use std::{collections::HashMap, sync::Mutex}; #[cfg(feature = "host")] use crate::{ CACHE_DIR, ICP_BASE, + files::{Confined, FsError}, fs::{ lock::{DirectoryStructureLock, PathsAccess}, read, write, @@ -66,6 +67,7 @@ pub enum LookupArtifactError { #[cfg(feature = "host")] pub struct ArtifactStore { project_root_locate: Arc, + confined: Option, } #[cfg(feature = "host")] @@ -124,23 +126,46 @@ impl PathsAccess for ArtifactPaths { #[cfg(feature = "host")] impl ArtifactStore { - pub fn new(project_root_locate: Arc) -> Self { + /// `confined` is given for a project that is not trusted: the store's + /// files are then held to it, so a symlink planted where the store keeps + /// one is refused rather than written or read through. + pub fn new( + project_root_locate: Arc, + confined: Option, + ) -> Self { Self { project_root_locate, + confined, } } - /// Locked directory access for the artifact store. It will create the directory if it does not exist. - fn lock(&self) -> Result, crate::fs::lock::LockError> { + fn paths(&self) -> ArtifactPaths { let project_root = self .project_root_locate .locate() .expect("failed to locate project root"); - let artifact_dir = project_root - .join(ICP_BASE) - .join(CACHE_DIR) - .join("artifacts"); - DirectoryStructureLock::open_or_create(ArtifactPaths { dir: artifact_dir }) + ArtifactPaths { + dir: project_root + .join(ICP_BASE) + .join(CACHE_DIR) + .join("artifacts"), + } + } + + /// Refuse the lock file and `name`'s artifact unless the filesystem the + /// project is confined to, if it is, would give them. + fn held(&self, name: &str) -> Result<(), FsError> { + if let Some(confined) = &self.confined { + let paths = self.paths(); + confined.resolve(&paths.lock_file())?; + confined.resolve(&paths.artifact_by_name(name))?; + } + Ok(()) + } + + /// Locked directory access for the artifact store. It will create the directory if it does not exist. + fn lock(&self) -> Result, crate::fs::lock::LockError> { + DirectoryStructureLock::open_or_create(self.paths()) } } @@ -181,6 +206,7 @@ impl Access for ArtifactStore { } .fail(); } + self.held(name).map_err(|e| save_store(name, e))?; self.lock() .map_err(|e| save_store(name, e))? .with_write(async |store| { @@ -198,6 +224,7 @@ impl Access for ArtifactStore { } .fail(); } + self.held(name).map_err(|e| lookup_store(name, e))?; self.lock() .map_err(|e| lookup_store(name, e))? .with_read(async |store| { @@ -301,3 +328,51 @@ mod tests { assert!(artifact_name_overflow(&deep).is_some()); } } + +#[cfg(all(test, unix, feature = "host"))] +mod confined_tests { + use super::*; + use crate::manifest::ProjectRootLocateImpl; + + /// A link planted where the store keeps an artifact, or its lock, is + /// refused when the project is confined, rather than written through. + #[tokio::test] + async fn an_artifact_behind_a_symlink_is_refused_when_confined() { + let dir = camino_tempfile::tempdir().unwrap(); + let root = dir.path().join("project"); + let artifacts = root.join(ICP_BASE).join(CACHE_DIR).join("artifacts"); + std::fs::create_dir_all(&artifacts).unwrap(); + std::fs::write(root.join("icp.yaml"), "canisters: []").unwrap(); + let victim = dir.path().join("victim"); + std::fs::write(&victim, "mine").unwrap(); + let locate = Arc::new(ProjectRootLocateImpl::new(root.clone(), Some(root.clone()))); + let store = ArtifactStore::new(locate, Some(Confined::new(&root).unwrap())); + + for planted in ["backend", ".lock"] { + let link = artifacts.join(planted); + std::os::unix::fs::symlink(&victim, &link).unwrap(); + let err = store.save("backend", b"wasm").await.unwrap_err(); + assert!( + matches!(err, SaveError::SaveStore { .. }), + "{planted}: {err}" + ); + let err = store.lookup("backend").await.unwrap_err(); + assert!( + matches!(err, LookupArtifactError::LookupStore { .. }), + "{planted}: {err}" + ); + assert_eq!( + std::fs::read_to_string(&victim).unwrap(), + "mine", + "{planted}" + ); + std::fs::remove_file(&link).unwrap(); + } + + store + .save("backend", b"wasm") + .await + .expect("no link, saved"); + assert_eq!(store.lookup("backend").await.unwrap(), b"wasm"); + } +} diff --git a/crates/icp-project/src/store_id.rs b/crates/icp-project/src/store_id.rs index f4e438df2..f63a84088 100644 --- a/crates/icp-project/src/store_id.rs +++ b/crates/icp-project/src/store_id.rs @@ -7,6 +7,7 @@ use crate::manifest::ProjectRootLocateError; #[cfg(feature = "host")] use crate::{ CACHE_DIR, DATA_DIR, ICP_BASE, + files::Confined, fs::{create_dir_all, json, remove_file}, manifest::ProjectRootLocate, prelude::*, @@ -161,17 +162,34 @@ impl std::error::Error for StoreCause { #[cfg(feature = "host")] pub struct AccessImpl { project_root_locate: Arc, + confined: Option, lock: Mutex<()>, } #[cfg(feature = "host")] impl AccessImpl { - pub fn new(project_root_locate: Arc) -> Self { + /// `confined` is given for a project that is not trusted: the store's + /// files are then held to it, so a symlink planted where the store keeps + /// one is refused rather than written or read through. + pub fn new( + project_root_locate: Arc, + confined: Option, + ) -> Self { Self { project_root_locate, + confined, lock: Mutex::new(()), } } + + /// Refuse `fpath` unless the filesystem the project is confined to, if + /// it is, would give it. + fn held(&self, fpath: &Path) -> Result<(), StoreCause> { + if let Some(confined) = &self.confined { + confined.resolve(fpath).map_err(StoreCause::new)?; + } + Ok(()) + } } #[cfg(feature = "host")] @@ -187,6 +205,11 @@ impl Access for AccessImpl { let _g = self.lock.lock().expect("failed to acquire id store lock"); let fpath = self.get_fpath_for_env(is_cache, env)?; + self.held(&fpath) + .map_err(|source| RegisterError::RegisterStore { + source, + env: env.to_owned(), + })?; create_dir_all(fpath.parent().unwrap()).map_err(|e| RegisterError::RegisterStore { source: StoreCause::new(e), env: env.to_owned(), @@ -227,6 +250,11 @@ impl Access for AccessImpl { let _g = self.lock.lock().expect("failed to acquire id store lock"); let fpath = self.get_fpath_for_env(is_cache, env)?; + self.held(&fpath) + .map_err(|source| UnregisterError::UnregisterStore { + source, + env: env.to_owned(), + })?; // Load the file let mut mapping = load_mapping(&fpath).map_err(|e| UnregisterError::UnregisterStore { @@ -254,6 +282,11 @@ impl Access for AccessImpl { ) -> Result { let _g = self.lock.lock().expect("failed to acquire id store lock"); let fpath = self.get_fpath_for_env(is_cache, env)?; + self.held(&fpath) + .map_err(|source| LookupIdError::LookupStore { + source, + env: env.to_owned(), + })?; load_mapping(&fpath) .map_err(|e| LookupIdError::LookupStore { source: StoreCause::new(e), @@ -270,6 +303,11 @@ impl Access for AccessImpl { fn lookup_by_environment(&self, is_cache: bool, env: &str) -> Result { let _g = self.lock.lock().expect("failed to acquire id store lock"); let fpath = self.get_fpath_for_env(is_cache, env)?; + self.held(&fpath) + .map_err(|source| LookupIdError::LookupStore { + source, + env: env.to_owned(), + })?; load_mapping(&fpath).map_err(|e| LookupIdError::LookupStore { source: StoreCause::new(e), env: env.to_owned(), @@ -279,6 +317,11 @@ impl Access for AccessImpl { fn cleanup(&self, is_cache: bool, env: &str) -> Result<(), CleanupError> { let _g = self.lock.lock().expect("failed to acquire id store lock"); let fpath = self.get_fpath_for_env(is_cache, env)?; + self.held(&fpath) + .map_err(|source| CleanupError::CleanupStore { + source, + env: env.to_owned(), + })?; if fpath.exists() { remove_file(&fpath).map_err(|e| CleanupError::CleanupStore { source: StoreCause::new(e), @@ -446,3 +489,34 @@ pub mod mock { } } } + +#[cfg(all(test, unix, feature = "host"))] +mod confined_tests { + use super::*; + use crate::manifest::ProjectRootLocateImpl; + + /// A link planted where the store keeps a mapping is refused when the + /// project is confined, rather than read or written through. + #[test] + fn a_mapping_behind_a_symlink_is_refused_when_confined() { + let dir = camino_tempfile::tempdir().unwrap(); + let root = dir.path().join("project"); + let mappings = root.join(ICP_BASE).join(DATA_DIR).join("mappings"); + std::fs::create_dir_all(&mappings).unwrap(); + std::fs::write(root.join("icp.yaml"), "canisters: []").unwrap(); + let victim = dir.path().join("victim.json"); + std::fs::write(&victim, "{}").unwrap(); + std::os::unix::fs::symlink(&victim, mappings.join("ic.ids.json")).unwrap(); + + let locate = Arc::new(ProjectRootLocateImpl::new(root.clone(), Some(root.clone()))); + let store = AccessImpl::new(locate, Some(Confined::new(&root).unwrap())); + + let err = store + .register(false, "ic", "backend", Principal::anonymous()) + .unwrap_err(); + assert!(matches!(err, RegisterError::RegisterStore { .. }), "{err}"); + let err = store.lookup(false, "ic", "backend").unwrap_err(); + assert!(matches!(err, LookupIdError::LookupStore { .. }), "{err}"); + assert_eq!(std::fs::read_to_string(&victim).unwrap(), "{}"); + } +} diff --git a/crates/icp-project/src/untrusted.rs b/crates/icp-project/src/untrusted.rs index 6a3b51c94..212db8f87 100644 --- a/crates/icp-project/src/untrusted.rs +++ b/crates/icp-project/src/untrusted.rs @@ -8,7 +8,8 @@ //! //! - what it reads goes through a filesystem confined to the project root — //! on a host, [`Confined`](crate::files::Confined) — so that every path a -//! manifest names is under the root and reached through no symlink; +//! manifest names is under the root and reached through no symlink. The +//! id and artifact stores under `.icp` are held to the same filesystem; //! - what it runs is on 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, a sync step is a plugin, and the file either