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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 62 additions & 5 deletions crates/icp-cli/src/operations/bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,8 @@ pub(crate) async fn create_bundle(
) -> Result<(), BundleError> {
validate_canisters(&canisters)?;
let canonical_project_dir = canonicalize(project_dir)?;
let canonical_sync_dirs = validate_source_paths(&canisters, &canonical_project_dir)?;
let canonical_sync_dirs =
validate_source_paths(project_dir, &canisters, &canonical_project_dir)?;
validate_output_path(output, &canonical_sync_dirs)?;

build_many_with_progress_bar(
Expand Down Expand Up @@ -769,10 +770,11 @@ fn validate_canisters(canisters: &[(PathBuf, Canister)]) -> Result<(), BundleErr
Ok(())
}

/// Canonicalize every asset/plugin source path and confirm it lives inside the canonical
/// Make every asset/plugin source path absolute and confirm it lives inside the
/// project directory. Returns the canonical sync-directory paths for use in output-overlap
/// detection.
fn validate_source_paths(
project_dir: &Path,
canisters: &[(PathBuf, Canister)],
canonical_project_dir: &Path,
) -> Result<Vec<PathBuf>, BundleError> {
Expand All @@ -785,19 +787,21 @@ fn validate_source_paths(
if let Some(dirs) = &adapter.dirs {
for d in dirs {
let src = canister_path.join(d);
let canon = canonicalize_within_project(
let resolved = resolve_within_project(
&src,
project_dir,
canonical_project_dir,
&canister.name,
)?;
canonical_sync_dirs.push(canon);
canonical_sync_dirs.push(resolved);
}
}
if let Some(files) = &adapter.files {
for f in files {
let src = canister_path.join(f);
canonicalize_within_project(
resolve_within_project(
&src,
project_dir,
canonical_project_dir,
&canister.name,
)?;
Expand All @@ -810,6 +814,59 @@ fn validate_source_paths(
Ok(canonical_sync_dirs)
}

/// Resolves a sync source path to its absolute location under the canonical
/// project directory, rejecting paths that escape the project — without touching
/// the filesystem.
///
/// Unlike [`canonicalize_within_project`], this performs no syscalls and does not
/// require the path to exist. Sync directories are frequently produced by a
/// canister's own build step (e.g. a frontend `dist` from `npm run build`), so
/// they do not exist yet when this validation runs, before the build. Validating
/// lexically keeps that feedback early instead of deferring it until after a
/// potentially slow build.
///
/// Every canister path is rooted at `project_dir`, so the path's project-relative
/// portion is recovered with `strip_prefix` and its `.`/`..` components resolved
/// textually; a `..` that rises above the root — or an absolute/sibling path that
/// `strip_prefix` rejects — is an escape. Symlinks are deliberately not resolved:
/// the archive step records them verbatim (`follow_symlinks(false)`), and a
/// not-yet-built directory cannot be a symlink anyway.
fn resolve_within_project(
src: &Path,
project_dir: &Path,
canonical_project_dir: &Path,
canister: &str,
) -> Result<PathBuf, BundleError> {
let escapes = || {
SourceEscapesProjectSnafu {
canister: canister.to_owned(),
path: src.to_path_buf(),
root: canonical_project_dir.to_path_buf(),
}
.build()
};

let project_dir = project_dir.strip_prefix(".").unwrap_or(project_dir);
let rel = src.strip_prefix(project_dir).map_err(|_| escapes())?;

let mut components: Vec<&str> = Vec::new();
for component in rel.components() {
match component {
Utf8Component::Normal(c) => components.push(c),
Utf8Component::CurDir => {}
// A `..` with nothing left to pop rises above the project root.
Utf8Component::ParentDir => {
components.pop().ok_or_else(escapes)?;
}
Utf8Component::RootDir | Utf8Component::Prefix(_) => return Err(escapes()),
}
}

let mut resolved = canonical_project_dir.to_path_buf();
resolved.extend(components);
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.
fn validate_output_path(output: &Path, canonical_sync_dirs: &[PathBuf]) -> Result<(), BundleError> {
Expand Down
65 changes: 65 additions & 0 deletions crates/icp-cli/tests/bundle_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,71 @@ fn bundle_rejects_source_outside_project() {
.stderr(contains("my-canister").and(contains("outside the project directory")));
}

/// A plugin's synced directory is often an output of the canister's own build
/// step (e.g. a frontend `dist` produced by `npm run build`), so it does not
/// exist when bundling validates the sync sources, before the build. Validation
/// must resolve sync paths lexically (no canonicalization) so a not-yet-built
/// directory is accepted; the build then creates it before it is archived.
#[test]
fn bundle_accepts_synced_dir_created_by_build_step() {
let ctx = TestContext::new();
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");

// `generated-assets` is created by the build step; it does not exist on disk
// before `icp project bundle` runs.
let pm = formatdoc! {r#"
canisters:
- name: my-canister
build:
steps:
- type: script
commands:
- mkdir -p generated-assets
- printf hello > generated-assets/index.html
- cp '{wasm_src}' "$ICP_WASM_OUTPUT_PATH"
sync:
steps:
- type: plugin
path: plugin.wasm
dirs:
- generated-assets
"#};

write_string(&project_dir.join("icp.yaml"), &pm).expect("failed to write project manifest");

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();

// The build-produced directory is archived under the plugin's `dirs/` area.
let bundle_bytes = fs::read(bundle_path.as_std_path()).expect("failed to read bundle");
let gz = GzDecoder::new(BufReader::new(bundle_bytes.as_slice()));
let mut archive = Archive::new(gz);
let mut found_asset = false;
for entry in archive.entries().expect("failed to read archive entries") {
let entry = entry.expect("failed to read archive entry");
let path = entry
.path()
.expect("failed to get entry path")
.to_string_lossy()
.into_owned();
if path == "plugins/my-canister/0/dirs/generated-assets/index.html" {
found_asset = true;
}
}
assert!(
found_asset,
"build-produced synced dir was not archived in the bundle"
);
}

/// Bundle a canister with two plugin sync steps and verify the archive layout: per-plugin
/// wasm at `plugins/{canister}/{idx}.wasm`, preopened dirs at `plugins/{canister}/{idx}/dirs/`,
/// input files at `plugins/{canister}/{idx}/files/`. Also verify the rewritten manifest
Expand Down
Loading