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:<path> 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 <[email protected]>
Цей коміт міститься в:
WangTongchaoтаchaoes authored and GitHub committed 2026-11-07 23:57:13 +08:00
1 джерело b43d9bf7fb
коміт 3f65cb0f57
41 files changed
+624 -81

No files matched your search

+26 -11
Переглянути файл
@@ -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::<Capability>().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::<Capability>().unwrap().to_string(), s);
}
assert_eq!("project-read".parse::<Capability>().unwrap(), Capability::ProjectRead);
@@ -249,6 +263,7 @@ mod tests {
assert!("net:a b".parse::<Capability>().is_err());
assert!("process:./evil".parse::<Capability>().is_err());
assert!("fs.read:rel".parse::<Capability>().is_err());
assert!("fs.write:rel".parse::<Capability>().is_err());
assert!("network".parse::<Capability>().is_err());
}
+15
Переглянути файл
@@ -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();
+53
Переглянути файл
@@ -39,6 +39,11 @@ pub trait PluginWorkspace: Send + Sync {
fn projects(&self) -> Vec<PathBuf>;
/// Unsaved text of the open document at `path`.
fn buffer_text(&self, path: &Path) -> Option<String>;
/// 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<Vec<PathBuf>> {
@@ -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<Arc<dyn PluginWorkspace>>, 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<crate::Host>);
@@ -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<Vec<PathBuf>> {
// 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)?;
+107 -1
Переглянути файл
@@ -74,6 +74,9 @@ impl PluginWorkspace for TestWorkspace {
fn buffer_text(&self, path: &Path) -> Option<String> {
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(&notes, "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(&notes).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);
+7
Переглянути файл
@@ -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<String, HostError>;
/// 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.
+70 -8
Переглянути файл
@@ -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:<path>` 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<PathBuf, Refusal> {
resolve(granted, path, Wanted::Read)
}
/// The real path `path` would be written to, when an `fs.write:<path>` 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<PathBuf, Refusal> {
resolve(granted, path, Wanted::Write)
}
fn resolve(granted: &[Capability], path: &str, wanted: Wanted) -> Result<PathBuf, Refusal> {
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<PathBuf, Re
if covered { Ok(real) } else { Err(Refusal::Denied(cap.to_string())) }
}
/// Where `asked` would be created: its directory, resolved, plus its name.
fn resolve_unsaved(asked: &Path) -> Result<PathBuf, String> {
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();
+48 -2
Переглянути файл
@@ -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<PathBuf, wit::Error> {
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<ProjectRes>, 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<ProjectRes>) -> wasmtime::Result<()> {
self.table.delete(rep)?;
Ok(())
@@ -1004,7 +1031,7 @@ fn file_stat(real: &Path, shown: &str) -> Result<workspace::FileStat, wit::Error
Ok(workspace::FileStat { kind: file_kind(meta.file_type()), size: meta.len(), modified })
}
/// A refused `fs.read` as the plugin sees it: `denied` carries the capability
/// A refused `fs` call as the plugin sees it: `denied` carries the capability
/// string, so the plugin can say exactly what its manifest lacks.
impl From<sandbox::Refusal> for wit::Error {
fn from(refusal: sandbox::Refusal) -> Self {
@@ -1015,7 +1042,8 @@ impl From<sandbox::Refusal> for wit::Error {
}
}
/// Files outside the open projects, gated by `fs.read:<path>` grants.
/// Files outside the open projects, gated by `fs.read:<path>` and
/// `fs.write:<path>` grants.
impl fs::Host for StoreData {
fn read_file(&mut self, path: String) -> Fallible<String> {
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<u8>) -> 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.
+1 -1
Переглянути файл
@@ -1 +1 @@
51c5348038e63c340b14111e3925f0fce96ea18a0a05293f4725dcc7072b7374
95de177eb6e9db39e0a9d5acb86ac1b0f19d91c954590135748774ba83638b53
+1 -1
Переглянути файл
@@ -1 +1 @@
ab2496c849c79e82ec5826a133587a8c15f56aa62fb97ce8a08921f304c9e66a
0f9992726659db0400c43192a623ecf97977766e0d7282f99e5ba800fdf247c1
+1 -1
Переглянути файл
@@ -1 +1 @@
9930626a916fca1a4ffb041faff00eaaf66277c3f6e169b20ef40d8eee8becd0
7a77d431efd04f0fd5b864cd30216e655b67f126d8a4dd4506dcf805a3454076
+1 -1
Переглянути файл
@@ -1 +1 @@
2a81c76cb9e2482f17a3cb080dbd1f9109c9305153cddcd232158c16b1a099c2
5cf52999f65c757d0eb793934c28ae6d9f453053514c2b4c8058d6e70384eb9a
+1 -1
Переглянути файл
@@ -1 +1 @@
602d2bb91e8f43a518602c9205f4f81cad9b1e4f65e23730e016662f47859156
07839dc01f3d528a81969703271d6c7ad1397b142bdc3aff68d7384249a15818
+1 -1
Переглянути файл
@@ -1 +1 @@
072674781078ba19d74b8f2ceb023edc23a822ddfd32eb8865084d42fbeb2b71
7ef04eaa3f6d506291c89877de26a11b6a27df900c223ce53bf1749523839ff6
+1 -1
Переглянути файл
@@ -1 +1 @@
e3282ad3db4c73c0b3d43b7d87dab24c58132059fcfd22adeb8cc26ef054fe99
bd79d7641d84e919a83ea082b00eca78d4d1427e1e3345fba7d289b06862edde
+6 -1
Переглянути файл
@@ -67,7 +67,12 @@ impl Plugin for Jobs {
.argument(format!("notification-action={}\nvalue", i + 1))
})
.collect::<Vec<_>>();
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" => {
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
1dc4cf2e2c0ef41972479a3ada9e787cc1374c3372368297ef86036ad085fb1f
95835b883dec1cf9b053e455533ed0329b6fb1b9270832865b7d8fd05fbaff5a
+8
Переглянути файл
@@ -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"
+9
Переглянути файл
@@ -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"))?;
+1 -1
Переглянути файл
@@ -1 +1 @@
fed7a3a23d48cdb44e9621597011b61a37dd292a6590870f92545dd7164e08c7
b6c96915f0b0668134bc1b197ad20545f85bd2cb0941a0e83a9e06b688c96434
+1 -1
Переглянути файл
@@ -1 +1 @@
4ab92904d16f49a632fd37e3f4f3501b181a310d6f457a92fa03fd8bdadebe63
9c0017584418a02395ddf532454ee7440fb044c4b4a77cdf0b554dbd0d84b52c
+1 -1
Переглянути файл
@@ -1 +1 @@
af793ed8c51662c30c45c55a9b6ae3cccfd5b814fa31c16bf414eb188d43588d
b90537a7745d8e3f8bb9fa0b8d5aa5e06465cf951ab25f5c4881efbeec46dd44
+1 -1
Переглянути файл
@@ -1 +1 @@
768198dc62d18f2c6d6c0aec57116df79ebc5a608abbff3d9912bdb24c893787
6a548f486d0d2accf510e2fe2ea598b26be81f73a2a010c41c9316646d4422b2
+12
Переглянути файл
@@ -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:<path>` | high | `host::fs::read_file`, `read_bytes`, `stat`, `read_dir` — files outside the projects, under the grant's directory |
| `fs.write:<path>` | 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:<path>` 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("<capability>")` — the plugin keeps running and can degrade
+1 -1
Переглянути файл
@@ -1 +1 @@
4063a70e22e64b0124ca6b2e600b1e5df89abe79c03ccf5bfca1bef4c53f7f18
35df068fc8ad60d011ac0280618fd4db7afddbd799f9d5cca0dab6798e85a4a7
+1 -1
Переглянути файл
@@ -1 +1 @@
8713c59c1e6d8c8210bcd19c33e0b306df4fb3bed1d55da8058625d52b9af7e1
01349e8fa8cba37979443c72aab247a49e85c8402ac67eae3e7e8d952b4715e7
+1 -1
Переглянути файл
@@ -1 +1 @@
b431d938419fc817d050d9ade5928cd79fb7e9873c030bffba925dd989e82060
1abd2598304ddfca641d022de63709a5e4914b7968a57aad40c317137a59514d
+1 -1
Переглянути файл
@@ -1 +1 @@
5ec4b1998682babf21c3bebbbb9abf5f7d717cb00f67ce7027c6e3684c9d0f5c
97efbeb8209eed4449ca850e985acefac08b44bb1f13853b313b836e996583a3
+1 -1
Переглянути файл
@@ -1 +1 @@
5810ca91766fff8e4bef16a952ef9c9877133f079388899298769d6fb603076f
0f5e465f98d98f8ff129bd72b98109fb8246d45ff00104d48375168ed8fe2086
+1 -1
Переглянути файл
@@ -1 +1 @@
d558d709d79c09dd1d8745ed65d3ce4b2c4083e1922522c2b2328c46f0d9e54a
6e23467236adfdca5025184e8cad45df47bba736f010a46d13ca88dc0ac2e897
+1 -1
Переглянути файл
@@ -1 +1 @@
2bb41b4e45f335141e7d257cd81a0cdcd767d77441d0fd2d12235735881f124c
b0ee4c5d79f32b5420930ef767c983ee405157893ecdc340211b8777f8a02e44
+1 -1
Переглянути файл
@@ -1 +1 @@
2cf14518608fd55cf27ded77116feb14245c9563794fe01315d5e18ef0badbd3
009452617b429df61dfaed6b1a96b0906342f3e0f0ccd8fb4b57ae74f3621b6f
+1 -1
Переглянути файл
@@ -1 +1 @@
afcf7cac63902be3d7547b99ff62db27034fdc27f3f6dd34e6beb1ec4b928e25
1c55ec95537d31885c1f860a2c3a02ea4f840b4d97cd78977df5701a3a439c8f
+1 -1
Переглянути файл
@@ -1 +1 @@
b0291bb0db88956c65758d82f5ea57fd65404e963cb87343ade6c713faccf47d
8d0eff09f96dc06f83af99d768e1c13d189b9b20cf481933c4f0bc6c47d5081a
+1 -1
Переглянути файл
@@ -1 +1 @@
3805e5469c539186fd83fef1f4eef831149d4ba846c54d16e1f9b941d49acb04
6a3d219b86eb112ee16aa846f97a17df4f3270ddfa90b5aa7ebe073b3a426d35
+45 -6
Переглянути файл
@@ -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<ProjectRes> {
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<D> = 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:<path>` 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:<path>`
/// (reading) or `fs.write:<path>` (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<Vec<DirEntry>, 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:<path>` 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;
+147 -21
Переглянути файл
@@ -65,6 +65,8 @@ struct State {
/// Files outside every project, for `fs.read` (absolute paths).
external: BTreeMap<String, String>,
/// The same, for what `fs::write_bytes` put there.
external_bytes: BTreeMap<String, Vec<u8>>,
dirs: BTreeSet<String>,
editor: Option<Editor>,
selected_paths: Vec<String>,
@@ -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:<a directory above it>` 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:<a directory above it>` 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:<base>` covers `<base>` 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:<base>` (or `fs.write:<base>`) covers `<base>` 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<bool, Error> {
with(|s| {
require(s, "project.read")?;
@@ -755,18 +814,28 @@ pub mod current {
pub fn fs_read_file(path: &str) -> Result<String, Error> {
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<Vec<u8>, 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<FileStat, Error> {
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<Vec<crate::host::fs::DirEntry>, 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<String, FileKind> = 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", "<licence/>");
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)]
+16 -4
Переглянути файл
@@ -4,10 +4,11 @@ package openidea:[email protected];
/// 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:<path>` grants; without one covering the path
/// a call returns `denied` naming the `fs.read:<path>` 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:<path>` (reading) or `fs.write:<path>`
/// (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<list<dir-entry>, 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<u8>) -> result<_, error>;
}
+8
Переглянути файл
@@ -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<string, error>;
/// 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.
+16 -4
Переглянути файл
@@ -4,10 +4,11 @@ package openidea:[email protected];
/// 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:<path>` grants; without one covering the path
/// a call returns `denied` naming the `fs.read:<path>` 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:<path>` (reading) or `fs.write:<path>`
/// (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<list<dir-entry>, 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<u8>) -> result<_, error>;
}
+8
Переглянути файл
@@ -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<string, error>;
/// 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.