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
64 changes: 32 additions & 32 deletions crates/icp-cli/src/operations/bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,32 +154,32 @@ pub enum BundleError {
source: serde_yaml::Error,
},

#[snafu(display("`screenshots` in app manifest '{path}' must be a list of file paths"))]
ScreenshotsNotSequence { path: PathBuf },
#[snafu(display("`images` in app manifest '{path}' must be a list of file paths"))]
Comment thread
adamspofford-dfinity marked this conversation as resolved.
ImagesNotSequence { path: PathBuf },

#[snafu(display("screenshot entries in app manifest '{path}' must be file path strings"))]
ScreenshotNotString { path: PathBuf },
#[snafu(display("image entries in app manifest '{path}' must be file path strings"))]
ImageNotString { path: PathBuf },

#[snafu(display(
"screenshot path '{path}' resolves outside the project directory '{root}'; \
"image path '{path}' resolves outside the project directory '{root}'; \
bundles cannot reference files outside the project"
))]
ScreenshotEscapesProject { path: PathBuf, root: PathBuf },
ImageEscapesProject { path: PathBuf, root: PathBuf },

#[snafu(display(
"screenshots {paths:?} both map to the same bundle path 'screenshots/{sanitized}'; \
"images {paths:?} both map to the same bundle path 'images/{sanitized}'; \
Comment thread
adamspofford-dfinity marked this conversation as resolved.
rename one so they use distinct file names"
))]
ScreenshotNameCollision {
ImageNameCollision {
sanitized: String,
paths: Vec<String>,
},

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

#[snafu(display("failed to read screenshot '{path}'"))]
ReadScreenshot { path: PathBuf, source: fs::IoError },
#[snafu(display("failed to read image '{path}'"))]
ReadImage { path: PathBuf, source: fs::IoError },
}

/// In-memory bytes destined for a single tar entry.
Expand Down Expand Up @@ -209,16 +209,16 @@ struct InitArgsFile {
}

/// The optional `icp_appmanifest.yaml` app-metadata file. We only understand its top-level
/// `screenshots` list; all other keys are preserved semantically.
/// `images` list; all other keys are preserved semantically.
struct AppManifest {
/// YAML to write at `APP_MANIFEST` in the archive. The original source text is used when
/// no screenshot relocation is needed; otherwise the YAML is re-serialized (formatting/comments may change).
/// no image relocation is needed; otherwise the YAML is re-serialized (formatting/comments may change).
yaml: String,
screenshots: Vec<ScreenshotFile>,
images: Vec<ImageFile>,
}

