Skip to content
Draft
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
1 change: 1 addition & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions crates/icp-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ dunce.workspace = true
elliptic-curve.workspace = true
flate2.workspace = true
futures.workspace = true
tar.workspace = true
hex.workspace = true
httptest.workspace = true
ic-agent.workspace = true
Expand Down
41 changes: 41 additions & 0 deletions crates/icp-cli/src/commands/project/bundle.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
use anyhow::Context as _;
use clap::Args;
use icp::context::Context;
use icp::prelude::*;

use crate::operations::bundle::create_bundle;

/// Bundle a project into a self-contained deployable archive.
///
/// Builds all project canisters and packages them with a rewritten manifest
/// into a `.tar.gz` file. The rewritten manifest replaces all build steps
/// with pre-built steps referencing the bundled WASM files. Asset sync
/// directories are included in the archive.
///
/// Projects with script sync steps cannot be bundled.
#[derive(Args, Debug)]
pub(crate) struct BundleArgs {
/// Output path for the bundle archive (e.g. bundle.tar.gz)
#[arg(long, short)]
pub(crate) output: PathBuf,
}

pub(crate) async fn exec(ctx: &Context, args: &BundleArgs) -> Result<(), anyhow::Error> {
let project = ctx.project.load().await.context("failed to load project")?;

let canisters: Vec<_> = project.canisters.into_values().collect();

create_bundle(
&project.dir,
canisters,
ctx.builder.clone(),
ctx.artifacts.clone(),
&ctx.dirs.package_cache()?,
ctx.debug,
&args.output,
)
.await
.context("failed to create bundle")?;

Ok(())
}
4 changes: 3 additions & 1 deletion crates/icp-cli/src/commands/project/mod.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
use clap::Subcommand;

pub(crate) mod bundle;
pub(crate) mod show;

/// Display information about the current project
/// Manage the current project
#[derive(Debug, Subcommand)]
pub(crate) enum Command {
Show(show::ShowArgs),
Bundle(bundle::BundleArgs),
}
3 changes: 3 additions & 0 deletions crates/icp-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,9 @@ async fn dispatch(ctx: &icp::context::Context, command: Command) -> Result<(), E
commands::project::Command::Show(args) => {
commands::project::show::exec(ctx, &args).await?
}
commands::project::Command::Bundle(args) => {
commands::project::bundle::exec(ctx, &args).await?
}
},

// Settings
Expand Down
249 changes: 249 additions & 0 deletions crates/icp-cli/src/operations/bundle.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
use std::{
fs::File,
io::{BufWriter, Cursor},
sync::Arc,
};

use sha2::{Digest, Sha256};

use flate2::{Compression, write::GzEncoder};
use icp::{
Canister, InitArgs,
canister::build::Build,
manifest::{
ArgsFormat, BuildStep, BuildSteps, CanisterManifest, Instructions, Item,
LoadManifestFromPathError, ManifestInitArgs, PROJECT_MANIFEST, ProjectManifest, SyncStep,
SyncSteps, assets::DirField, prebuilt,
},
package::PackageCache,
prelude::*,
store_artifact,
};
use snafu::{ResultExt, Snafu};
use tar::Builder;

use crate::operations::build::{BuildManyError, build_many_with_progress_bar};

#[derive(Debug, Snafu)]
pub enum BundleError {
#[snafu(display(
"canister '{canister}' has a script sync step, which is not supported in bundles"
))]
ScriptSyncStep { canister: String },

#[snafu(transparent)]
Build { source: BuildManyError },

#[snafu(display("failed to look up built artifact for canister '{canister}'"))]
LookupArtifact {
canister: String,
source: store_artifact::LookupArtifactError,
},

#[snafu(display("failed to load project manifest for bundle"))]
LoadManifest { source: LoadManifestFromPathError },

#[snafu(display("failed to serialize bundle manifest"))]
SerializeManifest { source: serde_yaml::Error },

