diff --git a/crates/ide-host/src/plugins/api.rs b/crates/ide-host/src/plugins/api.rs index 418ace365..e4f6d961a 100644 --- a/crates/ide-host/src/plugins/api.rs +++ b/crates/ide-host/src/plugins/api.rs @@ -1287,6 +1287,38 @@ impl HostApi for PluginApi { let value = user.get(key).filter(|v| setting.accepts(v)).cloned().unwrap_or(setting.default); serde_json::to_string(&value).ok() } + + fn read_text_file(&self, real: &Path, max: usize) -> Result { + // Unsaved editor contents win, as they do for project files. + if let Some(text) = self.shared.workspace().and_then(|w| w.buffer_text(real)) { + return Ok(text); + } + let meta = std::fs::metadata(real).map_err(|e| not_a_file(real, e))?; + if !meta.is_file() { + return Err(HostError::Invalid(format!("{}: not a file", real.display()))); + } + if meta.len() > max as u64 { + return Err(too_large(real, max)); + } + let loaded = ide_vfs::load_text(real).map_err(failed)?; + if loaded.binary { + return Err(HostError::Invalid(format!("{}: not text; read it as bytes", real.display()))); + } + Ok(loaded.text) + } +} + +/// A file that is not there, or cannot be read, as the plugin sees it. +fn not_a_file(path: &Path, e: std::io::Error) -> HostError { + match e.kind() { + std::io::ErrorKind::NotFound => HostError::NotFound(format!("{}: no such file", path.display())), + std::io::ErrorKind::PermissionDenied => HostError::Denied(format!("{}: {}", path.display(), "not readable")), + _ => failed(e), + } +} + +fn too_large(path: &Path, max: usize) -> HostError { + HostError::Invalid(format!("{}: file is larger than {} MiB", path.display(), max >> 20)) } /// [`PluginWorkspace`] over the desktop host's open projects. diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index e961f51b7..a34dec395 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -126,6 +126,19 @@ fn misbehave_pkg() -> PathBuf { repo().join("crates/ide-plugin/tests/fixtures/misbehave") } +/// `misbehave` with `fs.read:` added to its capabilities. +fn misbehave_granted(dir: &Path, granted: &Path) -> PathBuf { + let pkg = dir.join("misbehave-granted"); + copy_dir(&misbehave_pkg(), &pkg); + let manifest = pkg.join("plugin.toml"); + let text = std::fs::read_to_string(&manifest).unwrap(); + let path = granted.to_string_lossy().replace('\\', "/"); + let required = format!("required = [\"project.read\", \"fs.read:{path}\"]"); + let text = text.replace("required = [\"project.read\"]", &required); + std::fs::write(&manifest, text).unwrap(); + pkg +} + /// Whether `f` holds within 5 s. A call a slow runner keeps waiting behind /// another (or behind a new instance starting) holds once it caught up. fn eventually(mut f: impl FnMut() -> bool) -> bool { @@ -374,6 +387,64 @@ fn read_file_is_confined_to_the_project() { assert_eq!(f.status("misbehave"), PluginStatus::Active); } +/// `fs.read` reaches the granted paths and nothing else, through the real +/// component: the same checks the runtime makes, against a real file system. +#[test] +fn fs_read_reaches_only_the_granted_paths() { + let f = fixture(Limits::default()); + let shared = f._tmp.path().join("shared"); + std::fs::create_dir_all(&shared).unwrap(); + let notes = shared.join("notes.txt"); + std::fs::write(¬es, "outside the project\n").unwrap(); + // A sibling whose name starts with the grant's: not covered by it. + let sibling = f._tmp.path().join("shared-other"); + std::fs::create_dir_all(&sibling).unwrap(); + let other = sibling.join("notes.txt"); + std::fs::write(&other, "not the same directory\n").unwrap(); + let secret = f._tmp.path().join("secret.txt"); + f.install(&misbehave_granted(f._tmp.path(), &shared)); + + assert_eq!(f.run("misbehave", "fsread", Some(notes.to_str().unwrap())).unwrap().unwrap(), "outside the project\n"); + let err = f.run("misbehave", "fsread", Some(secret.to_str().unwrap())).unwrap_err(); + assert!(err.contains("fs.read:"), "the refusal names what to grant: {err}"); + let err = f.run("misbehave", "fsread", Some(other.to_str().unwrap())).unwrap_err(); + assert!(err.contains("fs.read:"), "a name prefix grants nothing: {err}"); + let err = f.run("misbehave", "fsread", Some("notes.txt")).unwrap_err(); + assert!(err.contains("absolute"), "{err}"); + let err = f.run("misbehave", "fsread", Some(shared.to_str().unwrap())).unwrap_err(); + assert!(err.contains("not a file"), "{err}"); + let missing = shared.join("gone.txt"); + let err = f.run("misbehave", "fsread", Some(missing.to_str().unwrap())).unwrap_err(); + assert!(err.contains("no such file"), "{err}"); + // A refusal is the plugin's to handle, like any other error it gets. + let info = f.host.info_of("misbehave").unwrap(); + assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}"); +} + +/// `fs.read-dir` lists a granted directory, sorted, and refuses the rest. +#[test] +fn fs_read_dir_lists_a_granted_directory() { + let f = fixture(Limits::default()); + let shared = f._tmp.path().join("shared"); + std::fs::create_dir_all(shared.join("zeta")).unwrap(); + std::fs::write(shared.join("beta.txt"), "b").unwrap(); + std::fs::write(shared.join("alpha.txt"), "a").unwrap(); + f.install(&misbehave_granted(f._tmp.path(), &shared)); + + let listing = f.run("misbehave", "read-dir", Some(shared.to_str().unwrap())).unwrap().unwrap(); + let lines: Vec<&str> = listing.lines().collect(); + assert_eq!(lines, vec!["file alpha.txt", "file beta.txt", "dir zeta"], "{listing}"); + + // A file is not a directory, and a path outside the grant is refused. + let file = shared.join("alpha.txt"); + let err = f.run("misbehave", "read-dir", Some(file.to_str().unwrap())).unwrap_err(); + assert!(err.contains("not a directory"), "{err}"); + let err = f.run("misbehave", "read-dir", Some(f._tmp.path().to_str().unwrap())).unwrap_err(); + assert!(err.contains("fs.read:"), "{err}"); + let info = f.host.info_of("misbehave").unwrap(); + assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}"); +} + /// A missing capability is an error the plugin can handle, not a trap. #[test] fn ungranted_capabilities_are_denied_not_trapped() { @@ -650,7 +721,7 @@ fn idle_instances_are_reclaimed_and_schedules_reactivate() { assert_eq!(f.host.instances(), 0); let info = f.host.info_of("misbehave").unwrap(); assert_eq!((info.status, info.stats.reclaims), (PluginStatus::Inactive, 1)); - assert_eq!(f.command_titles("misbehave").len(), 10, "contributions stay"); + assert_eq!(f.command_titles("misbehave").len(), 12, "contributions stay"); assert_eq!(f.run("misbehave", "echo", Some("y")).unwrap().as_deref(), Some("y")); assert_eq!(f.host.info_of("misbehave").unwrap().stats.activations, 2); diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index 35f154751..80dbf6df5 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -206,6 +206,10 @@ pub trait HostApi: Send + Sync { fn locale(&self) -> String; /// A setting of the plugin as JSON (project over global over the schema default). fn setting(&self, root: Option<&Path>, key: &str) -> Option; + /// Reads and decodes a text file outside the project, whose real path an + /// `fs.read` grant already covers: the unsaved buffer wins, then the disk. + /// `max` bounds the bytes read. + fn read_text_file(&self, real: &Path, max: usize) -> Result; } /// Shared compilation engine plus an epoch ticker thread for wall-clock limits. diff --git a/crates/ide-plugin/src/sandbox.rs b/crates/ide-plugin/src/sandbox.rs index 71a03d615..523a2b2f4 100644 --- a/crates/ide-plugin/src/sandbox.rs +++ b/crates/ide-plugin/src/sandbox.rs @@ -1,8 +1,12 @@ //! Capability checks for host functions that touch the file system. +use ide_extension::Capability; use std::path::{Path, PathBuf}; /// Largest file `read-file` returns. +/// The most entries `fs.read-dir` hands over: the same cap as `find-files`. +pub const MAX_LIST_ENTRIES: usize = 4096; + pub const MAX_READ_BYTES: u64 = 16 << 20; /// The canonical `root` and the absolute form of `path` (absolute, or @@ -124,6 +128,72 @@ pub fn read_text(real: &Path) -> Result { String::from_utf8(bytes).map_err(|_| format!("{}: not valid UTF-8 text", real.display())) } +/// Why a read outside the project was refused. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Refusal { + /// No `fs.read:` grant covers the file; names the capability to declare. + Denied(String), + /// The path cannot be read as asked. + Invalid(String), +} + +/// Expands a leading `~` or `~/` to the user's home; everything else as written. +fn expand_tilde(path: &str) -> PathBuf { + let rest = if path == "~" { + Some("") + } else if let Some(rest) = path.strip_prefix("~/") { + Some(rest) + } else { + path.strip_prefix("~\\") + }; + match rest { + Some(rest) => std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(|home| PathBuf::from(home).join(rest)) + .unwrap_or_else(|| PathBuf::from(path)), + None => PathBuf::from(path), + } +} + +/// The real path of `path` when an `fs.read:` grant covers it. +/// +/// The grant is compared with the **real** path, so a symlink pointing out of +/// a granted tree is refused, and `fs.read:/srv/app` covers `/srv/app/x` but +/// not `/srv/application`. Windows' `\\?\` prefix (what `canonicalize` adds) +/// is stripped from the file side so a manifest may spell `C:\Program Files\X`. +pub fn resolve_granted(granted: &[Capability], path: &str) -> Result { + let asked = expand_tilde(path); + if !asked.is_absolute() { + return Err(Refusal::Invalid(format!("{path}: use an absolute path (or ~/…)"))); + } + let real = asked.canonicalize().map_err(|_| Refusal::Invalid(format!("{path}: no such file")))?; + let plain = strip_verbatim(&real); + let cap = Capability::FsRead(plain.clone()); + let mut covered = false; + for grant in granted { + let Capability::FsRead(base) = grant else { continue }; + // The grant is compared where it really is too: `/tmp/x` is `/private/tmp/x` on macOS. + let base_real = expand_tilde(base).canonicalize().map(|p| strip_verbatim(&p)).unwrap_or_else(|_| base.clone()); + if !Capability::FsRead(base_real).covers(&cap) { + continue; + } + // A directory that is its own parent is a root: `/`, `C:\`. + if Path::new(base).parent().is_none() { + return Err(Refusal::Invalid(format!( + "{plain}: fs.read:{base} covers a whole file system; declare a directory" + ))); + } + covered = true; + } + if covered { Ok(real) } else { Err(Refusal::Denied(cap.to_string())) } +} + +/// `\\?\C:\…` (what `canonicalize` returns on Windows) as `C:\…`. +fn strip_verbatim(path: &Path) -> String { + let text = path.to_string_lossy(); + text.strip_prefix(r"\\?\").unwrap_or(&text).to_owned() +} + #[cfg(test)] mod tests { use super::*; @@ -226,4 +296,128 @@ mod tests { let p = resolve_in_root(tmp.path(), "b.bin").unwrap(); assert!(read_text(&p).unwrap_err().contains("UTF-8")); } + + /// The grant a manifest author would write for `dir`: the plain path, no + /// `\\?\` prefix. + fn grant_for(dir: &Path) -> Capability { + Capability::FsRead(strip_verbatim(&dir.canonicalize().unwrap())) + } + + fn text_at(dir: &Path, name: &str, body: &str) -> PathBuf { + let file = dir.join(name); + std::fs::write(&file, body).unwrap(); + file.canonicalize().unwrap() + } + + #[test] + fn a_file_outside_every_grant_is_denied_naming_the_capability() { + let tmp = tempfile::tempdir().unwrap(); + let file = text_at(tmp.path(), "app.properties", "name=YonBIP\n"); + let err = resolve_granted(&[], file.to_str().unwrap()).unwrap_err(); + match err { + Refusal::Denied(cap) => assert!(cap.starts_with("fs.read:"), "{cap}"), + other => panic!("expected denied, got {other:?}"), + } + // A grant on another tree does not cover it either. + let elsewhere = tempfile::tempdir().unwrap(); + let err = resolve_granted(&[grant_for(elsewhere.path())], file.to_str().unwrap()).unwrap_err(); + assert!(matches!(err, Refusal::Denied(_)), "{err:?}"); + } + + #[test] + fn a_grant_covers_what_is_under_it_but_not_a_sibling_with_its_prefix() { + let tmp = tempfile::tempdir().unwrap(); + let granted = tmp.path().join("seeyon"); + std::fs::create_dir_all(granted.join("conf")).unwrap(); + let file = text_at(&granted.join("conf"), "server.xml", ""); + let cap = grant_for(&granted); + + assert_eq!(resolve_granted(std::slice::from_ref(&cap), file.to_str().unwrap()).unwrap(), file); + // The directory itself, asked for explicitly, is covered too. + assert!(resolve_granted(std::slice::from_ref(&cap), granted.to_str().unwrap()).is_ok()); + + // `/x/seeyon` must not cover `/x/seeyon-old/app.properties`. + let sibling = tmp.path().join("seeyon-old"); + std::fs::create_dir_all(&sibling).unwrap(); + let other = text_at(&sibling, "app.properties", "x=1"); + assert!(matches!(resolve_granted(&[cap], other.to_str().unwrap()), Err(Refusal::Denied(_)))); + } + + #[cfg(unix)] + #[test] + fn a_grant_declared_through_a_symlinked_directory_covers_its_files() { + // `/tmp/tool` on macOS is `/private/tmp/tool`: the grant is resolved too. + let tmp = tempfile::tempdir().unwrap(); + let real = tmp.path().join("real"); + std::fs::create_dir_all(&real).unwrap(); + let file = text_at(&real, "a.txt", "a"); + let link = tmp.path().join("link"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + let cap = Capability::FsRead(link.to_string_lossy().into_owned()); + assert!(resolve_granted(std::slice::from_ref(&cap), link.join("a.txt").to_str().unwrap()).is_ok()); + assert!(resolve_granted(&[cap], file.to_str().unwrap()).is_ok()); + } + + #[test] + fn a_granted_symlink_target_must_still_be_covered() { + // The grant is compared with the real path, so a link inside a granted + // tree cannot be used to read outside it. + let tmp = tempfile::tempdir().unwrap(); + let granted = tmp.path().join("granted"); + std::fs::create_dir_all(&granted).unwrap(); + let secret = text_at(tmp.path(), "secret.txt", "s"); + #[cfg(unix)] + { + std::os::unix::fs::symlink(&secret, granted.join("link")).unwrap(); + let cap = grant_for(&granted); + let err = resolve_granted(&[cap], granted.join("link").to_str().unwrap()).unwrap_err(); + assert!(matches!(err, Refusal::Denied(_)), "{err:?}"); + } + #[cfg(windows)] + { + // Creating symlinks needs a privilege tests do not have; hard links + // to the same file are covered by the parent directory anyway, so + // only the textual check is exercised here. + let _ = secret; + assert!(resolve_granted(&[grant_for(&granted)], granted.to_str().unwrap()).is_ok()); + } + } + + #[test] + fn a_path_must_be_absolute_and_exist() { + let tmp = tempfile::tempdir().unwrap(); + let cap = grant_for(tmp.path()); + let err = resolve_granted(std::slice::from_ref(&cap), "app.properties").unwrap_err(); + assert!(matches!(err, Refusal::Invalid(ref m) if m.contains("absolute")), "{err:?}"); + let err = resolve_granted(&[cap], tmp.path().join("missing").to_str().unwrap()).unwrap_err(); + assert!(matches!(err, Refusal::Invalid(ref m) if m.contains("no such file")), "{err:?}"); + } + + #[test] + fn a_root_grant_is_refused() { + let tmp = tempfile::tempdir().unwrap(); + let file = text_at(tmp.path(), "a.txt", "hi"); + #[cfg(windows)] + let cap = Capability::FsRead(format!("{}\\", std::env::var("SystemDrive").unwrap_or_else(|_| "C:".into()))); + #[cfg(unix)] + let cap = Capability::FsRead("/".into()); + let err = resolve_granted(&[cap], file.to_str().unwrap()).unwrap_err(); + assert!(matches!(err, Refusal::Invalid(ref m) if m.contains("whole file system")), "{err:?}"); + } + + #[test] + fn a_tilde_starts_at_the_users_home() { + let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")); + if home.is_none() { + return; + } + let expanded = expand_tilde("~/app.properties"); + assert!(expanded.is_absolute(), "{}", expanded.display()); + assert!(expanded.starts_with(home.unwrap())); + assert_eq!( + expand_tilde("~"), + PathBuf::from(std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).unwrap()) + ); + assert_eq!(expand_tilde("relative/x"), PathBuf::from("relative/x")); + } } diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs index bf9d92b09..caa8b2517 100644 --- a/crates/ide-plugin/src/wit_host.rs +++ b/crates/ide-plugin/src/wit_host.rs @@ -49,6 +49,7 @@ pub(crate) mod syntax_bindings { "openidea:plugin/net": super::bindings::openidea::plugin::net, "openidea:plugin/secrets": super::bindings::openidea::plugin::secrets, "openidea:plugin/process": super::bindings::openidea::plugin::process, + "openidea:plugin/fs": super::bindings::openidea::plugin::fs, "openidea:plugin/views": super::bindings::openidea::plugin::views, "openidea:plugin/settings": super::bindings::openidea::plugin::settings, }, @@ -77,6 +78,7 @@ pub(crate) mod providers_bindings { "openidea:plugin/net": super::bindings::openidea::plugin::net, "openidea:plugin/secrets": super::bindings::openidea::plugin::secrets, "openidea:plugin/process": super::bindings::openidea::plugin::process, + "openidea:plugin/fs": super::bindings::openidea::plugin::fs, "openidea:plugin/views": super::bindings::openidea::plugin::views, "openidea:plugin/settings": super::bindings::openidea::plugin::settings, }, @@ -105,6 +107,7 @@ pub(crate) mod views_bindings { "openidea:plugin/net": super::bindings::openidea::plugin::net, "openidea:plugin/secrets": super::bindings::openidea::plugin::secrets, "openidea:plugin/process": super::bindings::openidea::plugin::process, + "openidea:plugin/fs": super::bindings::openidea::plugin::fs, "openidea:plugin/views": super::bindings::openidea::plugin::views, "openidea:plugin/settings": super::bindings::openidea::plugin::settings, }, @@ -112,7 +115,7 @@ pub(crate) mod views_bindings { } use bindings::openidea::plugin::{ - commit, diagnostics, editor, events, invocation, log, net, process, secrets, settings, storage, tasks, + commit, diagnostics, editor, events, fs, invocation, log, net, process, secrets, settings, storage, tasks, types as wit, ui, vcs, views, workspace, }; @@ -193,6 +196,12 @@ impl StoreData { if self.granted.iter().any(|g| g.covers(&cap)) { Ok(()) } else { Err(wit::Error::Denied(cap.to_string())) } } + /// `path` as the real path an `fs.read:…` grant covers, or what the plugin + /// should be told instead (`denied` naming the capability to declare). + fn granted_file(&self, path: &str) -> Result { + sandbox::resolve_granted(&self.granted, path).map_err(Into::into) + } + fn client(&self) -> Option<&str> { self.invocation.client.as_deref() } @@ -533,25 +542,7 @@ impl workspace::HostProject for StoreData { return Ok(Err(e)); } }; - let meta = match std::fs::symlink_metadata(&real) { - Ok(m) => m, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - return Ok(Err(wit::Error::NotFound(format!("{path}: no such file")))); - } - Err(e) => return Ok(Err(wit::Error::Failed(e.to_string()))), - }; - let kind = if meta.is_file() { - workspace::FileKind::File - } else if meta.is_dir() { - workspace::FileKind::Directory - } else if meta.is_symlink() { - workspace::FileKind::Symlink - } else { - workspace::FileKind::Other - }; - let modified = - meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs()); - Ok(Ok(workspace::FileStat { kind, size: meta.len(), modified })) + Ok(file_stat(&real, &path)) } fn find_files(&mut self, this: Resource, glob: String, limit: u32) -> Fallible> { @@ -991,6 +982,104 @@ impl settings::Host for StoreData { impl views_bindings::openidea::plugin::view_types::Host for StoreData {} +fn file_kind(t: std::fs::FileType) -> workspace::FileKind { + if t.is_file() { + workspace::FileKind::File + } else if t.is_dir() { + workspace::FileKind::Directory + } else if t.is_symlink() { + workspace::FileKind::Symlink + } else { + workspace::FileKind::Other + } +} + +/// `project.stat` and `fs.stat`: the metadata of `real`, named `shown` in errors. +fn file_stat(real: &Path, shown: &str) -> Result { + let meta = std::fs::symlink_metadata(real).map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => wit::Error::NotFound(format!("{shown}: no such file")), + _ => wit::Error::Failed(e.to_string()), + })?; + let modified = meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs()); + Ok(workspace::FileStat { kind: file_kind(meta.file_type()), size: meta.len(), modified }) +} + +/// A refused `fs.read` as the plugin sees it: `denied` carries the capability +/// string, so the plugin can say exactly what its manifest lacks. +impl From for wit::Error { + fn from(refusal: sandbox::Refusal) -> Self { + match refusal { + sandbox::Refusal::Denied(cap) => Self::Denied(cap), + sandbox::Refusal::Invalid(message) => Self::Invalid(message), + } + } +} + +/// Files outside the open projects, gated by `fs.read:` grants. +impl fs::Host for StoreData { + fn read_file(&mut self, path: String) -> Fallible { + let real = match self.granted_file(&path) { + Ok(real) => real, + Err(e) => return Ok(Err(e)), + }; + Ok(self.host.read_text_file(&real, sandbox::MAX_READ_BYTES as usize).map_err(to_wit)) + } + + fn read_bytes(&mut self, path: String) -> Fallible> { + use std::io::Read; + let real = match self.granted_file(&path) { + Ok(real) => real, + Err(e) => return Ok(Err(e)), + }; + let max = sandbox::MAX_READ_BYTES; + let read = std::fs::File::open(&real).and_then(|file| { + let mut bytes = Vec::new(); + // One byte over: a file that grew past the limit is refused, not truncated. + file.take(max + 1).read_to_end(&mut bytes).map(|_| bytes) + }); + Ok(match read { + Ok(bytes) if bytes.len() as u64 > max => { + Err(wit::Error::Invalid(format!("{path}: larger than {} MiB", max >> 20))) + } + Ok(bytes) => Ok(bytes), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + Err(wit::Error::NotFound(format!("{path}: no such file"))) + } + Err(e) => Err(wit::Error::Failed(format!("{path}: {e}"))), + }) + } + + fn stat(&mut self, path: String) -> Fallible { + Ok(self.granted_file(&path).and_then(|real| file_stat(&real, &path))) + } + + fn read_dir(&mut self, path: String) -> Fallible> { + let real = match self.granted_file(&path) { + Ok(real) => real, + Err(e) => return Ok(Err(e)), + }; + let read = match std::fs::read_dir(&real) { + Ok(read) => read, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(Err(wit::Error::NotFound(format!("{path}: no such directory")))); + } + Err(_) if real.is_file() => return Ok(Err(wit::Error::Invalid(format!("{path}: not a directory")))), + Err(e) => return Ok(Err(wit::Error::Failed(format!("{path}: {e}")))), + }; + let mut entries: Vec = read + .flatten() + .take(sandbox::MAX_LIST_ENTRIES) + .map(|e| fs::DirEntry { + name: e.file_name().to_string_lossy().into_owned(), + kind: e.file_type().map_or(workspace::FileKind::Other, file_kind), + }) + .collect(); + // Sorted, so what a plugin picks doesn't depend on the disk's order. + entries.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(Ok(entries)) + } +} + /// Largest message between a plugin and its view's frame. const MAX_VIEW_MESSAGE: usize = 1 << 20; diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm index 610b9b9e7..06b81cb17 100644 Binary files a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources index 51d9c12f7..453b22e93 100644 --- a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources @@ -1 +1 @@ -73c4c46303636f64afe278feb3987937ad64be427783412ba8a5f67e966b9b3b +51c5348038e63c340b14111e3925f0fce96ea18a0a05293f4725dcc7072b7374 diff --git a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm index a85348d92..5e7760fe0 100644 Binary files a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources index 1b3352545..2df0fdd91 100644 --- a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources @@ -1 +1 @@ -4f66df6e4650759aebcf0a1b75550bbad09acf3ac50129f55c17edf5a35ebc90 +ab2496c849c79e82ec5826a133587a8c15f56aa62fb97ce8a08921f304c9e66a diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm index b01c003f5..fa3faf4cf 100644 Binary files a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources index 41ad8e0bb..9bf6a9c20 100644 --- a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources @@ -1 +1 @@ -83081a6195792336c98704cd270ef8373fd25e73d14a86b517f12eafe845dee1 +9930626a916fca1a4ffb041faff00eaaf66277c3f6e169b20ef40d8eee8becd0 diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm index 04486b8dd..6a760fe02 100644 Binary files a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources index f07847b94..3db54b4e0 100644 --- a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources @@ -1 +1 @@ -bb94b980261fc996c92bc8eceb5a89e8dbdaf26b5e4466223a86fe8abb9b63e0 +2a81c76cb9e2482f17a3cb080dbd1f9109c9305153cddcd232158c16b1a099c2 diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm index 626ee3d8b..9c353983a 100644 Binary files a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources index ea69603ef..ff44e303b 100644 --- a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources @@ -1 +1 @@ -1e94c7de6c49e8b821b4e60825385bef5954983e983530d91b2d765eb9dda9ef +602d2bb91e8f43a518602c9205f4f81cad9b1e4f65e23730e016662f47859156 diff --git a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm index 578f845e3..1618e48b5 100644 Binary files a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources index ac690f4be..e4c12ad55 100644 --- a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources @@ -1 +1 @@ -17137e31b7997f20beee8ddc1c30918428c4083cb7284ad505a4b888b1ba34c0 +072674781078ba19d74b8f2ceb023edc23a822ddfd32eb8865084d42fbeb2b71 diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm index ec949d20b..cdb25003e 100644 Binary files a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources index 228a0f7ef..bb624f51d 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources @@ -1 +1 @@ -85f4f7fed98833c0a70346f37279c0e27bac5f07cfeb50c4b7afab290a1bd87e +e3282ad3db4c73c0b3d43b7d87dab24c58132059fcfd22adeb8cc26ef054fe99 diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm index db85e4fe3..1781d6c05 100644 Binary files a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources index 3b42c446a..30c00cb58 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources @@ -1 +1 @@ -ca8e84a495a450c61c818e6807a71463ae17716a06ece993849239a24f08e2e6 +1dc4cf2e2c0ef41972479a3ada9e787cc1374c3372368297ef86036ad085fb1f diff --git a/crates/ide-plugin/tests/fixtures/misbehave/plugin.toml b/crates/ide-plugin/tests/fixtures/misbehave/plugin.toml index 1eae84d20..a037a0b92 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/plugin.toml +++ b/crates/ide-plugin/tests/fixtures/misbehave/plugin.toml @@ -33,6 +33,14 @@ title = "read" id = "root" title = "root" +[[contributes.commands]] +id = "fsread" +title = "fsread" + +[[contributes.commands]] +id = "read-dir" +title = "List a directory outside the project" + [[contributes.commands]] id = "insert" title = "insert" diff --git a/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs b/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs index 3fc1b81f9..bbfb782c6 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs +++ b/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs @@ -63,6 +63,26 @@ impl commands::Guest for Misbehave { project.read_file(&arg.unwrap_or_default()).map(Some) } "root" => ctx.project().map(|p| p.root()).transpose(), + // The `fs.read` grants: the text of `arg`, or the refusal naming what it needs. + "fsread" => openidea::plugin::fs::read_file(&arg.unwrap_or_default()).map(Some), + // The `fs.read-dir` listing: "name kind" lines. + "read-dir" => { + use openidea::plugin::workspace::FileKind; + let entries = openidea::plugin::fs::read_dir(&arg.unwrap_or_default())?; + let lines: Vec = entries + .iter() + .map(|e| { + let kind = match e.kind { + FileKind::File => "file", + FileKind::Directory => "dir", + FileKind::Symlink => "link", + FileKind::Other => "other", + }; + format!("{kind} {}", e.name) + }) + .collect(); + Ok(Some(lines.join("\n"))) + } // `editor.write` is not granted: an error, not a trap. "insert" => { let editor = ctx.editor().ok_or_else(|| failed("no editor"))?; diff --git a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm index 043a98de0..ab5f37c09 100644 Binary files a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources index 00cae590f..c5490cf23 100644 --- a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources @@ -1 +1 @@ -66d958477027d1fc085f4096d082ddb71a26e173c4a93cfda42d3e770f27b25d +fed7a3a23d48cdb44e9621597011b61a37dd292a6590870f92545dd7164e08c7 diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm index 10292aee9..346356a45 100644 Binary files a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources index dc17a0146..fcad221bf 100644 --- a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources @@ -1 +1 @@ -98ab76da97224a698cb20a032d304e545ee168da97273f471030a74288b0f018 +4ab92904d16f49a632fd37e3f4f3501b181a310d6f457a92fa03fd8bdadebe63 diff --git a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm index ce1898c67..bbc46d28e 100644 Binary files a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources index fe09a781d..5d16f8a5c 100644 --- a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources @@ -1 +1 @@ -33a5b4ebe6447df38a3c87ab3ead83aac4e2c0a9f08a744527cf90438b366787 +af793ed8c51662c30c45c55a9b6ae3cccfd5b814fa31c16bf414eb188d43588d diff --git a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm index 8b2e2ce3e..71460d4df 100644 Binary files a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources index 0f9e3ec65..e532820fb 100644 --- a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources @@ -1 +1 @@ -92654b80a977c272264c15d8b608e8bdac6c5d5612a5da2707bab156e655f615 +768198dc62d18f2c6d6c0aec57116df79ebc5a608abbff3d9912bdb24c893787 diff --git a/plugins/README.md b/plugins/README.md index fa14654b0..4aefec7f5 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -250,6 +250,7 @@ machine. Users can revoke optional capabilities in Settings › Plugins. | Capability | Risk | Unlocks today | | ------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------- | | `project.read` | low | `Project::root`, `read_file`, `find_files`, `host::projects`, `publish_diagnostics` | +| `fs.read:` | high | `host::fs::read_file`, `read_bytes`, `stat`, `read_dir` — files outside the projects, under the grant's directory | | `editor.read` | low | `TextEditor::document`, `selections` | | `editor.write` | medium | `TextEditor::apply_edits` | | `vcs.read` | low | `Project::vcs_status` (branch, upstream, ahead/behind, changes), `vcs_remotes`, `host::pending_commit`, before-commit checks | @@ -269,8 +270,16 @@ working. On grant the program is resolved to an absolute path and its content hash is recorded; it is checked again right before every start, and a replaced file is refused until the user approves it again. In a remote project the server resolves and pins the program on its own machine (see _Remote -projects_). `fs.read:` can be declared and granted; it unlocks nothing -yet. +projects_). + +`fs.read:` grants reading outside the open projects: a directory (its +files, at any depth) or one file. The path is resolved to its real location +before it is compared, so a symlink pointing out of the granted directory is +refused, and a sibling whose name merely starts with the granted one (`/opt/see` +vs `/opt/seeyon`) is not covered. Granting a whole file system (`/`, `C:\`) is +refused when the manifest is read: declare the directories you need. Reads are +capped — text over the limit fails rather than truncating, and files that are +not text have to be read as bytes. Logging, notifications, status bar items, tasks and run-time contributions are always available. Calling a gated function without its capability returns diff --git a/plugins/official/ai-completion/dist/plugin.wasm b/plugins/official/ai-completion/dist/plugin.wasm index 41b13eb14..157cdf643 100644 Binary files a/plugins/official/ai-completion/dist/plugin.wasm and b/plugins/official/ai-completion/dist/plugin.wasm differ diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index 4c26aa7a6..e4f655b81 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -abd041ece9d10d591c377e1a432dc83d0bf4240f502bb62f8c1d1876585f2d9c +4063a70e22e64b0124ca6b2e600b1e5df89abe79c03ccf5bfca1bef4c53f7f18 diff --git a/plugins/official/checkstyle/dist/plugin.wasm b/plugins/official/checkstyle/dist/plugin.wasm index 113710bcc..8d002969b 100644 Binary files a/plugins/official/checkstyle/dist/plugin.wasm and b/plugins/official/checkstyle/dist/plugin.wasm differ diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index 3937291d0..d11c3807d 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -7e6d23a7ac4da5429c453ec33dae8ce4e19ca87114a741b289cd4041b05fb59f +8713c59c1e6d8c8210bcd19c33e0b306df4fb3bed1d55da8058625d52b9af7e1 diff --git a/plugins/official/csv-editor/dist/plugin.wasm b/plugins/official/csv-editor/dist/plugin.wasm index 46f165e97..3668c9b51 100644 Binary files a/plugins/official/csv-editor/dist/plugin.wasm and b/plugins/official/csv-editor/dist/plugin.wasm differ diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources index a994d32a5..c350daccd 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -d63205db552dfd0d48c6834e662e7423481bc0d54b4a66755bae20ba085d5c99 +b431d938419fc817d050d9ade5928cd79fb7e9873c030bffba925dd989e82060 diff --git a/plugins/official/git-toolbox/dist/plugin.wasm b/plugins/official/git-toolbox/dist/plugin.wasm index 0e75c8192..5b6ec3ad9 100644 Binary files a/plugins/official/git-toolbox/dist/plugin.wasm and b/plugins/official/git-toolbox/dist/plugin.wasm differ diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources index 0ff05c2b1..75eaec047 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -2ef0482dce9edcf0bb0e8e13e5df7fd1fe735832d8091dbe4603f49bb01dd22a +5ec4b1998682babf21c3bebbbb9abf5f7d717cb00f67ce7027c6e3684c9d0f5c diff --git a/plugins/official/graphviz/dist/plugin.wasm b/plugins/official/graphviz/dist/plugin.wasm index 29c352cd3..32960f3d7 100644 Binary files a/plugins/official/graphviz/dist/plugin.wasm and b/plugins/official/graphviz/dist/plugin.wasm differ diff --git a/plugins/official/graphviz/dist/plugin.wasm.sources b/plugins/official/graphviz/dist/plugin.wasm.sources index 99dff7b05..e7fd54d5e 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -4a1788b4a53f87cfe921f70102d76d6bc31668fbc3f993d81d60f8d2c92143bb +5810ca91766fff8e4bef16a952ef9c9877133f079388899298769d6fb603076f diff --git a/plugins/official/key-promoter/dist/plugin.wasm b/plugins/official/key-promoter/dist/plugin.wasm index 94d6c2566..130b49e65 100644 Binary files a/plugins/official/key-promoter/dist/plugin.wasm and b/plugins/official/key-promoter/dist/plugin.wasm differ diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index 37f95f763..dfdaa2126 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -d2992f8f58b131ba05e70262d4c825f61449c3b45f6c9543eb90f27bf8ef8748 +d558d709d79c09dd1d8745ed65d3ce4b2c4083e1922522c2b2328c46f0d9e54a diff --git a/plugins/official/license-header/dist/plugin.wasm b/plugins/official/license-header/dist/plugin.wasm index 239765768..d7c80ac18 100644 Binary files a/plugins/official/license-header/dist/plugin.wasm and b/plugins/official/license-header/dist/plugin.wasm differ diff --git a/plugins/official/license-header/dist/plugin.wasm.sources b/plugins/official/license-header/dist/plugin.wasm.sources index f8e01f0aa..b1653a867 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -582fb1f8b6c24e54c70b1f6028cd4cdfed067482f4a4635d6e7720608bca3584 +2bb41b4e45f335141e7d257cd81a0cdcd767d77441d0fd2d12235735881f124c diff --git a/plugins/official/mybatis/dist/plugin.wasm b/plugins/official/mybatis/dist/plugin.wasm index d2663a440..4174e6a1a 100644 Binary files a/plugins/official/mybatis/dist/plugin.wasm and b/plugins/official/mybatis/dist/plugin.wasm differ diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index e6a1f675b..4a7601cd0 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -3cfbfad0d9554cc7dad2e3255054e9a06f60f6e0745a99a608ef29143c93f42b +2cf14518608fd55cf27ded77116feb14245c9563794fe01315d5e18ef0badbd3 diff --git a/plugins/official/regex-tester/dist/plugin.wasm b/plugins/official/regex-tester/dist/plugin.wasm index 81d96c857..7712f2323 100644 Binary files a/plugins/official/regex-tester/dist/plugin.wasm and b/plugins/official/regex-tester/dist/plugin.wasm differ diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index 3cd7203d1..d02c6e45d 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -2238f16cbfca44f1ab02768e733e37fa713c0ee817ef32e94d3c27d8c07bb09c +afcf7cac63902be3d7547b99ff62db27034fdc27f3f6dd34e6beb1ec4b928e25 diff --git a/plugins/official/solidity/dist/plugin.wasm b/plugins/official/solidity/dist/plugin.wasm index 4aba760b8..bf9127080 100644 Binary files a/plugins/official/solidity/dist/plugin.wasm and b/plugins/official/solidity/dist/plugin.wasm differ diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index b9561d44b..53759122f 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -dff3656603212315d37e27a498e7090d494e0a52df20c4bd9541fb0c933aeb47 +b0291bb0db88956c65758d82f5ea57fd65404e963cb87343ade6c713faccf47d diff --git a/plugins/official/string-tools/dist/plugin.wasm b/plugins/official/string-tools/dist/plugin.wasm index d4921ebbe..b91e377d3 100644 Binary files a/plugins/official/string-tools/dist/plugin.wasm and b/plugins/official/string-tools/dist/plugin.wasm differ diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index c90232e94..9d1953079 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -8bb9095d0dae184aabbc8f2abfe9a3bf6c44ff97e73b7eda6f39d3504d040340 +3805e5469c539186fd83fef1f4eef831149d4ba846c54d16e1f9b941d49acb04 diff --git a/sdk/rust/openidea-plugin/src/host.rs b/sdk/rust/openidea-plugin/src/host.rs index b08e3df37..d98525ff1 100644 --- a/sdk/rust/openidea-plugin/src/host.rs +++ b/sdk/rust/openidea-plugin/src/host.rs @@ -315,19 +315,38 @@ mod imp { } pub fn project_stat(p: &ProjectRes, path: &str) -> Result { + p.stat(path).map(file_stat).map_err(error) + } + + fn file_kind(kind: wit::workspace::FileKind) -> FileKind { use wit::workspace::FileKind as K; - p.stat(path) - .map(|s| FileStat { - kind: match s.kind { - K::File => FileKind::File, - K::Directory => FileKind::Directory, - K::Symlink => FileKind::Symlink, - K::Other => FileKind::Other, - }, - size: s.size, - modified: s.modified, - }) - .map_err(error) + match kind { + K::File => FileKind::File, + K::Directory => FileKind::Directory, + K::Symlink => FileKind::Symlink, + K::Other => FileKind::Other, + } + } + + fn file_stat(s: wit::workspace::FileStat) -> FileStat { + FileStat { kind: file_kind(s.kind), size: s.size, modified: s.modified } + } + + pub fn fs_read_file(path: &str) -> Result { + wit::fs::read_file(path).map_err(error) + } + + pub fn fs_read_bytes(path: &str) -> Result, Error> { + wit::fs::read_bytes(path).map_err(error) + } + + pub fn fs_stat(path: &str) -> Result { + wit::fs::stat(path).map(file_stat).map_err(error) + } + + pub fn fs_read_dir(path: &str) -> Result, Error> { + let entries = wit::fs::read_dir(path).map_err(error)?; + Ok(entries.into_iter().map(|e| super::fs::DirEntry { name: e.name, kind: file_kind(e.kind) }).collect()) } pub fn publish_diagnostics(path: &str, items: &[Diagnostic]) -> Result<(), Error> { @@ -824,6 +843,46 @@ pub fn unsubscribe(kind: EventKind) { /// The plugin's persistent key-value store (≤ 64 MiB of UTF-8 keys and values). /// Needs `storage`; rejected writes preserve the stored data. +/// Files outside the open projects, for a program the plugin integrates with +/// or a tool installed elsewhere. Every path needs an `fs.read:` grant +/// covering it; without one a call returns [`Error::Denied`] naming the +/// capability the manifest has to declare. Paths are absolute, or start with +/// `~/` for the user's home. A file inside a project is [`Project::read_file`]. +pub mod fs { + use super::imp; + use crate::types::{Error, FileKind, FileStat}; + + /// An entry of a directory ([`read_dir`]). + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct DirEntry { + /// The entry's name, without its directory. + pub name: String, + pub kind: FileKind, + } + + /// A text file, decoded as the editor decodes it (BOM and encoding + /// detection), at most 16 MiB; unsaved editor contents win. + pub fn read_file(path: &str) -> Result { + imp::fs_read_file(path) + } + + /// The bytes of a file (images, class files, archives), at most 16 MiB. + pub fn read_bytes(path: &str) -> Result, Error> { + imp::fs_read_bytes(path) + } + + /// Metadata of an existing path; a missing path is [`Error::NotFound`]. + pub fn stat(path: &str) -> Result { + imp::fs_stat(path) + } + + /// The entries directly under a directory, sorted by name, at most 4096; + /// a file is [`Error::Invalid`]. + pub fn read_dir(path: &str) -> Result, Error> { + imp::fs_read_dir(path) + } +} + pub mod storage { use super::imp; use crate::types::Error; diff --git a/sdk/rust/openidea-plugin/src/mock.rs b/sdk/rust/openidea-plugin/src/mock.rs index a01e8f510..d701b82dd 100644 --- a/sdk/rust/openidea-plugin/src/mock.rs +++ b/sdk/rust/openidea-plugin/src/mock.rs @@ -62,6 +62,9 @@ struct State { root: Option, other_projects: Vec, files: BTreeMap, + + /// Files outside every project, for `fs.read` (absolute paths). + external: BTreeMap, dirs: BTreeSet, editor: Option, selected_paths: Vec, @@ -177,6 +180,13 @@ impl MockHost { self.state.borrow_mut().selected_paths = paths.iter().map(|p| (*p).to_owned()).collect(); } + /// A file outside every project, for [`crate::host::fs::read_file`]: it is + /// readable once an `fs.read:` capability is granted. + /// Directories are inferred from the paths it holds. + pub fn add_external_file(&self, path: &str, text: &str) { + self.state.borrow_mut().external.insert(path.to_owned(), text.to_owned()); + } + /// Focuses an editor on `text` with selections `(line, col, line, col)`. pub fn open_editor(&self, path: &str, language: &str, text: &str, selections: &[(u32, u32, u32, u32)]) { let selections = @@ -446,6 +456,43 @@ pub mod current { if s.capabilities.iter().any(|c| c == cap) { Ok(()) } else { Err(Error::Denied(cap.to_owned())) } } + /// `fs.read:` covers `` itself and what is under it: `/a` covers + /// `/a/b` but not `/ab`. + fn require_fs(s: &State, path: &str) -> Result<(), Error> { + let covered = s.capabilities.iter().any(|c| match c.strip_prefix("fs.read:") { + Some(base) => { + let base = base.trim_end_matches(['/', '\\']); + !base.is_empty() + && (path == base + || matches!(path.strip_prefix(base), Some(rest) if rest.starts_with('/') || rest.starts_with('\\'))) + } + None => false, + }); + if covered { Ok(()) } else { Err(Error::Denied(format!("fs.read:{path}"))) } + } + + /// The paths `fs` takes are absolute; `~/…` is the user's home on the real + /// host, and the mock resolves nothing, so add it to the mock as it is. + fn check_absolute(path: &str) -> Result<(), Error> { + let bytes = path.as_bytes(); + let drive = bytes.len() > 2 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && (bytes[2] == b'/' || bytes[2] == b'\\'); + if path.starts_with('/') || drive { + Ok(()) + } else { + Err(Error::Invalid(format!("{path}: use an absolute path (or ~/…)"))) + } + } + + /// `fs` checks what the real host checks, minus canonicalisation: absolute + /// path, then a grant covering it. + fn check_external(s: &State, path: &str) -> Result<(), Error> { + check_absolute(path)?; + require_fs(s, path) + } + pub fn log(level: LogLevel, message: &str) { with(|s| s.logs.push((level, message.to_owned()))) } @@ -706,6 +753,74 @@ pub mod current { }) } + pub fn fs_read_file(path: &str) -> Result { + with(|s| { + check_external(s, path)?; + // Unsaved editor contents win, as on the real host. + if let Some(e) = s.editor.as_ref().filter(|e| e.path == path) { + return Ok(e.text.clone()); + } + s.external.get(path).cloned().ok_or_else(|| Error::NotFound(path.into())) + }) + } + + pub fn fs_read_bytes(path: &str) -> Result, Error> { + with(|s| { + check_external(s, path)?; + let text = s.external.get(path).ok_or_else(|| Error::NotFound(path.into()))?; + Ok(text.clone().into_bytes()) + }) + } + + pub fn fs_stat(path: &str) -> Result { + with(|s| { + check_external(s, path)?; + if let Some(text) = s.external.get(path) { + return Ok(FileStat { kind: FileKind::File, size: text.len() as u64, modified: None }); + } + // A directory is a prefix of the files the mock holds. + let holds_files = s.external.keys().any( + |file| matches!(file.strip_prefix(path), Some(rest) if rest.starts_with('/') || rest.starts_with('\\')), + ); + if holds_files { + Ok(FileStat { kind: FileKind::Directory, size: 0, modified: None }) + } else { + Err(Error::NotFound(path.into())) + } + }) + } + + pub fn fs_read_dir(path: &str) -> Result, Error> { + with(|s| { + check_external(s, path)?; + if s.external.contains_key(path) { + return Err(Error::Invalid(format!("{path}: not a directory"))); + } + let mut entries: BTreeMap = BTreeMap::new(); + for file in s.external.keys() { + let rest = match file.strip_prefix(path) { + Some(rest) if rest.starts_with('/') || rest.starts_with('\\') => &rest[1..], + _ => continue, + }; + match rest.split(['/', '\\']).next() { + Some(name) if !name.is_empty() => { + let kind = if rest.contains('/') || rest.contains('\\') { + FileKind::Directory + } else { + FileKind::File + }; + entries.insert(name.to_owned(), kind); + } + _ => {} + } + } + if entries.is_empty() { + return Err(Error::NotFound(path.into())); + } + Ok(entries.into_iter().map(|(name, kind)| crate::host::fs::DirEntry { name, kind }).collect()) + }) + } + pub fn invocation_project(_: InvocationRef<'_>) -> Option { with(|s| s.root.clone().map(|root| MockProject { root })) } @@ -1181,6 +1296,21 @@ mod tests { assert_eq!(mock.notifications().len(), 1); } + #[test] + fn files_outside_the_project_need_an_fs_read_grant() { + let mock = MockHost::install().with_capabilities(&["fs.read:/opt/tool"]); + mock.add_external_file("/opt/tool/conf/app.yaml", "port: 8080"); + mock.add_external_file("/opt/tool/README", "hi"); + mock.add_external_file("/opt/other/secret", "no"); + assert_eq!(host::fs::read_file("/opt/tool/conf/app.yaml").unwrap(), "port: 8080"); + assert_eq!(host::fs::read_bytes("/opt/tool/README").unwrap(), b"hi"); + assert_eq!(host::fs::stat("/opt/tool/conf").unwrap().kind, FileKind::Directory); + let names: Vec<_> = host::fs::read_dir("/opt/tool").unwrap().into_iter().map(|e| (e.name, e.kind)).collect(); + assert_eq!(names, [("README".into(), FileKind::File), ("conf".into(), FileKind::Directory)]); + assert!(matches!(host::fs::read_file("/opt/other/secret"), Err(Error::Denied(_)))); + assert!(matches!(host::fs::read_file("relative"), Err(Error::Invalid(_)))); + } + #[test] fn missing_capabilities_are_denied() { let mock = MockHost::install().with_capabilities(&["project.read"]); diff --git a/sdk/rust/openidea-plugin/wit/fs.wit b/sdk/rust/openidea-plugin/wit/fs.wit new file mode 100644 index 000000000..f7297bbec --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/fs.wit @@ -0,0 +1,37 @@ +package openidea:plugin@1.3.0; + +/// Files outside the open projects: the configuration of the program a +/// plugin integrates with, a tool installed elsewhere, a licence file. +/// +/// A plugin's own WASI has no preopened directory, so every path here is +/// checked against its `fs.read:` grants; without one covering the path +/// a call returns `denied` naming the `fs.read:` the manifest has to +/// declare. A file inside a project is `workspace.project.read-file`. `path` +/// is absolute, or starts with `~/` for the user's home. +interface fs { + use types.{error}; + use workspace.{file-kind, file-stat}; + + /// A text file, decoded as the editor decodes it (BOM and encoding + /// detection), at most 16 MiB. Unsaved editor contents win, as they do + /// for `project.read-file`. + read-file: func(path: string) -> result; + + /// The bytes of a file (images, class files, archives), at most 16 MiB. + read-bytes: func(path: string) -> result, error>; + + /// Metadata of an existing path, as `project.stat` reports it; a missing + /// path is `not-found`. + stat: func(path: string) -> result; + + /// An entry of a directory. + record dir-entry { + /// The entry's name, without its directory. + name: string, + kind: file-kind, + } + + /// The entries directly under a directory, sorted by name, at most 4096 + /// (like `find-files`); a file is `invalid`. + read-dir: func(path: string) -> result, error>; +} diff --git a/sdk/rust/openidea-plugin/wit/plugin.wit b/sdk/rust/openidea-plugin/wit/plugin.wit index 311c4cef6..2f8afeb49 100644 --- a/sdk/rust/openidea-plugin/wit/plugin.wit +++ b/sdk/rust/openidea-plugin/wit/plugin.wit @@ -3,16 +3,18 @@ /// API"); a 2.0 keeps 1.0 running through an adapter in the host. /// /// A plugin is a WebAssembly component targeting the `plugin` world (or -/// `plugin-syntax`). It runs sandboxed: no filesystem, network, environment -/// or processes beyond WASI's clocks and randomness; everything else goes -/// through the imports below, gated by the capabilities granted in -/// `plugin.toml` (a missing one returns `error::denied`). +/// `plugin-syntax`). It runs sandboxed: no file system, network, environment +/// or processes beyond WASI's clocks and randomness; files are read through +/// `fs` and `workspace`, and everything else goes through the imports below, +/// gated by the capabilities granted in `plugin.toml` (a missing one returns +/// `error::denied`). package openidea:plugin@1.3.0; world plugin { import log; import ui; import storage; + import fs; import workspace; import editor; import invocation; diff --git a/wit/fs.wit b/wit/fs.wit new file mode 100644 index 000000000..f7297bbec --- /dev/null +++ b/wit/fs.wit @@ -0,0 +1,37 @@ +package openidea:plugin@1.3.0; + +/// Files outside the open projects: the configuration of the program a +/// plugin integrates with, a tool installed elsewhere, a licence file. +/// +/// A plugin's own WASI has no preopened directory, so every path here is +/// checked against its `fs.read:` grants; without one covering the path +/// a call returns `denied` naming the `fs.read:` the manifest has to +/// declare. A file inside a project is `workspace.project.read-file`. `path` +/// is absolute, or starts with `~/` for the user's home. +interface fs { + use types.{error}; + use workspace.{file-kind, file-stat}; + + /// A text file, decoded as the editor decodes it (BOM and encoding + /// detection), at most 16 MiB. Unsaved editor contents win, as they do + /// for `project.read-file`. + read-file: func(path: string) -> result; + + /// The bytes of a file (images, class files, archives), at most 16 MiB. + read-bytes: func(path: string) -> result, error>; + + /// Metadata of an existing path, as `project.stat` reports it; a missing + /// path is `not-found`. + stat: func(path: string) -> result; + + /// An entry of a directory. + record dir-entry { + /// The entry's name, without its directory. + name: string, + kind: file-kind, + } + + /// The entries directly under a directory, sorted by name, at most 4096 + /// (like `find-files`); a file is `invalid`. + read-dir: func(path: string) -> result, error>; +} diff --git a/wit/plugin.wit b/wit/plugin.wit index 311c4cef6..2f8afeb49 100644 --- a/wit/plugin.wit +++ b/wit/plugin.wit @@ -3,16 +3,18 @@ /// API"); a 2.0 keeps 1.0 running through an adapter in the host. /// /// A plugin is a WebAssembly component targeting the `plugin` world (or -/// `plugin-syntax`). It runs sandboxed: no filesystem, network, environment -/// or processes beyond WASI's clocks and randomness; everything else goes -/// through the imports below, gated by the capabilities granted in -/// `plugin.toml` (a missing one returns `error::denied`). +/// `plugin-syntax`). It runs sandboxed: no file system, network, environment +/// or processes beyond WASI's clocks and randomness; files are read through +/// `fs` and `workspace`, and everything else goes through the imports below, +/// gated by the capabilities granted in `plugin.toml` (a missing one returns +/// `error::denied`). package openidea:plugin@1.3.0; world plugin { import log; import ui; import storage; + import fs; import workspace; import editor; import invocation;