diff --git a/crates/e2e/tests/fixture/nested/icp.yaml b/crates/e2e/tests/fixture/nested/icp.yaml
new file mode 100644
index 0000000..598d1bb
--- /dev/null
+++ b/crates/e2e/tests/fixture/nested/icp.yaml
@@ -0,0 +1,22 @@
+networks:
+ - name: local
+ mode: managed
+ gateway:
+ port: 0
+
+canisters:
+ - name: frontend
+ build:
+ steps:
+ - type: pre-built
+ path: wasms/canister.wasm
+
+ sync:
+ steps:
+ - type: plugin
+ path: wasms/plugin.wasm
+ # A multi-component (nested) directory. The host preopens it under a
+ # multi-segment WASI guest name, which broke `canonicalize`/`realpath`
+ # in the plugin's scan step.
+ dirs:
+ - src/frontend/dist
diff --git a/crates/e2e/tests/fixture/nested/src/frontend/dist/assets/style.css b/crates/e2e/tests/fixture/nested/src/frontend/dist/assets/style.css
new file mode 100644
index 0000000..a918121
--- /dev/null
+++ b/crates/e2e/tests/fixture/nested/src/frontend/dist/assets/style.css
@@ -0,0 +1 @@
+body { color: rebeccapurple; }
diff --git a/crates/e2e/tests/fixture/nested/src/frontend/dist/index.html b/crates/e2e/tests/fixture/nested/src/frontend/dist/index.html
new file mode 100644
index 0000000..ebed777
--- /dev/null
+++ b/crates/e2e/tests/fixture/nested/src/frontend/dist/index.html
@@ -0,0 +1 @@
+
nested dir
diff --git a/crates/e2e/tests/sync.rs b/crates/e2e/tests/sync.rs
index 392d857..80f7f45 100644
--- a/crates/e2e/tests/sync.rs
+++ b/crates/e2e/tests/sync.rs
@@ -20,6 +20,30 @@ fn basic_deploy() {
);
}
+/// Deploy a fixture whose `dirs` entry is a *nested* path (`src/frontend/dist`).
+/// The host preopens it under a multi-segment WASI guest name; the plugin's scan
+/// step must not call `canonicalize`/`realpath` on it (WASI returns ENOENT for
+/// any path under a multi-component preopen, even though plain access works).
+#[test]
+fn nested_dir_deploy() {
+ let tmp = setup_project("tests/fixture/nested");
+ let project = tmp.path();
+ let _network = LocalNetwork::start(project);
+
+ icp_cmd(project).arg("deploy").assert().success();
+
+ let assets = list_assets(project);
+
+ assert!(
+ assets.iter().any(|a| a.key == "/index.html"),
+ "expected /index.html in canister asset list; got: {assets:#?}",
+ );
+ assert!(
+ assets.iter().any(|a| a.key == "/assets/style.css"),
+ "expected /assets/style.css (nested subdir) in canister asset list; got: {assets:#?}",
+ );
+}
+
#[test]
fn basic_deploy_with_proxy() {
let tmp = setup_project("tests/fixture/basic");
diff --git a/crates/sync-core/src/scan.rs b/crates/sync-core/src/scan.rs
index 2a1c368..d166ef3 100644
--- a/crates/sync-core/src/scan.rs
+++ b/crates/sync-core/src/scan.rs
@@ -25,13 +25,33 @@ pub struct AssetSource {
pub key: String,
}
+/// Builds an absolute root for `dir` (a manifest-relative directory the host
+/// preopened) by prepending `/` and dropping `.` / redundant components.
+///
+/// We deliberately avoid [`Path::canonicalize`] here. Under WASI it calls
+/// `realpath`, which returns `ENOENT` ("No such file or directory") for *any*
+/// path beneath a preopen whose guest name has more than one component (e.g.
+/// `src/frontend/dist`) — even though ordinary access (`read_dir`, `metadata`,
+/// `read`) through that preopen works fine. Single-component dirs like `dist`
+/// happen to canonicalize to `/dist`; this helper produces the same shape
+/// (`/src/frontend/dist`) for nested dirs without touching `realpath`.
+///
+/// The host guarantees `dir` is relative and free of `..` components, so keeping
+/// only `Normal` components cannot escape the preopen.
+fn absolute_root(dir: &str) -> PathBuf {
+ let mut root = PathBuf::from("/");
+ for component in Path::new(dir).components() {
+ if let std::path::Component::Normal(c) = component {
+ root.push(c);
+ }
+ }
+ root
+}
+
/// Scans `dir` for asset files.
pub fn scan(dir: &str) -> Result, String> {
let mut out = Vec::new();
- let root = Path::new(dir);
- let root_abs = root
- .canonicalize()
- .map_err(|e| format!("canonicalize {}: {e}", root.display()))?;
+ let root_abs = absolute_root(dir);
walk(&root_abs, &root_abs, &mut out)?;
Ok(out)
}