#[snafu(display("failed to add '{path}' to bundle archive"))]
WriteArchiveEntry {
path: PathBuf,
source: std::io::Error,
},

#[snafu(display("failed to create bundle output file at '{path}'"))]
CreateOutput {
path: PathBuf,
source: std::io::Error,
},

#[snafu(display("failed to finalize bundle archive"))]
FlushArchive { source: std::io::Error },
}

pub(crate) async fn create_bundle(
project_dir: &Path,
canisters: Vec<(PathBuf, Canister)>,
builder: Arc<dyn Build>,
artifacts: Arc<dyn store_artifact::Access>,
pkg_cache: &PackageCache,
debug: bool,
output: &Path,
) -> Result<(), BundleError> {
for (_, canister) in &canisters {
for step in &canister.sync.steps {
if matches!(step, SyncStep::Script(_)) {
return ScriptSyncStepSnafu {
canister: canister.name.clone(),
}
.fail();
}
}
}

build_many_with_progress_bar(
canisters.clone(),
builder,
artifacts.clone(),
pkg_cache,
debug,
)
.await?;

// Re-read the raw manifest to preserve networks and environments verbatim.
let raw_manifest: ProjectManifest =
icp::manifest::load_manifest_from_path(&project_dir.join(PROJECT_MANIFEST))
.await
.context(LoadManifestSnafu)?;

Comment on lines +94 to +99
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

raw_manifest.networks / raw_manifest.environments are copied into the bundle manifest verbatim, but the bundle archive only includes icp.yaml, *.wasm, and asset directories. If those lists contain Item::Path entries (external network/environment YAMLs) or environment init_args that reference files, the extracted bundle won’t be self-contained and icp deploy will fail. Consider inlining referenced manifests (load them and emit Item::Manifest), and/or embedding referenced init_args files and rewriting paths; alternatively, explicitly reject these cases with a clear error.

Copilot uses AI. Check for mistakes.
let mut bundle_canisters = Vec::new();
let mut canister_wasms: Vec<(String, Vec<u8>)> = Vec::new();
// (canister_path, canister_name, asset_dirs)
let mut asset_dirs: Vec<(PathBuf, String, Vec<String>)> = Vec::new();

for (canister_path, canister) in &canisters {
let wasm = artifacts
.lookup(&canister.name)
.await
.context(LookupArtifactSnafu {
canister: canister.name.clone(),
})?;

let sha256 = hex::encode(Sha256::digest(&wasm));
let wasm_filename = format!("{}.wasm", canister.name);

// Collect asset dirs and rewrite their paths for the bundle.
Comment on lines +113 to +116
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Archive entry names are derived from canister.name (e.g. format!("{}.wasm", canister.name)). Since the schema allows any string for canister names, a name containing path separators or .. could create invalid/unsafe tar paths (and likely break extraction). It’d be safer to validate/sanitize canister names for bundling (e.g., reject names containing '/', '\', or ..) before using them as filenames.

Copilot uses AI. Check for mistakes.
let mut bundle_sync_steps = Vec::new();
let mut raw_asset_dirs = Vec::new();

for step in &canister.sync.steps {
match step {
SyncStep::Script(_) => unreachable!("validated above"),
SyncStep::Assets(adapter) => {
let dirs = adapter.dir.as_vec();
raw_asset_dirs.extend(dirs.clone());

let prefixed_dirs: Vec<String> = dirs
.iter()
.map(|d| format!("{}/{d}", canister.name))
.collect();

let new_dir = if prefixed_dirs.len() == 1 {
DirField::Dir(prefixed_dirs.into_iter().next().unwrap())
} else {
DirField::Dirs(prefixed_dirs)
};

bundle_sync_steps.push(SyncStep::Assets(icp::manifest::assets::Adapter {
dir: new_dir,
}));
}
}
}

if !raw_asset_dirs.is_empty() {
asset_dirs.push((canister_path.clone(), canister.name.clone(), raw_asset_dirs));
}

let init_args = canister.init_args.as_ref().map(convert_init_args);

let sync = if bundle_sync_steps.is_empty() {
None
} else {
Some(SyncSteps {
steps: bundle_sync_steps,
})
};

let canister_manifest = CanisterManifest {
name: canister.name.clone(),
settings: canister.settings.clone(),
init_args,
instructions: Instructions::BuildSync {
build: BuildSteps {
steps: vec![BuildStep::Prebuilt(prebuilt::Adapter {
source: prebuilt::SourceField::Local(prebuilt::LocalSource {
path: wasm_filename.as_str().into(),
}),
sha256: Some(sha256),
})],
},
sync,
},
};

bundle_canisters.push(Item::Manifest(canister_manifest));
canister_wasms.push((wasm_filename, wasm));
}

let bundle_manifest = ProjectManifest {
canisters: bundle_canisters,
networks: raw_manifest.networks,
environments: raw_manifest.environments,
};

let manifest_yaml = serde_yaml::to_string(&bundle_manifest).context(SerializeManifestSnafu)?;

let file = File::create(output.as_std_path()).context(CreateOutputSnafu {
path: output.to_path_buf(),
})?;
let gz = GzEncoder::new(BufWriter::new(file), Compression::default());
let mut archive = Builder::new(gz);

// icp.yaml
let manifest_bytes = manifest_yaml.as_bytes();
let mut header = tar::Header::new_gnu();
header.set_size(manifest_bytes.len() as u64);
header.set_mode(0o644);
header.set_cksum();
archive
.append_data(&mut header, "icp.yaml", Cursor::new(manifest_bytes))
.context(WriteArchiveEntrySnafu {
path: PathBuf::from("icp.yaml"),
})?;

// WASM files
for (filename, wasm) in &canister_wasms {
let mut header = tar::Header::new_gnu();
header.set_size(wasm.len() as u64);
header.set_mode(0o644);
header.set_cksum();
archive
.append_data(&mut header, filename, Cursor::new(wasm))
.context(WriteArchiveEntrySnafu {
path: PathBuf::from(filename),
})?;
}

// Asset directories
for (canister_path, canister_name, dirs) in &asset_dirs {
for dir in dirs {
let src_path = canister_path.join(dir);
let archive_prefix = format!("{canister_name}/{dir}");
archive
.append_dir_all(&archive_prefix, src_path.as_std_path())
.context(WriteArchiveEntrySnafu {
Comment on lines +221 to +226
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When adding asset directories, src_path = canister_path.join(dir) will treat absolute dir values as absolute paths and will also allow .. traversal. Additionally, archive_prefix = format!("{canister_name}/{dir}") can embed .. segments into the tar entry path. Consider validating that asset dir entries are relative, normalized (no ..), and stay within canister_path before archiving; otherwise bundling can fail on unpack or unintentionally include files outside the project.

Copilot uses AI. Check for mistakes.
path: PathBuf::from(&archive_prefix),
})?;
}
}

let gz = archive.into_inner().context(FlushArchiveSnafu)?;
gz.finish().context(FlushArchiveSnafu)?;

Ok(())
}

fn convert_init_args(args: &InitArgs) -> ManifestInitArgs {
match args {
InitArgs::Text { content, format } => ManifestInitArgs::Value {
value: content.clone(),
format: format.clone(),
},
InitArgs::Binary(bytes) => ManifestInitArgs::Value {
value: hex::encode(bytes),
format: ArgsFormat::Hex,
},
}
}
1 change: 1 addition & 0 deletions crates/icp-cli/src/operations/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub(crate) mod binding_env_vars;
pub(crate) mod build;
pub(crate) mod bundle;
pub(crate) mod candid_compat;
pub(crate) mod canister_migration;
pub(crate) mod create;
Expand Down
Loading
Loading