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 1781d6c05..72a0a88b3 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 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.