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) }