дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
dc69c36604
коміт
377a8c8379
60 files changed
+760
-66
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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(¬es, "outside the project\n").unwrap();
|
||||
// A sibling whose name starts with the grant's: not covered by it.
|
||||
let sibling = f._tmp.path().join("shared-other");
|
||||
std::fs::create_dir_all(&sibling).unwrap();
|
||||
let other = sibling.join("notes.txt");
|
||||
std::fs::write(&other, "not the same directory\n").unwrap();
|
||||
let secret = f._tmp.path().join("secret.txt");
|
||||
f.install(&misbehave_granted(f._tmp.path(), &shared));
|
||||
|
||||
assert_eq!(f.run("misbehave", "fsread", Some(notes.to_str().unwrap())).unwrap().unwrap(), "outside the project\n");
|
||||
let err = f.run("misbehave", "fsread", Some(secret.to_str().unwrap())).unwrap_err();
|
||||
assert!(err.contains("fs.read:"), "the refusal names what to grant: {err}");
|
||||
let err = f.run("misbehave", "fsread", Some(other.to_str().unwrap())).unwrap_err();
|
||||
assert!(err.contains("fs.read:"), "a name prefix grants nothing: {err}");
|
||||
let err = f.run("misbehave", "fsread", Some("notes.txt")).unwrap_err();
|
||||
assert!(err.contains("absolute"), "{err}");
|
||||
let err = f.run("misbehave", "fsread", Some(shared.to_str().unwrap())).unwrap_err();
|
||||
assert!(err.contains("not a file"), "{err}");
|
||||
let missing = shared.join("gone.txt");
|
||||
let err = f.run("misbehave", "fsread", Some(missing.to_str().unwrap())).unwrap_err();
|
||||
assert!(err.contains("no such file"), "{err}");
|
||||
// A refusal is the plugin's to handle, like any other error it gets.
|
||||
let info = f.host.info_of("misbehave").unwrap();
|
||||
assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}");
|
||||
}
|
||||
|
||||
/// `fs.read-dir` lists a granted directory, sorted, and refuses the rest.
|
||||
#[test]
|
||||
fn fs_read_dir_lists_a_granted_directory() {
|
||||
let f = fixture(Limits::default());
|
||||
let shared = f._tmp.path().join("shared");
|
||||
std::fs::create_dir_all(shared.join("zeta")).unwrap();
|
||||
std::fs::write(shared.join("beta.txt"), "b").unwrap();
|
||||
std::fs::write(shared.join("alpha.txt"), "a").unwrap();
|
||||
f.install(&misbehave_granted(f._tmp.path(), &shared));
|
||||
|
||||
let listing = f.run("misbehave", "read-dir", Some(shared.to_str().unwrap())).unwrap().unwrap();
|
||||
let lines: Vec<&str> = listing.lines().collect();
|
||||
assert_eq!(lines, vec!["file alpha.txt", "file beta.txt", "dir zeta"], "{listing}");
|
||||
|
||||
// A file is not a directory, and a path outside the grant is refused.
|
||||
let file = shared.join("alpha.txt");
|
||||
let err = f.run("misbehave", "read-dir", Some(file.to_str().unwrap())).unwrap_err();
|
||||
assert!(err.contains("not a directory"), "{err}");
|
||||
let err = f.run("misbehave", "read-dir", Some(f._tmp.path().to_str().unwrap())).unwrap_err();
|
||||
assert!(err.contains("fs.read:"), "{err}");
|
||||
let info = f.host.info_of("misbehave").unwrap();
|
||||
assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}");
|
||||
}
|
||||
|
||||
/// A missing capability is an error the plugin can handle, not a trap.
|
||||
#[test]
|
||||
fn ungranted_capabilities_are_denied_not_trapped() {
|
||||
@@ -650,7 +721,7 @@ fn idle_instances_are_reclaimed_and_schedules_reactivate() {
|
||||
assert_eq!(f.host.instances(), 0);
|
||||
let info = f.host.info_of("misbehave").unwrap();
|
||||
assert_eq!((info.status, info.stats.reclaims), (PluginStatus::Inactive, 1));
|
||||
assert_eq!(f.command_titles("misbehave").len(), 10, "contributions stay");
|
||||
assert_eq!(f.command_titles("misbehave").len(), 12, "contributions stay");
|
||||
assert_eq!(f.run("misbehave", "echo", Some("y")).unwrap().as_deref(), Some("y"));
|
||||
assert_eq!(f.host.info_of("misbehave").unwrap().stats.activations, 2);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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 @@
|
||||
73c4c46303636f64afe278feb3987937ad64be427783412ba8a5f67e966b9b3b
|
||||
51c5348038e63c340b14111e3925f0fce96ea18a0a05293f4725dcc7072b7374
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
4f66df6e4650759aebcf0a1b75550bbad09acf3ac50129f55c17edf5a35ebc90
|
||||
ab2496c849c79e82ec5826a133587a8c15f56aa62fb97ce8a08921f304c9e66a
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
83081a6195792336c98704cd270ef8373fd25e73d14a86b517f12eafe845dee1
|
||||
9930626a916fca1a4ffb041faff00eaaf66277c3f6e169b20ef40d8eee8becd0
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
bb94b980261fc996c92bc8eceb5a89e8dbdaf26b5e4466223a86fe8abb9b63e0
|
||||
2a81c76cb9e2482f17a3cb080dbd1f9109c9305153cddcd232158c16b1a099c2
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
1e94c7de6c49e8b821b4e60825385bef5954983e983530d91b2d765eb9dda9ef
|
||||
602d2bb91e8f43a518602c9205f4f81cad9b1e4f65e23730e016662f47859156
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
17137e31b7997f20beee8ddc1c30918428c4083cb7284ad505a4b888b1ba34c0
|
||||
072674781078ba19d74b8f2ceb023edc23a822ddfd32eb8865084d42fbeb2b71
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
85f4f7fed98833c0a70346f37279c0e27bac5f07cfeb50c4b7afab290a1bd87e
|
||||
e3282ad3db4c73c0b3d43b7d87dab24c58132059fcfd22adeb8cc26ef054fe99
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
ca8e84a495a450c61c818e6807a71463ae17716a06ece993849239a24f08e2e6
|
||||
1dc4cf2e2c0ef41972479a3ada9e787cc1374c3372368297ef86036ad085fb1f
|
||||
@@ -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"
|
||||
|
||||
@@ -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 @@
|
||||
33a5b4ebe6447df38a3c87ab3ead83aac4e2c0a9f08a744527cf90438b366787
|
||||
af793ed8c51662c30c45c55a9b6ae3cccfd5b814fa31c16bf414eb188d43588d
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
92654b80a977c272264c15d8b608e8bdac6c5d5612a5da2707bab156e655f615
|
||||
768198dc62d18f2c6d6c0aec57116df79ebc5a608abbff3d9912bdb24c893787
|
||||
@@ -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 @@
|
||||
abd041ece9d10d591c377e1a432dc83d0bf4240f502bb62f8c1d1876585f2d9c
|
||||
4063a70e22e64b0124ca6b2e600b1e5df89abe79c03ccf5bfca1bef4c53f7f18
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
7e6d23a7ac4da5429c453ec33dae8ce4e19ca87114a741b289cd4041b05fb59f
|
||||
8713c59c1e6d8c8210bcd19c33e0b306df4fb3bed1d55da8058625d52b9af7e1
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
d63205db552dfd0d48c6834e662e7423481bc0d54b4a66755bae20ba085d5c99
|
||||
b431d938419fc817d050d9ade5928cd79fb7e9873c030bffba925dd989e82060
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
2ef0482dce9edcf0bb0e8e13e5df7fd1fe735832d8091dbe4603f49bb01dd22a
|
||||
5ec4b1998682babf21c3bebbbb9abf5f7d717cb00f67ce7027c6e3684c9d0f5c
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
4a1788b4a53f87cfe921f70102d76d6bc31668fbc3f993d81d60f8d2c92143bb
|
||||
5810ca91766fff8e4bef16a952ef9c9877133f079388899298769d6fb603076f
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
d2992f8f58b131ba05e70262d4c825f61449c3b45f6c9543eb90f27bf8ef8748
|
||||
d558d709d79c09dd1d8745ed65d3ce4b2c4083e1922522c2b2328c46f0d9e54a
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
582fb1f8b6c24e54c70b1f6028cd4cdfed067482f4a4635d6e7720608bca3584
|
||||
2bb41b4e45f335141e7d257cd81a0cdcd767d77441d0fd2d12235735881f124c
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
3cfbfad0d9554cc7dad2e3255054e9a06f60f6e0745a99a608ef29143c93f42b
|
||||
2cf14518608fd55cf27ded77116feb14245c9563794fe01315d5e18ef0badbd3
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
2238f16cbfca44f1ab02768e733e37fa713c0ee817ef32e94d3c27d8c07bb09c
|
||||
afcf7cac63902be3d7547b99ff62db27034fdc27f3f6dd34e6beb1ec4b928e25
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
dff3656603212315d37e27a498e7090d494e0a52df20c4bd9541fb0c933aeb47
|
||||
b0291bb0db88956c65758d82f5ea57fd65404e963cb87343ade6c713faccf47d
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
8bb9095d0dae184aabbc8f2abfe9a3bf6c44ff97e73b7eda6f39d3504d040340
|
||||
3805e5469c539186fd83fef1f4eef831149d4ba846c54d16e1f9b941d49acb04
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Заблокувати користувача