From 3f65cb0f57546ce4d4732838e74f34d9cd3aab00 Mon Sep 17 00:00:00 2001 From: WangTongchao Date: Sat, 7 Nov 2026 23:57:13 +0800 Subject: [PATCH] feat(plugins): let a plugin write files, inside and outside the project (#2016) Adds fs.write-file / fs.write-bytes behind a new fs.write: capability (high risk; resolved to the real path, a missing file through its real directory, so a link cannot reach outside the grant; writing doesn't imply reading), and workspace.project.write-file(path, text) behind project.write (medium risk; stays inside the root and creates missing directories). Writes go through the editor's save path: atomic, keeping the file's encoding and BOM (new files are UTF-8), capped at 16 MiB, and refused while an editor holds unsaved changes for the file. Both capabilities need engines.openidea ^1.3. Tested through the real component (misbehave fixture) and the SDK mock. Closes #1823 Closes #1824 Co-authored-by: chaoes <26876656+chaoes@users.noreply.github.com> --- crates/ide-extension/src/capability.rs | 37 ++-- crates/ide-extension/src/manifest.rs | 15 ++ crates/ide-host/src/plugins/api.rs | 53 ++++++ crates/ide-host/tests/plugins.rs | 108 ++++++++++- crates/ide-plugin/src/runtime.rs | 7 + crates/ide-plugin/src/sandbox.rs | 78 +++++++- crates/ide-plugin/src/wit_host.rs | 50 +++++- .../fixtures/commit/dist/plugin.wasm.sources | 2 +- .../endpoints/dist/plugin.wasm.sources | 2 +- .../fixtures/hello/dist/plugin.wasm.sources | 2 +- .../fixtures/hover/dist/plugin.wasm.sources | 2 +- .../fixtures/indexer/dist/plugin.wasm.sources | 2 +- .../inlay-hints/dist/plugin.wasm.sources | 2 +- .../fixtures/jobs/dist/plugin.wasm.sources | 2 +- .../ide-plugin/tests/fixtures/jobs/src/lib.rs | 7 +- .../tests/fixtures/misbehave/dist/plugin.wasm | Bin 112135 -> 113453 bytes .../misbehave/dist/plugin.wasm.sources | 2 +- .../tests/fixtures/misbehave/plugin.toml | 8 + .../tests/fixtures/misbehave/src/lib.rs | 9 + .../path-events/dist/plugin.wasm.sources | 2 +- .../semantic-tokens/dist/plugin.wasm.sources | 2 +- .../fixtures/storage/dist/plugin.wasm.sources | 2 +- .../fixtures/tables/dist/plugin.wasm.sources | 2 +- plugins/README.md | 12 ++ .../ai-completion/dist/plugin.wasm.sources | 2 +- .../checkstyle/dist/plugin.wasm.sources | 2 +- .../csv-editor/dist/plugin.wasm.sources | 2 +- .../git-toolbox/dist/plugin.wasm.sources | 2 +- .../graphviz/dist/plugin.wasm.sources | 2 +- .../key-promoter/dist/plugin.wasm.sources | 2 +- .../license-header/dist/plugin.wasm.sources | 2 +- .../official/mybatis/dist/plugin.wasm.sources | 2 +- .../regex-tester/dist/plugin.wasm.sources | 2 +- .../solidity/dist/plugin.wasm.sources | 2 +- .../string-tools/dist/plugin.wasm.sources | 2 +- sdk/rust/openidea-plugin/src/host.rs | 51 +++++- sdk/rust/openidea-plugin/src/mock.rs | 168 +++++++++++++++--- sdk/rust/openidea-plugin/wit/fs.wit | 20 ++- sdk/rust/openidea-plugin/wit/workspace.wit | 8 + wit/fs.wit | 20 ++- wit/workspace.wit | 8 + 41 files changed, 624 insertions(+), 81 deletions(-) diff --git a/crates/ide-extension/src/capability.rs b/crates/ide-extension/src/capability.rs index a02c7193f..e47a60455 100644 --- a/crates/ide-extension/src/capability.rs +++ b/crates/ide-extension/src/capability.rs @@ -18,6 +18,8 @@ pub enum Capability { EditorRead, /// Edit the focused editor (versioned edits). EditorWrite, + /// Create and replace files inside the project root. + ProjectWrite, VcsRead, VcsWrite, /// The plugin's own key-value store and data directory. @@ -36,6 +38,8 @@ pub enum Capability { Process(String), /// Read files outside the project under one path. FsRead(String), + /// Write files outside the project under one path. + FsWrite(String), } /// How a grant is presented (plan 4.2). @@ -54,10 +58,14 @@ impl Capability { pub fn risk(&self) -> Risk { match self { Self::ProjectRead | Self::EditorRead | Self::Storage | Self::Events | Self::VcsRead => Risk::Low, - Self::EditorWrite | Self::VcsWrite | Self::Net(_) | Self::Secrets | Self::InputEvents | Self::Clipboard => { - Risk::Medium - } - Self::Process(_) | Self::FsRead(_) => Risk::High, + Self::EditorWrite + | Self::ProjectWrite + | Self::VcsWrite + | Self::Net(_) + | Self::Secrets + | Self::InputEvents + | Self::Clipboard => Risk::Medium, + Self::Process(_) | Self::FsRead(_) | Self::FsWrite(_) => Risk::High, } } @@ -73,6 +81,7 @@ impl Capability { Self::ProjectRead => "Read files in the open project".into(), Self::EditorRead => "Read the focused editor's file and selection".into(), Self::EditorWrite => "Change text in the editor".into(), + Self::ProjectWrite => "Create and change files in the open project".into(), Self::VcsRead => "Read version control state".into(), Self::VcsWrite => "Change version control state (commit, fetch, checkout)".into(), Self::Storage => "Store its own data".into(), @@ -85,6 +94,7 @@ impl Capability { Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"), Self::Process(bin) => format!("Run {bin} — equivalent to running arbitrary code on this machine"), Self::FsRead(path) => format!("Read files under {path}, outside the project"), + Self::FsWrite(path) => format!("Change files under {path}, outside the project"), } } @@ -92,7 +102,7 @@ impl Capability { pub fn covers(&self, wanted: &Capability) -> bool { match (self, wanted) { (Self::Net(pattern), Self::Net(host)) => host_matches(pattern, host), - (Self::FsRead(base), Self::FsRead(path)) => { + (Self::FsRead(base), Self::FsRead(path)) | (Self::FsWrite(base), Self::FsWrite(path)) => { std::path::Path::new(path).starts_with(std::path::Path::new(base)) } _ => self == wanted, @@ -116,6 +126,7 @@ impl fmt::Display for Capability { Self::ProjectRead => f.write_str("project.read"), Self::EditorRead => f.write_str("editor.read"), Self::EditorWrite => f.write_str("editor.write"), + Self::ProjectWrite => f.write_str("project.write"), Self::VcsRead => f.write_str("vcs.read"), Self::VcsWrite => f.write_str("vcs.write"), Self::Storage => f.write_str("storage"), @@ -126,6 +137,7 @@ impl fmt::Display for Capability { Self::Net(h) => write!(f, "net:{h}"), Self::Process(b) => write!(f, "process:{b}"), Self::FsRead(p) => write!(f, "fs.read:{p}"), + Self::FsWrite(p) => write!(f, "fs.write:{p}"), } } } @@ -158,11 +170,11 @@ impl FromStr for Capability { } Ok(Self::Process(arg.to_owned())) } - "fs.read" => { + "fs.read" | "fs.write" => { if !std::path::Path::new(arg).is_absolute() && !arg.starts_with('~') { return Err(format!("{s:?}: use an absolute path (or ~/…)")); } - Ok(Self::FsRead(arg.to_owned())) + Ok(if kind == "fs.read" { Self::FsRead(arg.to_owned()) } else { Self::FsWrite(arg.to_owned()) }) } _ => Err(format!("unknown capability {s:?}")), }; @@ -171,6 +183,7 @@ impl FromStr for Capability { "project.read" | "project-read" => Self::ProjectRead, "editor.read" | "editor-read" => Self::EditorRead, "editor.write" | "editor-write" => Self::EditorWrite, + "project.write" => Self::ProjectWrite, "vcs.read" => Self::VcsRead, "vcs.write" => Self::VcsWrite, "storage" => Self::Storage, @@ -224,6 +237,7 @@ mod tests { for s in [ "project.read", "editor.write", + "project.write", "vcs.write", "storage", "secrets", @@ -236,12 +250,12 @@ mod tests { assert_eq!(s.parse::().unwrap().to_string(), s); } // Absolute paths are the platform's. - let (git, etc) = if cfg!(windows) { - (r"process:C:\Program Files\Git\bin\git.exe", r"fs.read:C:\Windows") + let (git, etc, out) = if cfg!(windows) { + (r"process:C:\Program Files\Git\bin\git.exe", r"fs.read:C:\Windows", r"fs.write:C:\out") } else { - ("process:/usr/bin/git", "fs.read:/etc") + ("process:/usr/bin/git", "fs.read:/etc", "fs.write:/out") }; - for s in [git, etc] { + for s in [git, etc, out] { assert_eq!(s.parse::().unwrap().to_string(), s); } assert_eq!("project-read".parse::().unwrap(), Capability::ProjectRead); @@ -249,6 +263,7 @@ mod tests { assert!("net:a b".parse::().is_err()); assert!("process:./evil".parse::().is_err()); assert!("fs.read:rel".parse::().is_err()); + assert!("fs.write:rel".parse::().is_err()); assert!("network".parse::().is_err()); } diff --git a/crates/ide-extension/src/manifest.rs b/crates/ide-extension/src/manifest.rs index 8f2e86955..69de1550a 100644 --- a/crates/ide-extension/src/manifest.rs +++ b/crates/ide-extension/src/manifest.rs @@ -315,6 +315,11 @@ impl Manifest { self.needs_api("`icon`", 2)?; check_relative(icon, "icon")?; } + for cap in self.capabilities.required.iter().chain(&self.capabilities.optional) { + if matches!(cap, Capability::ProjectWrite | Capability::FsWrite(_)) { + self.needs_api(&format!("capability `{cap}`"), 3)?; + } + } if let Some(dark) = &self.icon_dark { if self.icon.is_none() { return Err("`icon-dark` needs `icon`".into()); @@ -583,6 +588,16 @@ mod tests { const BASE: &str = "id = \"ok\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.2\"\n"; + #[test] + fn write_capabilities_need_api_1_3() { + for cap in ["project.write", "fs.write:/opt/tool"] { + let caps = format!("[capabilities]\noptional = [\"{cap}\"]\n"); + assert!(err(&format!("{BASE}{caps}")).contains("new in plugin API 1.3"), "{cap}"); + let m = Manifest::parse(&format!("{}{caps}", BASE.replace("^1.2", "^1.3"))).unwrap(); + assert_eq!(m.capabilities.optional[0].to_string(), cap); + } + } + #[test] fn parses_icons() { let m = Manifest::parse(&format!("{BASE}icon = \"icon.svg\"\nicon-dark = \"icon_dark.svg\"\n")).unwrap(); diff --git a/crates/ide-host/src/plugins/api.rs b/crates/ide-host/src/plugins/api.rs index e4f6d961a..26cc5a4b9 100644 --- a/crates/ide-host/src/plugins/api.rs +++ b/crates/ide-host/src/plugins/api.rs @@ -39,6 +39,11 @@ pub trait PluginWorkspace: Send + Sync { fn projects(&self) -> Vec; /// Unsaved text of the open document at `path`. fn buffer_text(&self, path: &Path) -> Option; + /// Whether the editor holds changes for `path` that are not on disk yet; + /// writing such a file would lose them. + fn buffer_dirty(&self, _path: &Path) -> bool { + false + } /// Up to `limit` files of the project index below `root` whose root-relative path satisfies /// `matches`; `None` when no index covers `root` yet (the caller walks the disk instead). fn indexed_files(&self, _root: &Path, _matches: &dyn Fn(&Path) -> bool, _limit: usize) -> Option> { @@ -1306,6 +1311,26 @@ impl HostApi for PluginApi { } Ok(loaded.text) } + + fn write_text_file(&self, real: &Path, text: &str) -> Result<(), HostError> { + check_unsaved(self.shared.workspace(), real)?; + // Write back in the file's own encoding, so a GBK properties file stays + // one; a file that is not there yet becomes UTF-8. + let (encoding, has_bom) = match ide_vfs::load_text(real) { + Ok(loaded) if loaded.binary => { + return Err(HostError::Invalid(format!("{}: not text; write it as bytes", real.display()))); + } + Ok(loaded) => (loaded.encoding, loaded.has_bom), + Err(e) if e.kind == ide_core::error::ErrorKind::NotFound => ("utf-8".to_string(), false), + Err(e) => return Err(not_writable(real, e)), + }; + ide_vfs::save_text(real, text, &encoding, has_bom).map(drop).map_err(|e| not_writable(real, e)) + } + + fn write_bytes_file(&self, real: &Path, bytes: &[u8]) -> Result<(), HostError> { + check_unsaved(self.shared.workspace(), real)?; + ide_vfs::write_atomic(real, bytes).map(drop).map_err(|e| not_writable(real, e)) + } } /// A file that is not there, or cannot be read, as the plugin sees it. @@ -1321,6 +1346,29 @@ fn too_large(path: &Path, max: usize) -> HostError { HostError::Invalid(format!("{}: file is larger than {} MiB", path.display(), max >> 20)) } +/// Refuses to replace a file the editor holds unsaved changes for: the write +/// would lose them. +fn check_unsaved(workspace: Option>, path: &Path) -> Result<(), HostError> { + match workspace { + Some(workspace) if workspace.buffer_dirty(path) => Err(HostError::Invalid(format!( + "{}: the editor has unsaved changes for it; save them first", + path.display() + ))), + _ => Ok(()), + } +} + +/// A write that did not land, as the plugin sees it. +fn not_writable(path: &Path, e: ide_core::error::IdeError) -> HostError { + let message = format!("{}: {}", path.display(), e.message); + match e.kind { + ide_core::error::ErrorKind::NotFound => HostError::NotFound(message), + ide_core::error::ErrorKind::PermissionDenied => HostError::Denied(message), + ide_core::error::ErrorKind::InvalidInput => HostError::Invalid(message), + _ => HostError::Failed(message), + } +} + /// [`PluginWorkspace`] over the desktop host's open projects. pub struct HostWorkspace(pub std::sync::Weak); @@ -1347,6 +1395,11 @@ impl PluginWorkspace for HostWorkspace { HostWorkspace::projects(self).iter().find_map(|p| p.documents.text_of(path)) } + fn buffer_dirty(&self, path: &Path) -> bool { + let Some((project, doc)) = self.open_document(path) else { return false }; + project.documents.is_dirty(doc).unwrap_or(false) + } + fn indexed_files(&self, root: &Path, matches: &dyn Fn(&Path) -> bool, limit: usize) -> Option> { // The index of the project rooted at `root` (a workspace member's files are in its own). let project = HostWorkspace::projects(self).into_iter().find(|p| p.root == root)?; diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index a34dec395..937dc581c 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -74,6 +74,9 @@ impl PluginWorkspace for TestWorkspace { fn buffer_text(&self, path: &Path) -> Option { self.documents.text_of(path) } + fn buffer_dirty(&self, path: &Path) -> bool { + self.documents.by_path(path).is_some_and(|doc| self.documents.is_dirty(doc).unwrap_or(false)) + } fn document(&self, path: &Path) -> Option<(u64, String)> { let snap = self.documents.snapshot(self.documents.by_path(path)?).ok()?; Some((u64::from(snap.version), snap.text)) @@ -139,6 +142,20 @@ fn misbehave_granted(dir: &Path, granted: &Path) -> PathBuf { pkg } +/// `misbehave` requiring `capability` too, which is new in API 1.3. +fn misbehave_with(dir: &Path, name: &str, capability: &str) -> PathBuf { + let pkg = dir.join(name); + copy_dir(&misbehave_pkg(), &pkg); + let manifest = pkg.join("plugin.toml"); + let text = std::fs::read_to_string(&manifest).unwrap(); + let required = format!("required = [\"project.read\", \"{capability}\"]"); + let text = text + .replace("required = [\"project.read\"]", &required) + .replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.3\""); + 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 { @@ -441,6 +458,95 @@ fn fs_read_dir_lists_a_granted_directory() { 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}"); +} + +/// `project.write` creates and replaces files under the project root, and never +/// leaves it, through the real component. +#[test] +fn project_write_stays_inside_the_project() { + let f = fixture(Limits::default()); + f.install(&misbehave_with(f._tmp.path(), "misbehave-project-writer", "project.write")); + + // A Run configuration: neither the file nor `.openidea/` exists yet. + assert_eq!(f.run("misbehave", "projwrite", Some(".openidea/run.toml")).unwrap(), None); + let conf = f.project.join(".openidea").join("run.toml"); + assert_eq!(std::fs::read_to_string(&conf).unwrap(), "written inside the project\n"); + // What it wrote reads back through the project. + assert_eq!( + f.run("misbehave", "read", Some(".openidea/run.toml")).unwrap().as_deref(), + Some("written inside the project\n") + ); + // An absolute path inside the root lands in the same place. + let fresh = f.project.join("build").join("patch.txt"); + assert_eq!(f.run("misbehave", "projwrite", Some(fresh.to_str().unwrap())).unwrap(), None); + assert_eq!(std::fs::read_to_string(&fresh).unwrap(), "written inside the project\n"); + // Replacing an existing file rewrites it whole. + assert_eq!(f.run("misbehave", "projwrite", Some("src/notes.txt")).unwrap(), None); + assert_eq!(std::fs::read_to_string(f.project.join("src/notes.txt")).unwrap(), "written inside the project\n"); + // Unsaved changes in an editor are not overwritten. + f.workspace.open("src/notes.txt", &[]); + f.workspace.type_text("src/notes.txt", "typed "); + let err = f.run("misbehave", "projwrite", Some("src/notes.txt")).unwrap_err(); + assert!(err.contains("unsaved changes"), "{err}"); + // Leaving the root is refused, and nothing is written. + let err = f.run("misbehave", "projwrite", Some("../escape.txt")).unwrap_err(); + assert!(err.contains("outside the project root"), "{err}"); + let info = f.host.info_of("misbehave").unwrap(); + assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}"); +} + +/// `fs.write` replaces files the grant covers and nothing else, through the +/// real component. +#[test] +fn fs_write_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, "old\n").unwrap(); + let bommed = shared.join("bom.txt"); + std::fs::write(&bommed, b"\xef\xbb\xbfold").unwrap(); + 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"); + std::fs::write(&secret, "keep me\n").unwrap(); + let grant = format!("fs.write:{}", shared.to_string_lossy().replace('\\', "/")); + f.install(&misbehave_with(f._tmp.path(), "misbehave-writable", &grant)); + + // An existing file is replaced… + assert_eq!(f.run("misbehave", "fswrite", Some(notes.to_str().unwrap())).unwrap(), None); + assert_eq!(std::fs::read_to_string(¬es).unwrap(), "written by the plugin\n"); + // …a new one is created… + let fresh = shared.join("fresh.txt"); + assert_eq!(f.run("misbehave", "fswrite", Some(fresh.to_str().unwrap())).unwrap(), None); + assert_eq!(std::fs::read_to_string(&fresh).unwrap(), "written by the plugin\n"); + // …and a file's encoding and BOM survive the write. + assert_eq!(f.run("misbehave", "fswrite", Some(bommed.to_str().unwrap())).unwrap(), None); + let bytes = std::fs::read(&bommed).unwrap(); + assert!(bytes.starts_with(b"\xef\xbb\xbf"), "the BOM stays: {bytes:?}"); + assert_eq!(String::from_utf8(bytes[3..].to_vec()).unwrap(), "written by the plugin\n"); + + // Outside the grant nothing changes. + let err = f.run("misbehave", "fswrite", Some(secret.to_str().unwrap())).unwrap_err(); + assert!(err.contains("fs.write:"), "the refusal names what to grant: {err}"); + assert_eq!(std::fs::read_to_string(&secret).unwrap(), "keep me\n"); + let err = f.run("misbehave", "fswrite", Some(other.to_str().unwrap())).unwrap_err(); + assert!(err.contains("fs.write:"), "a name prefix grants nothing: {err}"); + let err = f.run("misbehave", "fswrite", Some("notes.txt")).unwrap_err(); + assert!(err.contains("absolute"), "{err}"); + let missing_parent = shared.join("conf").join("web.xml"); + let err = f.run("misbehave", "fswrite", Some(missing_parent.to_str().unwrap())).unwrap_err(); + assert!(err.contains("directory does not exist"), "{err}"); + // Writing a directory fails rather than replacing it. + assert!(f.run("misbehave", "fswrite", Some(shared.to_str().unwrap())).is_err(), "a directory is not a file"); + assert!(shared.is_dir(), "the directory is still there"); + + // `fs.write` does not carry `fs.read`. + let err = f.run("misbehave", "fsread", Some(notes.to_str().unwrap())).unwrap_err(); + assert!(err.contains("fs.read:"), "write does not imply read: {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:?}"); } @@ -721,7 +827,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(), 12, "contributions stay"); + assert_eq!(f.command_titles("misbehave").len(), 14, "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 80dbf6df5..97557abfd 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -210,6 +210,13 @@ pub trait HostApi: Send + Sync { /// `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; + /// Replaces the text of a file whose real path the caller already checked: + /// in one step, keeping its encoding and BOM (a new file is UTF-8). Refused + /// while an editor holds unsaved changes for it. + fn write_text_file(&self, real: &Path, text: &str) -> Result<(), HostError>; + /// Replaces the bytes of a file whose real path the caller already checked, + /// in one step. Refused while an editor holds unsaved changes for it. + fn write_bytes_file(&self, real: &Path, bytes: &[u8]) -> Result<(), HostError>; } /// 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 523a2b2f4..153cae609 100644 --- a/crates/ide-plugin/src/sandbox.rs +++ b/crates/ide-plugin/src/sandbox.rs @@ -8,6 +8,8 @@ use std::path::{Path, PathBuf}; pub const MAX_LIST_ENTRIES: usize = 4096; pub const MAX_READ_BYTES: u64 = 16 << 20; +/// The largest file s.write may write, in bytes. +pub const MAX_WRITE_BYTES: usize = 16 << 20; /// The canonical `root` and the absolute form of `path` (absolute, or /// relative to `root`) — before any symlink or existence check. @@ -155,6 +157,30 @@ fn expand_tilde(path: &str) -> PathBuf { } } +/// Which grant a path is resolved against. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Wanted { + Read, + Write, +} + +impl Wanted { + fn capability(self, plain: String) -> Capability { + match self { + Self::Read => Capability::FsRead(plain), + Self::Write => Capability::FsWrite(plain), + } + } + + /// `fs.read` / `fs.write`, for messages. + fn prefix(self) -> &'static str { + match self { + Self::Read => "fs.read", + Self::Write => "fs.write", + } + } +} + /// 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 @@ -162,25 +188,53 @@ fn expand_tilde(path: &str) -> PathBuf { /// 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 { + resolve(granted, path, Wanted::Read) +} + +/// The real path `path` would be written to, when an `fs.write:` grant +/// covers it. +/// +/// A file that does not exist yet cannot be canonicalized, so its directory is +/// canonicalized instead and the name appended; an existing file (or link) is +/// followed first, so a link cannot be used to write outside the grant. +pub fn resolve_writable(granted: &[Capability], path: &str) -> Result { + resolve(granted, path, Wanted::Write) +} + +fn resolve(granted: &[Capability], path: &str, wanted: Wanted) -> 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 real = match wanted { + Wanted::Read => asked.canonicalize().map_err(|_| Refusal::Invalid(format!("{path}: no such file")))?, + Wanted::Write => asked + .canonicalize() + .or_else(|_| resolve_unsaved(&asked)) + .map_err(|e| Refusal::Invalid(format!("{path}: {e}")))?, + }; let plain = strip_verbatim(&real); - let cap = Capability::FsRead(plain.clone()); + let cap = wanted.capability(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) { + let base = match (wanted, grant) { + (Wanted::Read, Capability::FsRead(base)) | (Wanted::Write, Capability::FsWrite(base)) => base, + _ => continue, + }; + // The grant is compared where it really is too: `/tmp/x` is `/private/tmp/x` + // on macOS, and `~` is the home directory. + let base = match expand_tilde(base).canonicalize() { + Ok(real) => strip_verbatim(&real), + Err(_) => strip_verbatim(&expand_tilde(base)), + }; + if !wanted.capability(base.clone()).covers(&cap) { continue; } // A directory that is its own parent is a root: `/`, `C:\`. - if Path::new(base).parent().is_none() { + if Path::new(&base).parent().is_none() { return Err(Refusal::Invalid(format!( - "{plain}: fs.read:{base} covers a whole file system; declare a directory" + "{plain}: {}:{base} covers a whole file system; declare a directory", + wanted.prefix() ))); } covered = true; @@ -188,6 +242,14 @@ pub fn resolve_granted(granted: &[Capability], path: &str) -> Result Result { + let name = asked.file_name().ok_or_else(|| "not a file name".to_string())?; + let parent = asked.parent().ok_or_else(|| "no directory to put it in".to_string())?; + let dir = parent.canonicalize().map_err(|_| "its directory does not exist".to_string())?; + Ok(dir.join(name)) +} + /// `\\?\C:\…` (what `canonicalize` returns on Windows) as `C:\…`. fn strip_verbatim(path: &Path) -> String { let text = path.to_string_lossy(); diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs index caa8b2517..84229dcb4 100644 --- a/crates/ide-plugin/src/wit_host.rs +++ b/crates/ide-plugin/src/wit_host.rs @@ -202,6 +202,12 @@ impl StoreData { sandbox::resolve_granted(&self.granted, path).map_err(Into::into) } + /// The same for `fs.write:…`: a file that does not exist yet is resolved + /// through the directory it would be created in. + fn writable_file(&self, path: &str) -> Result { + sandbox::resolve_writable(&self.granted, path).map_err(Into::into) + } + fn client(&self) -> Option<&str> { self.invocation.client.as_deref() } @@ -553,6 +559,27 @@ impl workspace::HostProject for StoreData { Ok(self.host.find_files(&root, &glob, limit.min(MAX_FIND) as usize).map_err(to_wit)) } + fn write_file(&mut self, this: Resource, path: String, text: String) -> Fallible<()> { + if let Err(e) = self.require(Capability::ProjectWrite) { + return Ok(Err(e)); + } + if text.len() > sandbox::MAX_WRITE_BYTES { + return Ok(Err(too_much_to_write(&path))); + } + let root = self.table.get(&this)?.root.clone(); + let real = match sandbox::resolve_new_in_root(&root, &path) { + Ok(real) => real, + Err(e) => return Ok(Err(wit::Error::Invalid(e))), + }; + // The directories it goes in are created, like a new file in the project view. + if let Some(parent) = real.parent() + && let Err(e) = std::fs::create_dir_all(parent) + { + return Ok(Err(wit::Error::Failed(format!("{path}: {e}")))); + } + Ok(self.host.write_text_file(&real, &text).map_err(to_wit)) + } + fn drop(&mut self, rep: Resource) -> wasmtime::Result<()> { self.table.delete(rep)?; Ok(()) @@ -1004,7 +1031,7 @@ fn file_stat(real: &Path, shown: &str) -> Result for wit::Error { fn from(refusal: sandbox::Refusal) -> Self { @@ -1015,7 +1042,8 @@ impl From for wit::Error { } } -/// Files outside the open projects, gated by `fs.read:` grants. +/// Files outside the open projects, gated by `fs.read:` and +/// `fs.write:` grants. impl fs::Host for StoreData { fn read_file(&mut self, path: String) -> Fallible { let real = match self.granted_file(&path) { @@ -1078,6 +1106,24 @@ impl fs::Host for StoreData { entries.sort_by(|a, b| a.name.cmp(&b.name)); Ok(Ok(entries)) } + + fn write_file(&mut self, path: String, text: String) -> Fallible<()> { + if text.len() > sandbox::MAX_WRITE_BYTES { + return Ok(Err(too_much_to_write(&path))); + } + Ok(self.writable_file(&path).and_then(|real| self.host.write_text_file(&real, &text).map_err(to_wit))) + } + + fn write_bytes(&mut self, path: String, bytes: Vec) -> Fallible<()> { + if bytes.len() > sandbox::MAX_WRITE_BYTES { + return Ok(Err(too_much_to_write(&path))); + } + Ok(self.writable_file(&path).and_then(|real| self.host.write_bytes_file(&real, &bytes).map_err(to_wit))) + } +} + +fn too_much_to_write(path: &str) -> wit::Error { + wit::Error::Invalid(format!("{path}: more than {} MiB to write", sandbox::MAX_WRITE_BYTES >> 20)) } /// Largest message between a plugin and its view's frame. 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 453b22e93..45bef8203 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 @@ -51c5348038e63c340b14111e3925f0fce96ea18a0a05293f4725dcc7072b7374 +95de177eb6e9db39e0a9d5acb86ac1b0f19d91c954590135748774ba83638b53 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 2df0fdd91..a25fb3e02 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 @@ -ab2496c849c79e82ec5826a133587a8c15f56aa62fb97ce8a08921f304c9e66a +0f9992726659db0400c43192a623ecf97977766e0d7282f99e5ba800fdf247c1 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 9bf6a9c20..44b18a78c 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 @@ -9930626a916fca1a4ffb041faff00eaaf66277c3f6e169b20ef40d8eee8becd0 +7a77d431efd04f0fd5b864cd30216e655b67f126d8a4dd4506dcf805a3454076 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 3db54b4e0..70d501b2d 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 @@ -2a81c76cb9e2482f17a3cb080dbd1f9109c9305153cddcd232158c16b1a099c2 +5cf52999f65c757d0eb793934c28ae6d9f453053514c2b4c8058d6e70384eb9a 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 ff44e303b..f557e5034 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 @@ -602d2bb91e8f43a518602c9205f4f81cad9b1e4f65e23730e016662f47859156 +07839dc01f3d528a81969703271d6c7ad1397b142bdc3aff68d7384249a15818 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 e4c12ad55..901180000 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 @@ -072674781078ba19d74b8f2ceb023edc23a822ddfd32eb8865084d42fbeb2b71 +7ef04eaa3f6d506291c89877de26a11b6a27df900c223ce53bf1749523839ff6 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 bb624f51d..591da1285 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 @@ -e3282ad3db4c73c0b3d43b7d87dab24c58132059fcfd22adeb8cc26ef054fe99 +bd79d7641d84e919a83ea082b00eca78d4d1427e1e3345fba7d289b06862edde diff --git a/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs b/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs index e18e3991c..bfcc3b646 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs +++ b/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs @@ -67,7 +67,12 @@ impl Plugin for Jobs { .argument(format!("notification-action={}\nvalue", i + 1)) }) .collect::>(); - host::notify_with_actions(NotificationLevel::Info, "Choose an action", "A plugin notification", &actions)?; + host::notify_with_actions( + NotificationLevel::Info, + "Choose an action", + "A plugin notification", + &actions, + )?; Ok(None) } "copy" => { diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm index 1781d6c056b354f329ef2c400275e508b5d59661..72a0a88b33d5fb1f3dd5a47fa5212c725df2c47b 100644 GIT binary patch delta 19621 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 SbdIHFc6Dy!-p6T>`~Lwf#|>`) delta 18932 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 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 30c00cb58..52c1aa140 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 @@ -1dc4cf2e2c0ef41972479a3ada9e787cc1374c3372368297ef86036ad085fb1f +95835b883dec1cf9b053e455533ed0329b6fb1b9270832865b7d8fd05fbaff5a diff --git a/crates/ide-plugin/tests/fixtures/misbehave/plugin.toml b/crates/ide-plugin/tests/fixtures/misbehave/plugin.toml index a037a0b92..603b77c3d 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/plugin.toml +++ b/crates/ide-plugin/tests/fixtures/misbehave/plugin.toml @@ -41,6 +41,14 @@ title = "fsread" id = "read-dir" title = "List a directory outside the project" +[[contributes.commands]] +id = "fswrite" +title = "fswrite" + +[[contributes.commands]] +id = "projwrite" +title = "projwrite" + [[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 bbfb782c6..647628cf1 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs +++ b/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs @@ -83,6 +83,15 @@ impl commands::Guest for Misbehave { .collect(); Ok(Some(lines.join("\n"))) } + // The `fs.write` grant: replaces the file at `arg`. + "fswrite" => { + openidea::plugin::fs::write_file(&arg.unwrap_or_default(), "written by the plugin\n").map(|()| None) + } + // The `project.write` grant: writes `arg` inside the project. + "projwrite" => { + let project = ctx.project().ok_or_else(|| failed("no project is open"))?; + project.write_file(&arg.unwrap_or_default(), "written inside the project\n").map(|()| None) + } // `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.sources b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources index c5490cf23..33e47a9f2 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 @@ -fed7a3a23d48cdb44e9621597011b61a37dd292a6590870f92545dd7164e08c7 +b6c96915f0b0668134bc1b197ad20545f85bd2cb0941a0e83a9e06b688c96434 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 fcad221bf..07ffd1a8b 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 @@ -4ab92904d16f49a632fd37e3f4f3501b181a310d6f457a92fa03fd8bdadebe63 +9c0017584418a02395ddf532454ee7440fb044c4b4a77cdf0b554dbd0d84b52c 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 5d16f8a5c..44bab9153 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 @@ -af793ed8c51662c30c45c55a9b6ae3cccfd5b814fa31c16bf414eb188d43588d +b90537a7745d8e3f8bb9fa0b8d5aa5e06465cf951ab25f5c4881efbeec46dd44 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 e532820fb..65c90e30d 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 @@ -768198dc62d18f2c6d6c0aec57116df79ebc5a608abbff3d9912bdb24c893787 +6a548f486d0d2accf510e2fe2ea598b26be81f73a2a010c41c9316646d4422b2 diff --git a/plugins/README.md b/plugins/README.md index 4aefec7f5..91cfaa3b1 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -250,7 +250,9 @@ 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` | +| `project.write` | medium | `Project::write_file` — creates and replaces text files inside the open projects | | `fs.read:` | high | `host::fs::read_file`, `read_bytes`, `stat`, `read_dir` — files outside the projects, under the grant's directory | +| `fs.write:` | high | `host::fs::write_file`, `write_bytes` — files under that path; doesn't imply reading | | `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 | @@ -281,6 +283,16 @@ 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. +`fs.write:` grants replacing files outside the projects under that path, +creating them when they are not there. A write is separate from a read and does +not imply one. Writes are atomic (a temporary file is renamed over the old one), +keep the file's encoding and BOM (a new file is UTF-8), are capped at 16 MiB, +and are refused while the editor holds unsaved changes for the same file. The +path is resolved as a read's is, so a link inside a granted directory cannot be +used to write outside it. `project.write` does the same inside the open +projects, creating missing directories. Both are new in API 1.3 +(`engines.openidea = "^1.3"`). + Logging, notifications, status bar items, tasks and run-time contributions are always available. Calling a gated function without its capability returns `Error::Denied("")` — the plugin keeps running and can degrade diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index e4f655b81..43e452db6 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -4063a70e22e64b0124ca6b2e600b1e5df89abe79c03ccf5bfca1bef4c53f7f18 +35df068fc8ad60d011ac0280618fd4db7afddbd799f9d5cca0dab6798e85a4a7 diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index d11c3807d..3f6bbe5cc 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -8713c59c1e6d8c8210bcd19c33e0b306df4fb3bed1d55da8058625d52b9af7e1 +01349e8fa8cba37979443c72aab247a49e85c8402ac67eae3e7e8d952b4715e7 diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources index c350daccd..beed6c54f 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -b431d938419fc817d050d9ade5928cd79fb7e9873c030bffba925dd989e82060 +1abd2598304ddfca641d022de63709a5e4914b7968a57aad40c317137a59514d diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources index 75eaec047..b96139a94 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -5ec4b1998682babf21c3bebbbb9abf5f7d717cb00f67ce7027c6e3684c9d0f5c +97efbeb8209eed4449ca850e985acefac08b44bb1f13853b313b836e996583a3 diff --git a/plugins/official/graphviz/dist/plugin.wasm.sources b/plugins/official/graphviz/dist/plugin.wasm.sources index e7fd54d5e..feb3ba0e2 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -5810ca91766fff8e4bef16a952ef9c9877133f079388899298769d6fb603076f +0f5e465f98d98f8ff129bd72b98109fb8246d45ff00104d48375168ed8fe2086 diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index dfdaa2126..ecbb3fb4b 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -d558d709d79c09dd1d8745ed65d3ce4b2c4083e1922522c2b2328c46f0d9e54a +6e23467236adfdca5025184e8cad45df47bba736f010a46d13ca88dc0ac2e897 diff --git a/plugins/official/license-header/dist/plugin.wasm.sources b/plugins/official/license-header/dist/plugin.wasm.sources index b1653a867..6ee056556 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -2bb41b4e45f335141e7d257cd81a0cdcd767d77441d0fd2d12235735881f124c +b0ee4c5d79f32b5420930ef767c983ee405157893ecdc340211b8777f8a02e44 diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index 4a7601cd0..f2f6b200c 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -2cf14518608fd55cf27ded77116feb14245c9563794fe01315d5e18ef0badbd3 +009452617b429df61dfaed6b1a96b0906342f3e0f0ccd8fb4b57ae74f3621b6f diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index d02c6e45d..48a8a88c2 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -afcf7cac63902be3d7547b99ff62db27034fdc27f3f6dd34e6beb1ec4b928e25 +1c55ec95537d31885c1f860a2c3a02ea4f840b4d97cd78977df5701a3a439c8f diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index 53759122f..262256fba 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -b0291bb0db88956c65758d82f5ea57fd65404e963cb87343ade6c713faccf47d +8d0eff09f96dc06f83af99d768e1c13d189b9b20cf481933c4f0bc6c47d5081a diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index 9d1953079..0d59fa996 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -3805e5469c539186fd83fef1f4eef831149d4ba846c54d16e1f9b941d49acb04 +6a3d219b86eb112ee16aa846f97a17df4f3270ddfa90b5aa7ebe073b3a426d35 diff --git a/sdk/rust/openidea-plugin/src/host.rs b/sdk/rust/openidea-plugin/src/host.rs index d98525ff1..7fdfe4822 100644 --- a/sdk/rust/openidea-plugin/src/host.rs +++ b/sdk/rust/openidea-plugin/src/host.rs @@ -194,6 +194,10 @@ mod imp { p.read_file(path).map_err(error) } + pub fn project_write(p: &ProjectRes, path: &str, text: &str) -> Result<(), Error> { + p.write_file(path, text).map_err(error) + } + pub fn invocation_project(i: InvocationRef<'_>) -> Option { i.project() } @@ -349,6 +353,14 @@ mod imp { Ok(entries.into_iter().map(|e| super::fs::DirEntry { name: e.name, kind: file_kind(e.kind) }).collect()) } + pub fn fs_write_file(path: &str, text: &str) -> Result<(), Error> { + wit::fs::write_file(path, text).map_err(error) + } + + pub fn fs_write_bytes(path: &str, bytes: &[u8]) -> Result<(), Error> { + wit::fs::write_bytes(path, bytes).map_err(error) + } + pub fn publish_diagnostics(path: &str, items: &[Diagnostic]) -> Result<(), Error> { use wit::diagnostics::{Diagnostic as D, RelatedInformation as R, Severity as S}; let items: Vec = items @@ -514,6 +526,15 @@ impl Project { imp::project_read(&self.inner, path) } + /// Replaces the text of a file inside the project (absolute or + /// root-relative), or creates it with the directories it needs, at most + /// 16 MiB: in one step, keeping the file's encoding and BOM (a new file is + /// UTF-8). An editor holding unsaved changes for the file makes it + /// [`Error::Invalid`]. Needs `project.write`. + pub fn write_file(&self, path: &str, text: &str) -> Result<(), Error> { + imp::project_write(&self.inner, path, text) + } + /// Whether `path` names an existing file or directory inside the project /// — including hidden and ignored ones — without reading any content. /// Needs `project.read`. @@ -841,13 +862,12 @@ pub fn unsubscribe(kind: EventKind) { imp::unsubscribe(kind) } -/// 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`]. +/// or a tool installed elsewhere. Every path needs an `fs.read:` +/// (reading) or `fs.write:` (writing) 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`] / [`Project::write_file`]. pub mod fs { use super::imp; use crate::types::{Error, FileKind, FileStat}; @@ -881,8 +901,27 @@ pub mod fs { pub fn read_dir(path: &str) -> Result, Error> { imp::fs_read_dir(path) } + + /// Replaces the text of a file, or creates it, at most 16 MiB: in one step, + /// keeping the file's encoding and BOM (a new file is UTF-8). Needs an + /// `fs.write:` grant, which doesn't imply reading. + /// + /// [`Error::Invalid`] when the text can't be written in the file's + /// encoding, the directory is missing, the path is a directory, or an + /// editor holds unsaved changes for the file (save them first). + pub fn write_file(path: &str, text: &str) -> Result<(), Error> { + imp::fs_write_file(path, text) + } + + /// Replaces the bytes of a file, or creates it, at most 16 MiB, in one + /// step; fails like [`write_file`]. + pub fn write_bytes(path: &str, bytes: &[u8]) -> Result<(), Error> { + imp::fs_write_bytes(path, bytes) + } } +/// The plugin's persistent key-value store (≤ 64 MiB of UTF-8 keys and values). +/// Needs `storage`; rejected writes preserve the stored data. 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 d701b82dd..3195dd501 100644 --- a/sdk/rust/openidea-plugin/src/mock.rs +++ b/sdk/rust/openidea-plugin/src/mock.rs @@ -65,6 +65,8 @@ struct State { /// Files outside every project, for `fs.read` (absolute paths). external: BTreeMap, + /// The same, for what `fs::write_bytes` put there. + external_bytes: BTreeMap>, dirs: BTreeSet, editor: Option, selected_paths: Vec, @@ -180,9 +182,9 @@ 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. + /// A file outside every project, for [`crate::host::fs`]: it is readable + /// once an `fs.read:` capability is granted, writable + /// with `fs.write:`. 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()); } @@ -456,10 +458,11 @@ 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:") { + /// `fs.read:` (or `fs.write:`) covers `` itself and what is + /// under it: `/a` covers `/a/b` but not `/ab`. + fn require_fs(s: &State, kind: &str, path: &str) -> Result<(), Error> { + let prefix = format!("{kind}:"); + let covered = s.capabilities.iter().any(|c| match c.strip_prefix(&prefix) { Some(base) => { let base = base.trim_end_matches(['/', '\\']); !base.is_empty() @@ -468,7 +471,7 @@ pub mod current { } None => false, }); - if covered { Ok(()) } else { Err(Error::Denied(format!("fs.read:{path}"))) } + if covered { Ok(()) } else { Err(Error::Denied(format!("{prefix}{path}"))) } } /// The paths `fs` takes are absolute; `~/…` is the user's home on the real @@ -488,9 +491,53 @@ pub mod current { /// `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> { + fn check_external(s: &State, kind: &str, path: &str) -> Result<(), Error> { check_absolute(path)?; - require_fs(s, path) + require_fs(s, kind, path) + } + + /// The directory `path` lives in: `/a/b` → `/a`, `/a` → `/`, `C:\a` → `C:\`. + fn parent_dir(path: &str) -> Option<&str> { + let cut = path.rfind(['/', '\\'])?; + let root = cut == 0 || (cut == 2 && path.as_bytes()[1] == b':'); + Some(&path[..if root { cut + 1 } else { cut }]) + } + + /// Files the mock holds under `dir`. + fn has_children(s: &State, dir: &str) -> bool { + let under = + |p: &String| matches!(p.strip_prefix(dir), Some(rest) if rest.starts_with('/') || rest.starts_with('\\')); + s.external.keys().any(under) || s.external_bytes.keys().any(under) + } + + /// The mock holds no file system, so a directory exists when a grant names + /// it or a registered file lives under it — plus the root of a volume. + fn dir_exists(s: &State, dir: &str) -> bool { + let dir = dir.trim_end_matches(['/', '\\']); + if dir.is_empty() || dir.ends_with(':') { + return true; + } + let granted = ["fs.read:", "fs.write:"].iter().any(|kind| { + s.capabilities + .iter() + .filter_map(|c| c.strip_prefix(kind)) + .any(|base| base.trim_end_matches(['/', '\\']) == dir) + }); + granted || has_children(s, dir) + } + + /// What a write has to get past: an `fs.write` grant, a path that is not a + /// directory, and a parent directory that exists. + fn check_writable(s: &State, path: &str) -> Result<(), Error> { + check_external(s, "fs.write", path)?; + if has_children(s, path) { + return Err(Error::Invalid(format!("{path}: is a directory"))); + } + match parent_dir(path) { + Some(dir) if dir_exists(s, dir) => Ok(()), + Some(dir) => Err(Error::Invalid(format!("{dir}: no such directory"))), + None => Err(Error::Invalid(format!("{path}: expected a file path"))), + } } pub fn log(level: LogLevel, message: &str) { @@ -726,6 +773,18 @@ pub mod current { }) } + pub fn project_write(p: &ProjectRes, path: &str, text: &str) -> Result<(), Error> { + with(|s| { + require(s, "project.write")?; + let rel = rel_in_root(p, path)?; + if s.editor.as_ref().is_some_and(|e| e.path == path || e.path == format!("{}/{rel}", p.root)) { + return Err(Error::Invalid(format!("{path}: the editor has unsaved changes; save it first"))); + } + s.files.insert(rel.to_string(), text.to_owned()); + Ok(()) + }) + } + pub fn project_exists(p: &ProjectRes, path: &str) -> Result { with(|s| { require(s, "project.read")?; @@ -755,18 +814,28 @@ pub mod current { pub fn fs_read_file(path: &str) -> Result { with(|s| { - check_external(s, path)?; + check_external(s, "fs.read", 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())) + if let Some(text) = s.external.get(path) { + return Ok(text.clone()); + } + match s.external_bytes.get(path) { + Some(bytes) => String::from_utf8(bytes.clone()) + .map_err(|_| Error::Invalid(format!("{path}: not valid UTF-8 text"))), + None => Err(Error::NotFound(path.into())), + } }) } pub fn fs_read_bytes(path: &str) -> Result, Error> { with(|s| { - check_external(s, path)?; + check_external(s, "fs.read", path)?; + if let Some(bytes) = s.external_bytes.get(path) { + return Ok(bytes.clone()); + } let text = s.external.get(path).ok_or_else(|| Error::NotFound(path.into()))?; Ok(text.clone().into_bytes()) }) @@ -774,15 +843,15 @@ pub mod current { pub fn fs_stat(path: &str) -> Result { with(|s| { - check_external(s, path)?; + check_external(s, "fs.read", path)?; if let Some(text) = s.external.get(path) { return Ok(FileStat { kind: FileKind::File, size: text.len() as u64, modified: None }); } + if let Some(bytes) = s.external_bytes.get(path) { + return Ok(FileStat { kind: FileKind::File, size: bytes.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 { + if has_children(s, path) { Ok(FileStat { kind: FileKind::Directory, size: 0, modified: None }) } else { Err(Error::NotFound(path.into())) @@ -790,14 +859,32 @@ pub mod current { }) } + pub fn fs_write_file(path: &str, text: &str) -> Result<(), Error> { + with(|s| { + check_writable(s, path)?; + s.external_bytes.remove(path); + s.external.insert(path.to_owned(), text.to_owned()); + Ok(()) + }) + } + + pub fn fs_write_bytes(path: &str, bytes: &[u8]) -> Result<(), Error> { + with(|s| { + check_writable(s, path)?; + s.external.remove(path); + s.external_bytes.insert(path.to_owned(), bytes.to_vec()); + Ok(()) + }) + } + pub fn fs_read_dir(path: &str) -> Result, Error> { with(|s| { - check_external(s, path)?; - if s.external.contains_key(path) { + check_external(s, "fs.read", path)?; + if s.external.contains_key(path) || s.external_bytes.contains_key(path) { return Err(Error::Invalid(format!("{path}: not a directory"))); } let mut entries: BTreeMap = BTreeMap::new(); - for file in s.external.keys() { + for file in s.external.keys().chain(s.external_bytes.keys()) { let rest = match file.strip_prefix(path) { Some(rest) if rest.starts_with('/') || rest.starts_with('\\') => &rest[1..], _ => continue, @@ -1351,6 +1438,45 @@ mod tests { .unwrap(); assert_eq!(mock.diagnostics()["/project/a.java"].len(), 1); } + + #[test] + fn fs_write_needs_a_grant_of_its_own() { + // `fs.read` does not let a plugin write. + let mock = MockHost::install().with_capabilities(&["fs.read:/opt/seeyon"]); + mock.add_external_file("/opt/seeyon/app.properties", "name=YonBIP\n"); + assert_eq!( + host::fs::write_file("/opt/seeyon/app.properties", "name=YonBIP2\n"), + Err(Error::Denied("fs.write:/opt/seeyon/app.properties".into())) + ); + assert_eq!(host::fs::read_file("/opt/seeyon/app.properties").unwrap(), "name=YonBIP\n"); + + // Nor does `fs.write` imply reading. + let mock = MockHost::install().with_capabilities(&["fs.write:/opt/seeyon"]); + mock.add_external_file("/opt/seeyon/app.properties", "name=YonBIP\n"); + host::fs::write_file("/opt/seeyon/app.properties", "name=YonBIP2\n").unwrap(); + assert_eq!( + host::fs::read_file("/opt/seeyon/app.properties"), + Err(Error::Denied("fs.read:/opt/seeyon/app.properties".into())) + ); + + let mock = MockHost::install().with_capabilities(&["fs.read:/opt/seeyon", "fs.write:/opt/seeyon"]); + mock.add_external_file("/opt/seeyon/app.properties", "name=YonBIP\n"); + mock.add_external_file("/opt/seeyon/conf/licence.xml", ""); + host::fs::write_file("/opt/seeyon/app.properties", "name=YonBIP2\n").unwrap(); + assert_eq!(host::fs::read_file("/opt/seeyon/app.properties").unwrap(), "name=YonBIP2\n"); + assert_eq!(host::fs::read_bytes("/opt/seeyon/app.properties").unwrap(), b"name=YonBIP2\n".to_vec()); + // A file that was not there is created; bytes stay bytes. + host::fs::write_bytes("/opt/seeyon/blob.bin", &[0xff, 0x00, 0x7f]).unwrap(); + assert_eq!(host::fs::read_bytes("/opt/seeyon/blob.bin").unwrap(), [0xff, 0x00, 0x7f]); + assert_eq!(host::fs::stat("/opt/seeyon/blob.bin").unwrap().size, 3); + assert!(matches!(host::fs::read_file("/opt/seeyon/blob.bin"), Err(Error::Invalid(_)))); + + // Relative paths are path errors, a directory is not a file, and a + // missing parent directory is not created. + assert!(matches!(host::fs::write_file("opt/seeyon/app.properties", "x"), Err(Error::Invalid(_)))); + assert!(matches!(host::fs::write_file("/opt/seeyon/conf", "x"), Err(Error::Invalid(_)))); + assert!(matches!(host::fs::write_bytes("/opt/seeyon/no/dir/x.bin", &[1]), Err(Error::Invalid(_)))); + } } #[cfg(test)] diff --git a/sdk/rust/openidea-plugin/wit/fs.wit b/sdk/rust/openidea-plugin/wit/fs.wit index f7297bbec..5be15207f 100644 --- a/sdk/rust/openidea-plugin/wit/fs.wit +++ b/sdk/rust/openidea-plugin/wit/fs.wit @@ -4,10 +4,11 @@ package openidea:plugin@1.3.0; /// 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. +/// checked against its `fs.read:` (reading) or `fs.write:` +/// (writing; it doesn't imply reading) grants; without one covering the path a +/// call returns `denied` naming the capability the manifest has to declare. A +/// file inside a project is `workspace.project.read-file` / `write-file`. +/// `path` is absolute, or starts with `~/` for the user's home. interface fs { use types.{error}; use workspace.{file-kind, file-stat}; @@ -34,4 +35,15 @@ interface fs { /// 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>; + + /// Replaces the text of a file, or creates it, at most 16 MiB: in one step + /// (a reader never sees half of it), keeping the file's encoding and BOM; + /// a new file is UTF-8. `invalid` when the text can't be written in that + /// encoding, the directory is missing, the path is a directory, or an + /// editor holds unsaved changes for the file (save them first). + write-file: func(path: string, text: string) -> result<_, error>; + + /// Replaces the bytes of a file, or creates it, at most 16 MiB, in one + /// step; fails like `write-file`. + write-bytes: func(path: string, bytes: list) -> result<_, error>; } diff --git a/sdk/rust/openidea-plugin/wit/workspace.wit b/sdk/rust/openidea-plugin/wit/workspace.wit index c764d50e8..baa2edbce 100644 --- a/sdk/rust/openidea-plugin/wit/workspace.wit +++ b/sdk/rust/openidea-plugin/wit/workspace.wit @@ -39,6 +39,14 @@ interface workspace { /// contents win over the disk. A missing path is `not-found`. /// Requires `project.read`. read-file: func(path: string) -> result; + /// Replaces the text of a file inside the project (absolute, or + /// relative to the root), or creates it with missing parent + /// directories; paths leaving the root are refused. Written in one + /// step (a reader never sees half of it), keeping the file's encoding + /// and BOM; a new file is UTF-8. An editor holding unsaved changes for + /// the file makes it `invalid` (save them first). Requires + /// `project.write`. + write-file: func(path: string, text: string) -> result<_, error>; /// Whether `path` (absolute, or relative to the root) names an /// existing file or directory — including hidden and ignored ones, /// without reading any content. Paths leaving the root are refused. diff --git a/wit/fs.wit b/wit/fs.wit index f7297bbec..5be15207f 100644 --- a/wit/fs.wit +++ b/wit/fs.wit @@ -4,10 +4,11 @@ package openidea:plugin@1.3.0; /// 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. +/// checked against its `fs.read:` (reading) or `fs.write:` +/// (writing; it doesn't imply reading) grants; without one covering the path a +/// call returns `denied` naming the capability the manifest has to declare. A +/// file inside a project is `workspace.project.read-file` / `write-file`. +/// `path` is absolute, or starts with `~/` for the user's home. interface fs { use types.{error}; use workspace.{file-kind, file-stat}; @@ -34,4 +35,15 @@ interface fs { /// 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>; + + /// Replaces the text of a file, or creates it, at most 16 MiB: in one step + /// (a reader never sees half of it), keeping the file's encoding and BOM; + /// a new file is UTF-8. `invalid` when the text can't be written in that + /// encoding, the directory is missing, the path is a directory, or an + /// editor holds unsaved changes for the file (save them first). + write-file: func(path: string, text: string) -> result<_, error>; + + /// Replaces the bytes of a file, or creates it, at most 16 MiB, in one + /// step; fails like `write-file`. + write-bytes: func(path: string, bytes: list) -> result<_, error>; } diff --git a/wit/workspace.wit b/wit/workspace.wit index c764d50e8..baa2edbce 100644 --- a/wit/workspace.wit +++ b/wit/workspace.wit @@ -39,6 +39,14 @@ interface workspace { /// contents win over the disk. A missing path is `not-found`. /// Requires `project.read`. read-file: func(path: string) -> result; + /// Replaces the text of a file inside the project (absolute, or + /// relative to the root), or creates it with missing parent + /// directories; paths leaving the root are refused. Written in one + /// step (a reader never sees half of it), keeping the file's encoding + /// and BOM; a new file is UTF-8. An editor holding unsaved changes for + /// the file makes it `invalid` (save them first). Requires + /// `project.write`. + write-file: func(path: string, text: string) -> result<_, error>; /// Whether `path` (absolute, or relative to the root) names an /// existing file or directory — including hidden and ignored ones, /// without reading any content. Paths leaving the root are refused.