diff --git a/CHANGELOG.md b/CHANGELOG.md index 37e38f6e..81f667d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Changed +- **Filesystem data-plane contract tightened: backend is now root-layer-only and `perform fs` reads are deprecated.** The shell/MCP evaluation wrapper no longer routes data-plane reads through `(perform fs ...)`; filesystem reads now go through WASI path I/O (`load`, `import`, `/ipfs/...`, `/ipns/...`). The legacy `fs` handler remains only to return a migration-grade deprecation error. Backend virtual mount resolution now rejects targeted mounts (`source:/guest/path`) and accepts root layers only; `ww run` enforces this early with a CLI preflight error that lists offending mounts. Docs updated across `doc/shell.md`, `doc/capabilities.md`, and `doc/architecture.md` to reflect the single-path model. - **CompilationService now uses a dedicated worker pool with in-flight dedupe.** The compiler subsystem moved from single-thread compile handling to a fixed worker pool (`WW_COMPILE_WORKERS`, default derived from CPU count), keys cache entries by `(wasm_blake3, engine identity)`, and coalesces concurrent duplicate compile requests so one cold compile serves all waiters. - **Shell local-discovery policy aligned to per-user run dir.** `ww shell` local discovery now scans only `~/.ww/run/` for `.sock` entries (no `/var/run/ww` fallback), and the client fails deterministically with a disambiguation error when multiple local daemons are present instead of prompting interactively. Updated `src/discovery.rs`, `src/cli/shell.rs`, and shell/CLI docs to match this behavior and keep the local admin auth boundary consistently user-scoped. - **CLI module boundary cleanup (no behavior change).** Extracted daemon-management helpers, namespace command handlers, and doctor checks from `src/cli/main.rs` into `src/cli/daemon_cmd.rs`, `src/cli/ns_cmd.rs`, and `src/cli/doctor_cmd.rs`, leaving `Commands` as thin delegators. This reduces `main.rs` surface area and improves maintainability while preserving existing command behavior. diff --git a/crates/cell/src/image.rs b/crates/cell/src/image.rs index c2952d98..687343c5 100644 --- a/crates/cell/src/image.rs +++ b/crates/cell/src/image.rs @@ -2,13 +2,11 @@ //! //! Every positional arg to `ww run` is a mount: `source[:target]`. //! Root mounts (target `/`) are traditional image layers. Targeted -//! mounts overlay a host file or directory at a specific guest path. +//! mounts are currently rejected in backend virtual mode. //! //! Mounts are applied left-to-right via `resolve_mounts_virtual`: //! root layers are DAG-merged at the IPFS MFS level (file blocks never -//! touched, only directory nodes get new CIDs), and targeted mounts -//! become `LocalOverride` entries that the guest's `CidTree` checks -//! before falling through to the CID walk. No file content is +//! touched, only directory nodes get new CIDs). No file content is //! materialized to disk by this module. //! //! Pre-#416 this file also exposed an `apply_mounts` API that @@ -169,10 +167,9 @@ async fn merge_overlay_recursive_inner( /// Resolve mounts into a root CID and local overrides for the virtual filesystem. /// -/// Performs the DAG merge to produce a merged root CID, and collects -/// targeted mounts as `LocalOverride` entries. No file content is copied -/// or fetched — that happens lazily when the guest opens files via -/// `CidTree` + `fs_intercept`. +/// Performs the DAG merge to produce a merged root CID. +/// Targeted mounts are rejected in backend mode to avoid a second, +/// host-local filesystem path. /// /// Returns `(root_cid, local_overrides)` suitable for constructing a `CidTree`. pub async fn resolve_mounts_virtual( @@ -182,8 +179,6 @@ pub async fn resolve_mounts_virtual( String, std::collections::HashMap, )> { - use crate::vfs::LocalOverride; - if mounts.is_empty() { bail!("No mounts provided"); } @@ -195,6 +190,14 @@ pub async fn resolve_mounts_virtual( bail!("No root mounts provided (at least one required)"); } + if !targeted_mounts.is_empty() { + bail!( + "targeted mounts are not supported in backend virtual mode (received {} targeted mount(s)); \ + publish content to IPFS/IPNS and mount as a root layer", + targeted_mounts.len() + ); + } + // Resolve all root mounts to CIDs. let mut cids = Vec::with_capacity(root_mounts.len()); for mount in &root_mounts { @@ -226,44 +229,7 @@ pub async fn resolve_mounts_virtual( let root_cid = dag_merge(&cids, ipfs_client).await?; tracing::info!(cid = %root_cid, layers = cids.len(), "Virtual DAG merge complete"); - // Collect targeted mounts as local overrides. - let mut overrides = std::collections::HashMap::new(); - for mount in &targeted_mounts { - let guest_path = mount - .target - .strip_prefix("/") - .unwrap_or(&mount.target) - .to_path_buf(); - - if guest_path.as_os_str().is_empty() { - continue; // skip empty target - } - - if ipfs::is_ipfs_path(&mount.source) { - // IPFS targeted mounts are not supported in virtual mode. - // They would need to be added to the CID tree, which conflicts - // with the design goal of keeping private mounts off IPFS. - bail!( - "IPFS targeted mounts not supported in virtual mode: {} -> {}", - mount.source, - mount.target.display() - ); - } - - let src = Path::new(&mount.source); - if !src.exists() { - bail!("Mount source does not exist: {}", mount.source); - } - - let ovr = if src.is_dir() { - LocalOverride::Dir(src.to_path_buf()) - } else { - LocalOverride::File(src.to_path_buf()) - }; - overrides.insert(guest_path, ovr); - } - - Ok((root_cid, overrides)) + Ok((root_cid, std::collections::HashMap::new())) } /// Split `/ipns/[/]` into `(hash, subpath)`. `subpath` @@ -378,18 +344,12 @@ mod tests { // // Two pure-validation cases live here (no IPFS roundtrip needed). // - // Merge correctness (`dag_merge` over multiple layers) and the - // targeted-mount → `LocalOverride` translation are NOT unit-tested - // here: those paths require Kubo to `add_dir` local layers, and - // CI's daemon does not reliably accept ephemeral `tempfile::TempDir` - // paths inside the test runner. The previous `apply_mounts` / - // `merge_layers` tests only worked because the deleted code had an - // all-local `copy_merge` fast path that never hit IPFS — now gone. - // - // End-to-end coverage of the merge + override translation lives - // in `tests/discovery_integration.rs` and `tests/shell_e2e.rs`, - // which boot real kernels through `CidTree` against published - // images (paths Kubo already accepts). + // Merge correctness (`dag_merge` over multiple layers) is NOT unit-tested + // here: those paths require Kubo to `add_dir` local layers, and CI's + // daemon does not reliably accept ephemeral `tempfile::TempDir` paths + // inside the test runner. The previous `apply_mounts` / `merge_layers` + // tests only worked because the deleted code had an all-local + // `copy_merge` fast path that never hit IPFS — now gone. #[tokio::test] async fn test_virtual_empty_mounts_errors() { @@ -407,6 +367,36 @@ mod tests { assert!(result.is_err()); } + #[tokio::test] + async fn test_virtual_targeted_mounts_rejected() { + let client = stub_ipfs_client(); + let mounts = vec![ + Mount { + source: "/ipfs/bafybeigdyrzt".to_string(), + target: PathBuf::from("/"), + }, + Mount { + source: "./local-secret".to_string(), + target: PathBuf::from("/etc/identity"), + }, + ]; + let result = resolve_mounts_virtual(&mounts, &client).await; + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("targeted mounts are not supported in backend virtual mode"), + "unexpected error: {msg}" + ); + assert!( + msg.contains("received 1 targeted mount(s)"), + "error should include targeted mount count: {msg}" + ); + assert!( + msg.contains("publish content to IPFS/IPNS and mount as a root layer"), + "error should include migration guidance: {msg}" + ); + } + // ── split_ipns_path: pure parsing, IPNS-to-IPFS subpath split ── #[test] diff --git a/doc/architecture.md b/doc/architecture.md index b1e159fe..e86da5ca 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -395,21 +395,20 @@ preopen. 2. **Root Atom binding** — the `stem::Atom` whose value is the cell's root CID. Swap the Atom and `CidTree::swap_root` updates the view atomically. -3. **Glia env bindings** — what's callable inside the cell. Restrict - filesystem access by not binding the `fs` handler in the cell's - env (`std/caps/src/lib.rs:make_fs_handler`). +3. **Glia env bindings** — what's callable inside the cell. Filesystem + data-plane access is via WASI path I/O (`load`, `import`, guest file + reads), not a separate `perform fs` surface. -`LocalOverride` (`src/vfs.rs:59-62`) is the only principled exception: -per-file host-local mounts checked before CID resolution, so private -content (identity keys, per-node secrets) never enters IPFS. Use -sparingly and only for genuinely host-private files. +Backend virtual mode now rejects targeted mounts, so host-local overrides are +not active in the backend mount path. Data-plane content for backend cells must +flow through `/ipfs` / `/ipns` root layers. Revocation = epoch advance + respawn. Classical ocap: you cannot un-hand a CID. Advance the epoch (RPC caps fail `staleEpoch`), kill and respawn the cell under a different root Atom — the new cell sees a different slice of the universe. -For the agent-facing view of all this (`fs/*`, `(schema cap)`, +For the agent-facing view of all this (WASI path I/O, `(schema cap)`, structured errors, attenuation strategies), see [capabilities.md](capabilities.md). diff --git a/doc/capabilities.md b/doc/capabilities.md index b8b7a268..82160719 100644 --- a/doc/capabilities.md +++ b/doc/capabilities.md @@ -77,46 +77,31 @@ appearing in the top-level graft list. Every capability is epoch-guarded: it fails with `staleEpoch` once the on-chain head advances, forcing a re-graft. -### Content access (no `fs` capability over RPC) +### Content access (WASI path I/O only) Cells do not receive an explicit filesystem capability over the membrane. Content access flows through the WASI virtual filesystem, -which the host backs with `CidTree`. Glia code reaches it via the -`fs` capability bound into the cell's env, invoked through `perform`: +which the host backs with `CidTree`. -- `(perform fs :read path)` — bytes -- `(perform fs :read-str path)` — UTF-8 string -- `(perform fs :ls path)` — list of `{:name :size :type}` maps -- `(perform fs :stat path)` — `{:size :type}` -- `(perform fs :exists? path)` — bool, never raises on missing paths +Use regular guest file I/O against filesystem paths: +- `(load "path")` for bytes in Glia +- `(perform import "module")` for module loading +- direct guest reads via WASI-aware code under `/ipfs//...` and + `/ipns//...` -Path resolution (`std/caps/src/lib.rs:resolve_fs_path`): absolute paths -including `/ipfs/` and `/ipns/` pass through to the WASI VFS; -relative paths resolve against `$WW_ROOT` (defaults to `/`). - -The `fs` cap is wired by the kernel after graft (`std/kernel/src/lib.rs`); -the shell and MCP cells wire it the same way (`std/shell/src/lib.rs`, -`std/mcp/src/lib.rs`). Restricting filesystem access for a particular -cell means not binding the `fs` handler in its env — the language layer -is the attenuation point, not a separate cap surface. +`(perform fs ...)` is legacy and returns a migration error. Keeping a separate +`perform` filesystem read surface created dual-path semantics and is being +removed. ## Local overrides -`LocalOverride` (`src/vfs.rs:59-62`) is the only principled exception -to "everything resolves through CIDs." It supports per-file or -per-directory host-local mounts that are checked **before** CID -resolution, so private content (the operator's identity key, -per-node secrets) never enters IPFS. - -``` -guest opens /etc/identity/key.pem - → CidTree::resolve_path - → check LocalOverride map first → matches → return ResolvedNode::LocalFile(host_path) - → never falls through to the CID walk -``` +Backend virtual mode rejects targeted mounts, so host-local overrides are +currently not part of the backend runtime surface. Publish content to IPFS/IPNS +and mount it as a root layer instead. -Use this exception sparingly. It is meant for genuinely host-private -files. Anything that should be reachable across nodes belongs in IPFS. +`LocalOverride` types remain in the codebase as implementation scaffolding for +future shell-local workflows, but they are not used by `ww run` backend mount +resolution in this mode. ## Capability lifecycle diff --git a/doc/shell.md b/doc/shell.md index 7c2b08d0..d3fa021b 100644 --- a/doc/shell.md +++ b/doc/shell.md @@ -76,15 +76,18 @@ The shell cell currently grafts these caps from its membrane (see ### Local effect handlers -The shell cell also wires three glia-only effect handlers that do not -go through a remote capability: +The shell cell wires Glia-only local helpers that do not go through a +remote capability: -- **`fs`** — read paths from the cell's WASI filesystem (reactive to - stem updates per [`architecture.md`](architecture.md)) - **`import`** — load other Glia source files +- **`load`** dispatch form — read bytes from the WASI filesystem - The kernel's built-in expressions: arithmetic, `let`, `if`, `defn`, etc. +`(perform fs ...)` is deprecated and returns a migration error. Filesystem +reads should go through normal WASI file I/O (`load`, `import`, and guest +file operations on `/ipfs/...` / `/ipns/...` paths). + ### Built-ins | Form | Description | diff --git a/src/cli/main.rs b/src/cli/main.rs index b723f4c0..44e79e63 100644 --- a/src/cli/main.rs +++ b/src/cli/main.rs @@ -101,16 +101,15 @@ enum Commands { /// Run a wetware environment. /// - /// Every positional argument is a mount: `source[:target]`. - /// Without `:target`, the source is mounted at `/` (image layer). - /// With `:target`, the source is overlaid at that guest path. + /// Every positional argument is a mount source mounted at `/` (image layer). + /// Targeted mounts (`source:/guest/path`) are rejected in backend virtual mode. /// /// Examples: /// ww run . # dev mode - /// ww run images/app ~/.ww/identity:/etc/identity - /// ww run /ipfs/QmHash ~/data:/var/data + /// ww run images/app + /// ww run /ipfs/QmHash /ipns/k51qzi5uqu5... Run { - /// Mount(s): `source` (image at /) or `source:/guest/path` (targeted). + /// Mount source(s) at `/` (image layers). #[arg(default_value = ".", value_name = "MOUNT")] mounts: Vec, @@ -130,7 +129,7 @@ enum Commands { #[arg(long)] wasm_debug: bool, - /// Path to an Ed25519 identity file. Sugar for PATH:/etc/identity mount. + /// Path to an Ed25519 identity file (host-side only; not a guest mount). /// Works well with direnv: `export WW_IDENTITY=~/.ww/identity` in .envrc. #[arg(long, env = "WW_IDENTITY", value_name = "PATH")] identity: Option, @@ -539,6 +538,7 @@ impl Commands { ipfs_url, } => { let mounts = ww::cell::mount::parse_args(&mount_args)?; + Self::validate_backend_mount_policy(&mounts)?; // Identity is passed separately — NOT as a mount. // The host reads it to create the signing key for the Membrane. // It must never enter the merged FHS tree (which is preopened @@ -1095,6 +1095,34 @@ wasip2::cli::command::export!({iface_name}Guest); } } + /// Backend policy: only root mounts are allowed. + /// + /// Targeted mounts (`source:/guest/path`) previously fed `LocalOverride`. + /// Backend virtual mode removes that path to enforce a single data-plane: + /// publish to IPFS/IPNS and mount as root layers. + fn validate_backend_mount_policy(mounts: &[ww::cell::mount::Mount]) -> Result<()> { + let targeted: Vec<&ww::cell::mount::Mount> = + mounts.iter().filter(|m| !m.is_root()).collect(); + if targeted.is_empty() { + return Ok(()); + } + + let mut details = String::new(); + for mount in targeted { + details.push_str(&format!( + "\n - {}:{}", + mount.source, + mount.target.display() + )); + } + + bail!( + "targeted mounts are not supported in backend virtual mode.\n\ + Use root image layers (`/ipfs/...`, `/ipns/...`, or local dirs) and publish data explicitly.\n\ + Offending mount(s):{details}" + ); + } + /// Generate a new Ed25519 identity secret. async fn keygen(output: Option) -> Result<()> { let sk = ww::keys::generate()?; @@ -2685,6 +2713,70 @@ mod tests { assert_eq!(ww::keys::encode(&loaded_sk), encoded); } + #[test] + fn test_validate_backend_mount_policy_accepts_root_mounts() { + let mounts = + ww::cell::mount::parse_args(&[".".to_string(), "/ipfs/bafybeigdyrzt".to_string()]) + .unwrap(); + Commands::validate_backend_mount_policy(&mounts).unwrap(); + } + + #[test] + fn test_validate_backend_mount_policy_rejects_targeted_mounts() { + let mounts = ww::cell::mount::parse_args(&[ + ".".to_string(), + "~/.ww/identity:/etc/identity".to_string(), + ]) + .unwrap(); + let err = Commands::validate_backend_mount_policy(&mounts).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("targeted mounts are not supported in backend virtual mode"), + "unexpected error: {msg}" + ); + assert!( + msg.contains("/etc/identity"), + "error should include offending target path: {msg}" + ); + } + + #[test] + fn test_run_command_rejects_targeted_mounts_preflight() { + let cmd = Commands::Run { + mounts: vec![".".to_string(), "~/.ww/identity:/etc/identity".to_string()], + listen: Vec::new(), + wasm_debug: false, + identity: None, + stem: None, + rpc_url: "http://127.0.0.1:8545".to_string(), + ws_url: "ws://127.0.0.1:8545".to_string(), + confirmation_depth: 6, + epoch_drain_secs: 1, + executor_threads: 0, + mcp: false, + http_listen: None, + http_dial: Vec::new(), + runtime_cache_policy: "shared".to_string(), + with_http_admin: None, + ipfs_url: "http://localhost:5001".to_string(), + }; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let err = rt.block_on(cmd.run()).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("targeted mounts are not supported in backend virtual mode"), + "unexpected error: {msg}" + ); + assert!( + msg.contains("/etc/identity"), + "error should include offending target path: {msg}" + ); + } + // ── Daemon service-file writer tests ────────────────────────────── // // These guard the `--http-listen` flag emission, which is the diff --git a/std/caps/src/lib.rs b/std/caps/src/lib.rs index d8ac53b9..02f527e0 100644 --- a/std/caps/src/lib.rs +++ b/std/caps/src/lib.rs @@ -460,169 +460,25 @@ pub fn make_routing_handler(routing: routing_capnp::routing::Client) -> Val { } } -/// Resolve a Glia-supplied path against the cell's root. +/// Legacy `fs` effect handler. /// -/// `/ipfs/` and `/ipns/` paths pass through unchanged — the -/// host's CidTree-backed VFS handles content addressing. Absolute paths -/// also pass through. Relative paths are resolved against `$WW_ROOT`, -/// falling back to `/` when unset. -fn resolve_fs_path(path: &str) -> String { - if path.starts_with('/') { - return path.to_string(); - } - let root = std::env::var("WW_ROOT").unwrap_or_else(|_| "/".to_string()); - if root.ends_with('/') { - format!("{root}{path}") - } else { - format!("{root}/{path}") - } -} - -/// Map a `std::fs::FileType` to a Glia keyword. -fn file_type_keyword(ft: std::fs::FileType) -> Val { - if ft.is_dir() { - Val::Keyword("dir".into()) - } else if ft.is_symlink() { - Val::Keyword("symlink".into()) - } else { - Val::Keyword("file".into()) - } -} - -/// Build a `Val::Map` describing a single filesystem entry. -fn entry_map(name: String, size: u64, ft: std::fs::FileType) -> Val { - Val::Map(glia::ValMap::from_pairs(vec![ - (Val::Keyword("name".into()), Val::Str(name)), - (Val::Keyword("size".into()), Val::Int(size as i64)), - (Val::Keyword("type".into()), file_type_keyword(ft)), - ])) -} - -/// Glia effect handler for the `fs` capability — content-addressed -/// filesystem read access via the WASI VFS (CidTree-backed at runtime). -/// -/// Methods: -/// - `:read path` → `Val::Bytes` (raw file contents) -/// - `:read-str path` → `Val::Str` (UTF-8 decoded; structured error on invalid UTF-8) -/// - `:ls path` → `Val::List` (entries `{:name :size :type}` where type ∈ #{:file :dir :symlink}) -/// - `:stat path` → `Val::Map` (single-entry map: `{:size :type}`) -/// - `:exists? path` → `Val::Bool` (false on missing path; never errors) -/// -/// Path resolution (see `resolve_fs_path`): -/// - Absolute paths (incl. `/ipfs/...`, `/ipns/...`) pass through to -/// the WASI VFS -/// - Relative paths are resolved against `$WW_ROOT` (falls back to `/`) -/// -/// Replaces the prior `make_ipfs_handler` (which exposed `:cat`/`:ls` -/// under the `ipfs` cap name). Cap-name change `ipfs` → `fs` is part of -/// the same cleanup: post-#415/#416, content access is uniformly via -/// the content-addressed VFS, not a separate IPFS surface. +/// `perform fs` data-plane reads are removed in favor of direct WASI file I/O +/// (`load`, `import`, and normal guest file reads against `/ipfs` / `/ipns`). +/// The handler remains only to produce a migration-grade error when older code +/// attempts `(perform fs ...)`. pub fn make_fs_handler() -> Val { Val::AsyncNativeFn { name: "fs-handler".into(), func: Rc::new(move |args: Vec| { Box::pin(async move { let (method, rest) = extract_method(&args[0])?; - let resume = &args[1]; - - let path_arg = |method_label: &str| -> Result { - match rest.first() { - Some(Val::Str(s)) => Ok(s.clone()), - Some(other) => Err(glia::error::type_mismatch( - &format!("fs :{method_label}"), - "path string", - other, - )), - None => Err(glia::error::arity( - &format!("fs :{method_label}"), - "1", - rest.len(), - )), - } - }; - - match method { - "read" => { - let path = path_arg("read")?; - let resolved = resolve_fs_path(&path); - match std::fs::read(&resolved) { - Ok(bytes) => call_resume(resume, Val::Bytes(bytes)), - Err(e) => Err(glia::error::cap_call( - "fs", - "read", - format!("{resolved}: {e}"), - )), - } - } - "read-str" => { - let path = path_arg("read-str")?; - let resolved = resolve_fs_path(&path); - let bytes = std::fs::read(&resolved).map_err(|e| { - glia::error::cap_call("fs", "read-str", format!("{resolved}: {e}")) - })?; - match String::from_utf8(bytes) { - Ok(s) => call_resume(resume, Val::Str(s)), - Err(e) => Err(glia::error::cap_call( - "fs", - "read-str", - format!("{resolved}: invalid UTF-8: {e}"), - )), - } - } - "ls" => { - let path = path_arg("ls")?; - let resolved = resolve_fs_path(&path); - match std::fs::read_dir(&resolved) { - Ok(entries) => { - let items: Vec = entries - .filter_map(|e| { - let e = e.ok()?; - let meta = e.metadata().ok()?; - let name = - e.file_name().to_string_lossy().to_string(); - Some(entry_map(name, meta.len(), meta.file_type())) - }) - .collect(); - call_resume(resume, Val::List(items)) - } - Err(e) => Err(glia::error::cap_call( - "fs", - "ls", - format!("{resolved}: {e}"), - )), - } - } - "stat" => { - let path = path_arg("stat")?; - let resolved = resolve_fs_path(&path); - match std::fs::metadata(&resolved) { - Ok(meta) => { - let m = Val::Map(glia::ValMap::from_pairs(vec![ - (Val::Keyword("size".into()), Val::Int(meta.len() as i64)), - (Val::Keyword("type".into()), file_type_keyword(meta.file_type())), - ])); - call_resume(resume, m) - } - Err(e) => Err(glia::error::cap_call( - "fs", - "stat", - format!("{resolved}: {e}"), - )), - } - } - "exists?" => { - let path = path_arg("exists?")?; - let resolved = resolve_fs_path(&path); - // exists? never errors — missing path is just `false`. - let exists = std::fs::metadata(&resolved).is_ok(); - call_resume(resume, Val::Bool(exists)) - } - other => Err(glia::error::cap_call( - "fs", - other, - format!("unknown method :{other}"), - )), - } + let _resume = &args[1]; + let _ = rest; + Err(glia::error::cap_call( + "fs", + method, + "deprecated: `(perform fs ...)` is removed; use WASI path I/O (`load`, `import`, or direct file reads under /ipfs|/ipns)".to_string(), + )) }) }), } @@ -654,7 +510,7 @@ pub fn wrap_with_handlers(form: &Val, extra_caps: &[&str]) -> Val { // Wrap in cap handlers (innermost to outermost) // Core caps first, then any extras - let mut caps: Vec<&str> = vec!["import", "routing", "fs", "host"]; + let mut caps: Vec<&str> = vec!["import", "routing", "host"]; for extra in extra_caps { caps.insert(0, extra); } @@ -940,7 +796,7 @@ mod tests { fn wrap_with_handlers_no_extras() { let form = Val::Int(42); let wrapped = wrap_with_handlers(&form, &[]); - // Should be nested: host(fs(routing(import(:load(42))))) + // Should be nested: host(routing(import(:load(42)))) // Outermost is host match &wrapped { Val::List(items) => { @@ -967,13 +823,7 @@ mod tests { } } - // -- make_fs_handler -- - // - // Drive the handler against real local files in a tempdir. In a WASM - // cell std::fs would be backed by the WASI VFS (CidTree at runtime); - // here it's the host's real FS, but the handler logic is identical. - // Each test sets WW_ROOT to the tempdir so relative paths resolve - // against it without leaking into the host's actual root. + // -- make_fs_handler (legacy deprecation behavior) -- /// Drive an `AsyncNativeFn` synchronously by polling its future to /// completion on a fresh single-threaded tokio runtime. @@ -1001,188 +851,29 @@ mod tests { } } - /// Cargo runs tests in parallel, but `WW_ROOT` is a process-wide env - /// var and `make_fs_handler` reads it at call time. Without - /// serialization, two tests setting `WW_ROOT` at the same time race - /// and one fixture's path resolves against the other's tempdir. - /// All `with_fixture` callers acquire this mutex. - static FS_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - - /// Set up a tempdir, populate it with a known fixture, point WW_ROOT - /// at it, run the closure, then restore WW_ROOT. Serialized via - /// `FS_TEST_LOCK` so concurrent tests don't clobber each other's - /// env-var state. - fn with_fixture(f: F) { - let _guard = FS_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - - let dir = tempfile::tempdir().expect("tempdir"); - std::fs::write(dir.path().join("hello.txt"), b"hi").unwrap(); - std::fs::write(dir.path().join("data.bin"), [0xff_u8, 0xfe, 0xfd]).unwrap(); - std::fs::create_dir(dir.path().join("sub")).unwrap(); - std::fs::write(dir.path().join("sub").join("nested.txt"), b"nested").unwrap(); - - let prev = std::env::var("WW_ROOT").ok(); - std::env::set_var("WW_ROOT", dir.path()); - f(dir.path()); - match prev { - Some(v) => std::env::set_var("WW_ROOT", v), - None => std::env::remove_var("WW_ROOT"), - } - } - - #[test] - fn fs_read_returns_bytes() { - let handler = make_fs_handler(); - with_fixture(|_| { - let result = drive_handler(&handler, "read", Some("hello.txt")).unwrap(); - assert_eq!(result, Val::Bytes(b"hi".to_vec())); - }); - } - #[test] - fn fs_read_str_returns_utf8_string() { + fn fs_read_returns_deprecation_error() { let handler = make_fs_handler(); - with_fixture(|_| { - let result = drive_handler(&handler, "read-str", Some("hello.txt")).unwrap(); - assert_eq!(result, Val::Str("hi".into())); - }); - } - - #[test] - fn fs_read_str_invalid_utf8_yields_structured_error() { - let handler = make_fs_handler(); - with_fixture(|_| { - let err = drive_handler(&handler, "read-str", Some("data.bin")).unwrap_err(); - assert_eq!( - glia::error::type_tag(&err), - Some(glia::error::tag::CAP_CALL), - "expected cap-call-failed tag, got: {err:?}" - ); - }); - } - - #[test] - fn fs_ls_returns_entries_with_name_size_type() { - let handler = make_fs_handler(); - with_fixture(|_| { - let result = drive_handler(&handler, "ls", Some("")).unwrap(); - let items = match result { - Val::List(xs) => xs, - other => panic!("expected list, got {other:?}"), - }; - assert_eq!(items.len(), 3, "fixture has 3 entries (2 files, 1 dir)"); - - // Each entry should be a map with :name :size :type - for entry in &items { - let m = match entry { - Val::Map(m) => m, - other => panic!("expected map, got {other:?}"), - }; - assert!(m.contains_key(&Val::Keyword("name".into()))); - assert!(m.contains_key(&Val::Keyword("size".into()))); - let ty = m - .get(&Val::Keyword("type".into())) - .expect(":type field present"); - assert!(matches!( - ty, - Val::Keyword(k) if k == "file" || k == "dir" || k == "symlink" - )); - } - }); - } - - #[test] - fn fs_stat_returns_size_and_type() { - let handler = make_fs_handler(); - with_fixture(|_| { - let result = drive_handler(&handler, "stat", Some("hello.txt")).unwrap(); - let m = match result { - Val::Map(m) => m, - other => panic!("expected map, got {other:?}"), - }; - assert_eq!( - m.get(&Val::Keyword("size".into())), - Some(&Val::Int(2)), - "hello.txt is 2 bytes" - ); - assert_eq!( - m.get(&Val::Keyword("type".into())), - Some(&Val::Keyword("file".into())) - ); - }); - } - - #[test] - fn fs_stat_dir_reports_dir_type() { - let handler = make_fs_handler(); - with_fixture(|_| { - let result = drive_handler(&handler, "stat", Some("sub")).unwrap(); - let m = match result { - Val::Map(m) => m, - other => panic!("expected map, got {other:?}"), - }; - assert_eq!( - m.get(&Val::Keyword("type".into())), - Some(&Val::Keyword("dir".into())) - ); - }); - } - - #[test] - fn fs_exists_returns_true_for_present_path() { - let handler = make_fs_handler(); - with_fixture(|_| { - let result = drive_handler(&handler, "exists?", Some("hello.txt")).unwrap(); - assert_eq!(result, Val::Bool(true)); - }); - } - - #[test] - fn fs_exists_returns_false_for_missing_path_without_error() { - let handler = make_fs_handler(); - with_fixture(|_| { - // Critical: missing paths must NOT error — the whole point of - // exists? is to query without raising. - let result = drive_handler(&handler, "exists?", Some("does-not-exist.txt")).unwrap(); - assert_eq!(result, Val::Bool(false)); - }); - } - - #[test] - fn fs_read_missing_path_yields_structured_error() { - let handler = make_fs_handler(); - with_fixture(|_| { - let err = drive_handler(&handler, "read", Some("nope.txt")).unwrap_err(); - assert_eq!( - glia::error::type_tag(&err), - Some(glia::error::tag::CAP_CALL), - "expected cap-call-failed tag, got: {err:?}" - ); - }); - } - - #[test] - fn fs_unknown_method_yields_structured_error() { - let handler = make_fs_handler(); - with_fixture(|_| { - let err = drive_handler(&handler, "explode", Some("anything")).unwrap_err(); - assert_eq!( - glia::error::type_tag(&err), - Some(glia::error::tag::CAP_CALL) - ); - // Message should mention the unknown method name to help debugging. - let msg = glia::error::message(&err).unwrap_or(""); - assert!(msg.contains("explode"), "msg should mention method: {msg}"); - }); + let err = drive_handler(&handler, "read", Some("/ipfs/bafy.../foo.txt")).unwrap_err(); + assert_eq!( + glia::error::type_tag(&err), + Some(glia::error::tag::CAP_CALL), + "expected cap-call-failed tag, got: {err:?}" + ); + let msg = glia::error::message(&err).unwrap_or(""); + assert!( + msg.contains("deprecated") && msg.contains("perform fs"), + "expected migration error message, got: {msg}" + ); } #[test] - fn fs_missing_path_arg_yields_arity_error() { + fn fs_unknown_method_still_returns_deprecation_error() { let handler = make_fs_handler(); - let err = drive_handler(&handler, "read", None).unwrap_err(); + let err = drive_handler(&handler, "explode", Some("anything")).unwrap_err(); assert_eq!( glia::error::type_tag(&err), - Some(glia::error::tag::ARITY) + Some(glia::error::tag::CAP_CALL) ); } } diff --git a/std/mcp/src/lib.rs b/std/mcp/src/lib.rs index 294363d2..19362dd7 100644 --- a/std/mcp/src/lib.rs +++ b/std/mcp/src/lib.rs @@ -26,7 +26,7 @@ use wasip2::exports::cli::run::Guest; // Shared effect handler factories from the caps crate. use caps::{ - eval_load, get_graft_cap, make_fs_handler, make_host_handler, make_import_handler, + eval_load, get_graft_cap, make_host_handler, make_import_handler, make_routing_handler, routing_capnp, stem_capnp, system_capnp, wrap_with_handlers, }; @@ -736,9 +736,8 @@ fn run_impl() { // with-effect-handler can match on it. { let mut e = env.borrow_mut(); - let cap_handlers: [(&str, Val); 4] = [ + let cap_handlers: [(&str, Val); 3] = [ ("host", make_host_handler(host)), - ("fs", make_fs_handler()), ("routing", make_routing_handler(routing)), ("import", make_import_handler()), ]; diff --git a/std/shell/src/lib.rs b/std/shell/src/lib.rs index 50ab3ad1..a3331293 100644 --- a/std/shell/src/lib.rs +++ b/std/shell/src/lib.rs @@ -23,7 +23,7 @@ use wasip2::exports::cli::run::Guest; // Shared effect handler factories. use caps::{ - eval_load, get_graft_cap, make_fs_handler, make_host_handler, make_import_handler, + eval_load, get_graft_cap, make_host_handler, make_import_handler, make_routing_handler, routing_capnp, stem_capnp, system_capnp, wrap_with_handlers, }; @@ -257,9 +257,8 @@ fn run_impl() { // 2. Bind cap values + effect handlers into the environment. { let mut env = env.borrow_mut(); - let caps: [(&str, Val); 4] = [ + let caps: [(&str, Val); 3] = [ ("host", make_host_handler(host)), - ("fs", make_fs_handler()), ("routing", make_routing_handler(routing)), ("import", make_import_handler()), ];