/// A screenshot referenced from `icp_appmanifest.yaml`, relocated under `screenshots/` in the bundle.
struct ScreenshotFile {
/// A image referenced from `icp_appmanifest.yaml`, relocated under `images/` in the bundle.
struct ImageFile {
src_path: PathBuf,
archive_path: String,
}
Expand Down Expand Up @@ -567,8 +567,8 @@ async fn inline_environments(
Ok((out, init_args_files))
}

/// Load `icp_appmanifest.yaml` if present, rewriting its top-level `screenshots` paths to point at
/// copies relocated under `screenshots/` in the bundle. Returns `None` when the file is absent.
/// Load `icp_appmanifest.yaml` if present, rewriting its top-level `images` paths to point at
/// copies relocated under `images/` in the bundle. Returns `None` when the file is absent.
fn prepare_app_manifest(
project_dir: &Path,
canonical_project_dir: &Path,
Expand All @@ -585,57 +585,57 @@ fn prepare_app_manifest(
path: &manifest_path,
})?;

let Some(screenshots_val) = doc.get_mut("screenshots") else {
// No screenshots to relocate; embed the file unchanged.
let Some(images_val) = doc.get_mut("images") else {
// No images to relocate; embed the file unchanged.
return Ok(Some(AppManifest {
yaml: raw,
screenshots: Vec::new(),
images: Vec::new(),
}));
};
let seq = screenshots_val
let seq = images_val
.as_sequence_mut()
.context(ScreenshotsNotSequenceSnafu {
.context(ImagesNotSequenceSnafu {
path: &manifest_path,
})?;

let mut screenshots = Vec::with_capacity(seq.len());
let mut images = Vec::with_capacity(seq.len());
// Maps a relocated archive name back to the canonical source and original path it came from,
// so identical entries are deduplicated and distinct sources that flatten to the same name
// are reported as a collision.
let mut seen: HashMap<String, (PathBuf, String)> = HashMap::new();
for entry in seq.iter_mut() {
let orig = entry
.as_str()
.context(ScreenshotNotStringSnafu {
.context(ImageNotStringSnafu {
path: &manifest_path,
})?
.to_owned();
let src = project_dir.join(&orig);
let canon = canonicalize(&src)?;
if !canon.starts_with(canonical_project_dir) {
return ScreenshotEscapesProjectSnafu {
return ImageEscapesProjectSnafu {
path: src,
root: canonical_project_dir.to_path_buf(),
}
.fail();
}

// Flatten into the top-level `screenshots/` folder by basename, sanitized the same way
// Flatten into the top-level `images/` folder by basename, sanitized the same way
// canister name segments are.
let base = canon.file_name().unwrap_or(orig.as_str());
let sanitized = path_segment(base);
let archive_path = format!("screenshots/{sanitized}");
let archive_path = format!("images/{sanitized}");

match seen.get(&sanitized) {
Some((prev_canon, _)) if *prev_canon == canon => {}
Some((_, prev_orig)) => {
let mut paths = vec![prev_orig.clone(), orig.clone()];
paths.sort();
return ScreenshotNameCollisionSnafu { sanitized, paths }.fail();
return ImageNameCollisionSnafu { sanitized, paths }.fail();
}
None => {
seen.insert(sanitized.clone(), (canon.clone(), orig.clone()));
screenshots.push(ScreenshotFile {
images.push(ImageFile {
src_path: canon,
archive_path: archive_path.clone(),
});
Expand All @@ -646,7 +646,7 @@ fn prepare_app_manifest(
}

let yaml = serde_yaml::to_string(&doc).context(SerializeAppManifestSnafu)?;
Ok(Some(AppManifest { yaml, screenshots }))
Ok(Some(AppManifest { yaml, images }))
}

fn write_archive(
Expand Down Expand Up @@ -676,8 +676,8 @@ fn write_archive(

if let Some(app) = app_manifest {
append_bytes(&mut archive, APP_MANIFEST, app.yaml.as_bytes())?;
for shot in &app.screenshots {
let data = fs::read(&shot.src_path).context(ReadScreenshotSnafu {
for shot in &app.images {
let data = fs::read(&shot.src_path).context(ReadImageSnafu {
path: shot.src_path.clone(),
})?;
append_bytes(&mut archive, &shot.archive_path, &data)?;
Expand Down
38 changes: 16 additions & 22 deletions crates/icp-cli/tests/bundle_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -744,10 +744,10 @@ fn bundle_packages_plugin_sync_steps() {
}

/// An `icp_appmanifest.yaml` next to the project manifest must be included in the bundle, with its
/// top-level `screenshots` paths relocated under a top-level `screenshots/` folder and the
/// top-level `images` paths relocated under a top-level `images/` folder and the
/// referenced image files copied alongside. Unrelated metadata is preserved.
#[test]
fn bundle_includes_app_manifest_screenshots() {
fn bundle_includes_app_manifest_images() {
let ctx = TestContext::new();
let project_dir = ctx.create_project_dir("icp");
let wasm_src = ctx.make_asset("example_icp_mo.wasm");
Expand All @@ -770,7 +770,7 @@ fn bundle_includes_app_manifest_screenshots() {
let app_manifest = formatdoc! {r#"
name: My App
description: an app we do not parse
screenshots:
images:
- media/home.png
- media/detail.png
"#};
Expand Down Expand Up @@ -806,14 +806,14 @@ fn bundle_includes_app_manifest_screenshots() {
.read_to_string(&mut app_manifest_yaml)
.expect("failed to read icp_appmanifest.yaml");
}
"screenshots/home.png" => {
"images/home.png" => {
let mut buf = Vec::new();
entry
.read_to_end(&mut buf)
.expect("failed to read home.png");
home_bytes = Some(buf);
}
"screenshots/detail.png" => {
"images/detail.png" => {
let mut buf = Vec::new();
entry
.read_to_end(&mut buf)
Expand All @@ -827,24 +827,18 @@ fn bundle_includes_app_manifest_screenshots() {
assert_eq!(
home_bytes.as_deref(),
Some(b"home-bytes".as_slice()),
"screenshots/home.png missing or wrong content"
"images/home.png missing or wrong content"
);
assert_eq!(
detail_bytes.as_deref(),
Some(b"detail-bytes".as_slice()),
"screenshots/detail.png missing or wrong content"
"images/detail.png missing or wrong content"
);

let parsed: serde_yaml::Value =
serde_yaml::from_str(&app_manifest_yaml).expect("app manifest yaml is invalid");
assert_eq!(
parsed["screenshots"][0].as_str(),
Some("screenshots/home.png")
);
assert_eq!(
parsed["screenshots"][1].as_str(),
Some("screenshots/detail.png")
);
assert_eq!(parsed["images"][0].as_str(), Some("images/home.png"));
assert_eq!(parsed["images"][1].as_str(), Some("images/detail.png"));
// Unrelated metadata must survive the rewrite.
assert_eq!(parsed["name"].as_str(), Some("My App"));
assert_eq!(
Expand All @@ -853,9 +847,9 @@ fn bundle_includes_app_manifest_screenshots() {
);
}

/// Two screenshots whose basenames collide under the flat `screenshots/` folder must be rejected.
/// Two images whose basenames collide under the flat `images/` folder must be rejected.
#[test]
fn bundle_rejects_screenshot_name_collision() {
fn bundle_rejects_image_name_collision() {
let ctx = TestContext::new();
let project_dir = ctx.create_project_dir("icp");
let wasm_src = ctx.make_asset("example_icp_mo.wasm");
Expand All @@ -876,7 +870,7 @@ fn bundle_rejects_screenshot_name_collision() {
write_string(&project_dir.join("icp.yaml"), &pm).expect("failed to write project manifest");

let app_manifest = formatdoc! {r#"
screenshots:
images:
- a/shot.png
- b/shot.png
"#};
Expand All @@ -891,10 +885,10 @@ fn bundle_rejects_screenshot_name_collision() {
.stderr(contains("same bundle path").and(contains("shot.png")));
}

/// A screenshot path resolving outside the project directory must be rejected, like other bundle
/// A image path resolving outside the project directory must be rejected, like other bundle
/// sources.
#[test]
fn bundle_rejects_screenshot_outside_project() {
fn bundle_rejects_image_outside_project() {
let ctx = TestContext::new();
let project_dir = ctx.create_project_dir("icp");
let wasm_src = ctx.make_asset("example_icp_mo.wasm");
Expand All @@ -903,7 +897,7 @@ fn bundle_rejects_screenshot_outside_project() {
.parent()
.expect("project dir has no parent")
.join("outside.png");
write(&outside, b"secret").expect("failed to write outside screenshot");
write(&outside, b"secret").expect("failed to write outside image");

let pm = formatdoc! {r#"
canisters:
Expand All @@ -916,7 +910,7 @@ fn bundle_rejects_screenshot_outside_project() {
write_string(&project_dir.join("icp.yaml"), &pm).expect("failed to write project manifest");

let app_manifest = formatdoc! {r#"
screenshots:
images:
- ../outside.png
"#};
write_string(&project_dir.join("icp_appmanifest.yaml"), &app_manifest)
Expand Down
Loading