feat(plugins): let a plugin read files outside the project (#2006)

Adds the API 1.3 `fs` import behind the existing `fs.read:<path>` capability: `read-file` (decoded like the editor, unsaved contents win), `read-bytes`, `stat` and `read-dir` (sorted, at most 4096), all at most 16 MiB. Paths are absolute or `~/`; the requested path and the grant are both resolved to their real location before the prefix check, so `..`, symlinks and name-prefix siblings can't climb out, and a whole-file-system grant is refused. A refusal is `denied` naming the `fs.read:<path>` to declare. Reuses `workspace.file-kind` / `file-stat`; SDK `host::fs`. Closes #1822, closes #1825.

Co-authored-by: chaoes <[email protected]>
Цей коміт міститься в:
WangTongchaoтаchaoes authored and GitHub committed 2026-11-07 23:45:21 +08:00
1 джерело dc69c36604
коміт 377a8c8379
60 files changed
+760 -66

No files matched your search

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