feat(plugins): add persistent private filesystem directories (#2051)

Closes #1947

Adds API 1.3 `fs.plugin-dir`: a persistent owner-only directory per plugin (0700 directories / 0600 files on Unix, owner-only ACL on Windows), separate from the packaged code, storage and secrets. The plugin reads and writes under it without fs.* grants, can open files there in the editor and pass their paths to approved programs; editor saves keep the protection, restart keeps the content, uninstall removes it. Traversal and symlink escapes into other plugins' directories are rejected (real-Wasm regressions).

Co-authored-by: arnaud-ritti <[email protected]>
This commit is contained in:
Arnaud Ritti authored and GitHub committed 2026-11-08 06:53:36 +08:00
1 parent a5db5cdda8
commit 04b1b863df
84 files changed
+1100 -51

No files matched your search

Generated
+2
View File
@@ -4783,12 +4783,14 @@ dependencies = [
"ignore",
"notify",
"parking_lot",
"rustix",
"serde",
"serde_json",
"specta",
"tempfile",
"tracing",
"trash",
"windows-sys 0.61.2",
"zip",
]
+21 -2
View File
@@ -1794,6 +1794,16 @@ impl HostApi for PluginApi {
serde_json::to_string(&value).ok()
}
fn plugin_dir(&self) -> Result<PathBuf, HostError> {
ide_extension::validate_plugin_id(self.id()).map_err(HostError::Invalid)?;
std::fs::create_dir_all(&self.shared.data_dir).map_err(failed)?;
let data = self.shared.data_dir.join(self.id());
ide_vfs::create_private_dir(&data).map_err(failed)?;
let files = data.join("files");
ide_vfs::create_private_dir(&files).map_err(failed)?;
files.canonicalize().map_err(failed)
}
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)) {
@@ -1825,12 +1835,21 @@ impl HostApi for PluginApi {
Err(e) if e.kind == ide_core::error::ErrorKind::NotFound => ("utf-8".to_string(), false),
Err(e) => return Err(not_writable(real, e)),
};
ide_vfs::save_text(real, text, &encoding, has_bom).map(drop).map_err(|e| not_writable(real, e))
if self.plugin_dir().is_ok_and(|dir| real.starts_with(dir)) {
let bytes = ide_vfs::encode(text, &encoding, has_bom).map_err(|e| not_writable(real, e))?;
ide_vfs::write_atomic_private(real, &bytes).map(drop).map_err(|e| not_writable(real, e))
} else {
ide_vfs::save_text(real, text, &encoding, has_bom).map(drop).map_err(|e| not_writable(real, e))
}
}
fn write_bytes_file(&self, real: &Path, bytes: &[u8]) -> Result<(), HostError> {
check_unsaved(self.shared.workspace(), real)?;
ide_vfs::write_atomic(real, bytes).map(drop).map_err(|e| not_writable(real, e))
if self.plugin_dir().is_ok_and(|dir| real.starts_with(dir)) {
ide_vfs::write_atomic_private(real, bytes).map(drop).map_err(|e| not_writable(real, e))
} else {
ide_vfs::write_atomic(real, bytes).map(drop).map_err(|e| not_writable(real, e))
}
}
}
+5 -1
View File
@@ -1042,7 +1042,11 @@ impl PluginHost {
std::fs::remove_dir_all(if root.is_dir() { root } else { plugin.dir().to_path_buf() })?;
self.updates.lock().remove(id);
// Its storage goes with it.
let _ = std::fs::remove_dir_all(self.shared.data_dir.join(id));
match std::fs::remove_dir_all(self.shared.data_dir.join(id)) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
{
let mut ledger = self.ledger.lock();
ledger.remove(&plugin.subject());
+155
View File
@@ -683,6 +683,161 @@ fn a_reporting_task_outlives_its_background_budget() {
assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}");
}
#[test]
fn private_directory_is_owned_persistent_and_removed_on_uninstall() {
let f = fixture(Limits {
timeout: Duration::from_secs(30),
idle_timeout: Some(Duration::from_millis(100)),
..Limits::default()
});
let pkg = sample("private-dir");
f.host.install_from_dir(&pkg, &Consent::default()).unwrap();
let run = |cmd: &str, arg: Option<&str>| f.host.run_command("test.private-dir", cmd, arg, Invocation::default());
let dir = PathBuf::from(run("directory", None).unwrap().unwrap());
assert!(!dir.starts_with(f.installed("test.private-dir")));
assert_eq!(run("roundtrip", None).unwrap().as_deref(), Some("fake private data\n"));
assert_eq!(run("large-cache", None).unwrap().as_deref(), Some("83886080"));
let file = dir.join("fake-secret.txt");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o700);
for name in ["fake-secret.txt", "cache.bin"] {
assert_eq!(std::fs::metadata(dir.join(name)).unwrap().permissions().mode() & 0o777, 0o600);
}
}
for path in [f._tmp.path().join("secret.txt"), dir.parent().unwrap().join("adjacent.txt")] {
std::fs::write(&path, "other content").unwrap();
for cmd in ["read", "write"] {
assert_eq!(run(cmd, path.to_str()).unwrap_err().kind, ErrorKind::PermissionDenied);
}
assert_eq!(std::fs::read_to_string(&path).unwrap(), "other content");
}
let other_pkg = f._tmp.path().join("other-plugin");
copy_dir(&pkg, &other_pkg);
let manifest = other_pkg.join("plugin.toml");
let text = std::fs::read_to_string(&manifest).unwrap().replace("test.private-dir", "test.private-dir-other");
std::fs::write(manifest, text).unwrap();
f.host.install_from_dir(&other_pkg, &Consent::default()).unwrap();
f.host.run_command("test.private-dir-other", "roundtrip", None, Invocation::default()).unwrap();
let other_dir = PathBuf::from(
f.host.run_command("test.private-dir-other", "directory", None, Invocation::default()).unwrap().unwrap(),
);
for cmd in ["read", "write"] {
assert_eq!(run(cmd, other_dir.join("fake-secret.txt").to_str()).unwrap_err().kind, ErrorKind::PermissionDenied);
}
for path in [
dir.join("../adjacent.txt"),
dir.join("../../test.private-dir-other/files/fake-secret.txt"),
dir.join("../../test.private-dir-other/files/new.txt"),
] {
assert_eq!(run("write", path.to_str()).unwrap_err().kind, ErrorKind::PermissionDenied);
if path.exists() {
assert_eq!(run("read", path.to_str()).unwrap_err().kind, ErrorKind::PermissionDenied);
}
}
assert!(!other_dir.join("new.txt").exists());
assert_eq!(std::fs::read_to_string(dir.parent().unwrap().join("adjacent.txt")).unwrap(), "other content");
assert_eq!(std::fs::read_to_string(other_dir.join("fake-secret.txt")).unwrap(), "fake private data\n");
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
symlink(other_dir.join("fake-secret.txt"), dir.join("escape.txt")).unwrap();
symlink(&other_dir, dir.join("escape-dir")).unwrap();
for path in [dir.join("escape.txt"), dir.join("escape-dir/fake-secret.txt")] {
for cmd in ["read", "write"] {
assert_eq!(run(cmd, path.to_str()).unwrap_err().kind, ErrorKind::PermissionDenied);
}
}
assert_eq!(
run("write", dir.join("escape-dir/new.txt").to_str()).unwrap_err().kind,
ErrorKind::PermissionDenied
);
}
// The same real file opens and saves through Documents, preserving privacy.
f.workspace.documents.open(&file).unwrap();
let doc = f.workspace.documents.by_path(&file).unwrap();
f.workspace
.documents
.apply_edits(doc, &[ide_core::TextEdit { range: ide_core::Range::default(), new_text: "edited ".into() }])
.unwrap();
assert!(run("write", None).unwrap_err().message.contains("unsaved changes"));
assert_eq!(run("read", None).unwrap().as_deref(), Some("edited fake private data\n"));
f.workspace.documents.save(doc).unwrap();
assert_eq!(std::fs::read_to_string(&file).unwrap(), "edited fake private data\n");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(std::fs::metadata(&file).unwrap().permissions().mode() & 0o777, 0o600);
}
// Reload and idle reclaim never remove content.
f.host.reload("test.private-dir").unwrap();
assert_eq!(run("directory", None).unwrap().as_deref(), dir.to_str());
std::thread::sleep(Duration::from_millis(150));
f.host.tick();
assert_eq!(f.status("test.private-dir"), PluginStatus::Inactive);
assert!(file.exists());
f.host.uninstall("test.private-dir").unwrap();
assert!(!dir.exists());
assert!(other_dir.join("fake-secret.txt").exists());
f.host.install_from_dir(&pkg, &Consent::default()).unwrap();
assert!(run("read", None).unwrap_err().message.contains("no such file"));
}
#[test]
fn private_directory_is_available_to_providers_without_a_project() {
let f = fixture(Limits::default());
f.host.install_from_dir(&sample("private-dir"), &Consent::default()).unwrap();
f.host.set_workspace(None);
f.host.run_command("test.private-dir", "roundtrip", None, Invocation::default()).unwrap();
let doc = ide_plugin::ProviderDocument {
path: f._tmp.path().join("outside.txt"),
language: "private-fixture".into(),
version: 1,
text: String::new(),
};
assert!(eventually(|| f
.host
.provide_completion(&doc, ide_core::Pos::new(0, 0), None)
.iter()
.any(|(_, item)| item.label == "fake private data\n")));
}
#[test]
fn private_directory_survives_host_restart_and_inactive_uninstall() {
let f = fixture(Limits::default());
f.host.install_from_dir(&sample("private-dir"), &Consent::default()).unwrap();
f.run("test.private-dir", "roundtrip", None).unwrap();
let dir = PathBuf::from(f.run("test.private-dir", "directory", None).unwrap().unwrap());
f.host.shutdown();
let host =
PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _: PluginEvent| {}), Limits::default())
.unwrap();
host.load_all();
host.startup_finished();
assert_eq!(
host.run_command("test.private-dir", "read", None, Invocation::default()).unwrap().as_deref(),
Some("fake private data\n")
);
host.shutdown();
host.uninstall("test.private-dir").unwrap();
assert!(!dir.exists());
}
#[cfg(unix)]
#[test]
fn private_paths_work_with_open_location_and_approved_process_arguments() {
let f = fixture(Limits::default());
f.host.install_from_dir(&sample("private-dir"), &Consent::default()).unwrap();
f.run("test.private-dir", "roundtrip", None).unwrap();
let path = PathBuf::from(f.run("test.private-dir", "open", None).unwrap().unwrap());
assert!(f.take_events().iter().any(|(client, event)| client.as_deref() == Some("main")
&& matches!(event, PluginEvent::OpenLocation { path: opened, line: 0, column: 0, .. } if opened.0 == path)));
assert!(f.run("test.private-dir", "process", None).unwrap_err().contains("process:cat"));
f.host.set_capability("test.private-dir", "process:cat", true).unwrap();
assert_eq!(f.run("test.private-dir", "process", None).unwrap().as_deref(), Some("fake private data\n"));
}
/// A missing capability is an error the plugin can handle, not a trap.
#[test]
fn ungranted_capabilities_are_denied_not_trapped() {
+5 -1
View File
@@ -24,7 +24,7 @@ use ide_core::CancellationToken;
use ide_core::ipc_path::to_ipc;
use ide_extension::Capability;
use parking_lot::Mutex;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, Instant};
@@ -253,6 +253,10 @@ pub trait HostApi: Send + Sync {
/// `fs.read` grant already covers: the unsaved buffer wins, then the disk.
/// `max` bounds the bytes read.
fn read_text_file(&self, real: &Path, max: usize) -> Result<String, HostError>;
/// The plugin's private directory, independent of the current invocation.
fn plugin_dir(&self) -> Result<PathBuf, HostError> {
Err(HostError::Failed("private plugin storage is unavailable".into()))
}
/// Replaces the text of a file whose real path the caller already checked:
/// in one step, keeping its encoding and BOM (a new file is UTF-8). Refused
/// while an editor holds unsaved changes for it.
+22 -2
View File
@@ -223,13 +223,21 @@ impl StoreData {
/// `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)
let mut granted = self.granted.clone();
if let Ok(dir) = self.host.plugin_dir() {
granted.push(Capability::FsRead(to_ipc(&dir)));
}
sandbox::resolve_granted(&granted, path).map_err(Into::into)
}
/// The same for `fs.write:…`: a file that does not exist yet is resolved
/// through the directory it would be created in.
fn writable_file(&self, path: &str) -> Result<PathBuf, wit::Error> {
sandbox::resolve_writable(&self.granted, path).map_err(Into::into)
let mut granted = self.granted.clone();
if let Ok(dir) = self.host.plugin_dir() {
granted.push(Capability::FsWrite(to_ipc(&dir)));
}
sandbox::resolve_writable(&granted, path).map_err(Into::into)
}
fn client(&self) -> Option<&str> {
@@ -1144,6 +1152,14 @@ impl views::Host for StoreData {
}
fn open_location(&mut self, path: String, line: u32, column: u32) -> Fallible<()> {
if let Ok(dir) = self.host.plugin_dir()
&& let Ok(real) = sandbox::resolve_granted(&[Capability::FsRead(to_ipc(&dir))], &path)
{
if let Err(e) = self.require_interactive() {
return Ok(Err(e));
}
return Ok(self.host.open_location(self.client(), &real, line, column).map_err(to_wit));
}
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
@@ -1204,6 +1220,10 @@ impl From<sandbox::Refusal> for wit::Error {
/// Files outside the open projects, gated by `fs.read:<path>` and
/// `fs.write:<path>` grants.
impl fs::Host for StoreData {
fn plugin_dir(&mut self) -> Fallible<String> {
Ok(self.host.plugin_dir().map(|dir| to_ipc(&dir)).map_err(to_wit))
}
fn read_file(&mut self, path: String) -> Fallible<String> {
let real = match self.granted_file(&path) {
Ok(real) => real,
Binary file not shown.
@@ -1 +1 @@
54bad5e7b66e800a70e9e8a3189be3c18b815b1020f0718e9f63b6d0d116f700
b243e04f0c4e95d66cfa588c805b504b9dc0cfb80c699f4304b888e298812fb9
Binary file not shown.
@@ -1 +1 @@
d3094365391dba5c9d65f506b4faa7abf3e4cf3906b94bbd058995efda105922
1079919eeaf79cff09ecba0e5be922dbb1c2989b5f44216c349cedf634c83939
Binary file not shown.
@@ -1 +1 @@
3fe751d024341ab4f62aa30916c51708d371e52c8b9e7b7518332ed93b24395a
3be9380d804cf5446d80ff43d00442c64de80ff7d66560d0c78ff3eee7602531
Binary file not shown.
@@ -1 +1 @@
1c5c1ce6d465aa201e39adf6c02e32f0d324d312b50371dd4f248b77ae8cda30
9b1e49d2375cccada5952bc881b78fb56740f3f391950ce4a7fa661965e49dce
Binary file not shown.
@@ -1 +1 @@
5f0877c4438f91dbb4069c733e9f2a214e366ec6e4c295c5c798a28ea75a5400
e357dc8ec3348b94ab8761b682290e8351c29dc44a96a88032c62e6b7cc1f84a
Binary file not shown.
@@ -1 +1 @@
ff562c4c62faa7eaf112e12ab839e6bef4dcbdee42fa84d949e6857065432214
2d217bbc64084a567bb9ab3b3211707bb3910775a0ff83e999224450b3147f16
Binary file not shown.
@@ -1 +1 @@
ea1066f976a18f765057a217b9de7a6c9871da321c80e0f7d4ccac6642dd44dd
d17719e25a37452fff08809ba1d8d347db9b753a592f54874cc94f1f60b647ed
Binary file not shown.
@@ -1 +1 @@
206f442d5360fc87b9e46ba5fdb7b7d0d4dc5ed550fd33181921c46463b5ea6f
e347601e9dcf2024eed831fdb1a97a4b804b1422cfb05cda73f8057cf76125e0
Binary file not shown.
@@ -1 +1 @@
1d647b76ef93c768c23b63b93d666d96323d19cb5011865e1acb5a8d178e0c3e
6e6411816e1a398718d443318a688c874f724a874d7ccf151c12d9680b6354d2
Binary file not shown.
@@ -1 +1 @@
b777e29256630e58a6e92ef95c67db8d091d0fc07af24e8b91b087eaac4d269c
2b5b1ba2fdd256a01dadecd70f402512d069a5650b39c015835424617ee9dad3
Binary file not shown.
@@ -1 +1 @@
b82d03052dd638871e278a691ad1ed09108ad4999678e6462780d686967eaf95
b191c05468f235a6fae36cef79a1cd81ab2c5cc75972fc79dc2ec96be6b1430a
Binary file not shown.
@@ -1 +1 @@
eb2efa2d8f0f1fd3579de8a965679755e99ed87b06faf528c20c6a558ca0e56c
9ca3febe3a08e7f747112a3639ee594c85008258b5474cc906d93af2d682aa91
Binary file not shown.
@@ -1 +1 @@
f72d5a14faaab7fc51dcefe37ba434615ffe47482772339e4aeffa0127f5ce79
2e86bfc80245ba988e0377b3e9f3e94ce68d01d34a188ffdace5a9ec91bebaa4
+333
View File
@@ -0,0 +1,333 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-test-private-dir"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "openidea-test-private-dir"
version = "0.1.0"
edition = "2024"
publish = false
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
@@ -0,0 +1,6 @@
A real SDK component exercising `fs.plugin-dir` during activation and commands
without filesystem/project grants. `large-cache` writes an 80 MiB cache as ten
8 MiB files, beyond the storage quota while respecting each filesystem write limit. It only writes fake text; `open` opens it in
the editor and `process` passes its path to an explicitly approved `cat`.
Rebuild with `plugins/build.sh crates/ide-plugin/tests/fixtures/private-dir`.
Binary file not shown.
@@ -0,0 +1 @@
1fe522ba55691c65026bca5a2a25ba298ee55f4800b4911a0ef38e3e9cb957c3
@@ -0,0 +1,42 @@
id = "test.private-dir"
name = "Private directory fixture"
version = "0.1.0"
engines.openidea = "^1.3"
wasm = "dist/plugin.wasm"
activation = ["on-command:*"]
[capabilities]
required = []
optional = ["process:cat"]
[[contributes.commands]]
id = "directory"
title = "Private directory"
[[contributes.commands]]
id = "roundtrip"
title = "Write fake private content"
[[contributes.commands]]
id = "read"
title = "Read file"
[[contributes.commands]]
id = "write"
title = "Write file"
[[contributes.commands]]
id = "open"
title = "Open fake private content"
[[contributes.commands]]
id = "process"
title = "Read private content with approved cat"
[[contributes.commands]]
id = "large-cache"
title = "Write private cache larger than storage"
[[contributes.providers]]
kinds = ["completion"]
languages = ["private-fixture"]
+63
View File
@@ -0,0 +1,63 @@
use openidea_plugin::{Context, Error, Invocation, Plugin, host, register_plugin};
#[derive(Default)]
struct PrivateDir;
impl Plugin for PrivateDir {
fn activate(&mut self, _: &Context) -> Result<(), Error> {
// Activation has no project or focused window.
host::fs::plugin_dir().map(drop)
}
fn run_command(&mut self, id: &str, _: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
let dir = host::fs::plugin_dir()?;
let path = format!("{dir}/fake-secret.txt");
match id {
"directory" => Ok(Some(dir)),
"roundtrip" => {
host::fs::write_file(&path, "fake private data\n")?;
let bytes = host::fs::read_bytes(&path)?;
host::fs::write_bytes(&format!("{dir}/cache.bin"), &bytes)?;
if host::fs::stat(&path)?.size != bytes.len() as u64 || host::fs::read_dir(&dir)?.len() < 2 {
return Err("private filesystem roundtrip failed".into());
}
host::fs::read_file(&path).map(Some)
}
"large-cache" => {
let bytes = vec![b'x'; 8 << 20];
let mut total = 0;
for index in 0..10 {
let file = format!("{dir}/cache-{index}.bin");
host::fs::write_bytes(&file, &bytes)?;
total += host::fs::stat(&file)?.size;
}
Ok(Some(total.to_string()))
}
"read" => host::fs::read_file(arg.unwrap_or(&path)).map(Some),
"write" => host::fs::write_file(arg.unwrap_or(&path), "fake replacement\n").map(|()| None),
"open" => host::open_location(&path, 0, 0).map(|()| Some(path)),
"process" => host::run_process("cat", &[path], None).map(|out| Some(out.stdout)),
_ => Err(Error::NotFound(id.into())),
}
}
}
impl openidea_plugin::providers::Providers for PrivateDir {
fn completion(
&mut self,
_: &openidea_plugin::providers::Document,
_: openidea_plugin::Position,
_: Option<&str>,
) -> Vec<openidea_plugin::providers::CompletionItem> {
let Ok(dir) = host::fs::plugin_dir() else { return Vec::new() };
let path = format!("{dir}/fake-secret.txt");
let Ok(text) = host::fs::read_file(&path) else { return Vec::new() };
// Providers may read their content but must not open an editor.
if host::open_location(&path, 0, 0).is_ok() {
return Vec::new();
}
vec![openidea_plugin::providers::CompletionItem::new(&text, openidea_plugin::providers::CompletionKind::Text)]
}
}
register_plugin!(PrivateDir, providers);
Binary file not shown.
@@ -1 +1 @@
6d7583de2f1ce90bec0078e9d3e5fe63db2f1c0fa1e564dbe3c65327c1c6b045
175611b829f280adf44c7a4f2d544dc78c9111bd9cb3cb4842a89e456f503249
Binary file not shown.
@@ -1 +1 @@
f9be5a80ddd63d623ec0e5a68e7f102c56955958874481cea52d4aabd0493dea
3f52ba4bcb2615ad4fe61fae48ac88e6e06c7f0713cf8b41283d12efb614f8fd
Binary file not shown.
@@ -1 +1 @@
39163b3ce79c043c21fb2f87e980db4d2a3f0ae8c6678e1865875301416ba791
40c382c554daa12d2fb83c3be98064ed7fc9122e435bd331ea6bf382f4d399dd
Binary file not shown.
@@ -1 +1 @@
c060d8941dd7aba312c42ce5205bab91506be19450ac7b671381ce2088d44dfe
8039afdedc540c2d3278813b526eaf3d1e6a8a4b8f3bab498323837e59989cbc
Binary file not shown.
@@ -1 +1 @@
b6b4ebd96fb1a879947ba2b12b2497eb466150e7c44f60257cff869b2f1db941
6d63e1eb1bb8b2c0adb41f33f5164b57ddd8860b0a8a712c046963500d48b25f
Binary file not shown.
@@ -1 +1 @@
09863db73769c86bed14a4df0b0a4d480291aa1383172c406aba18a78e1d8989
52879d53687affe3aa1ac2ef1f18bb673ca2f02615840a223c8250bb7b0a636d
+6
View File
@@ -24,3 +24,9 @@ zip.workspace = true
[dev-dependencies]
tempfile.workspace = true
serde_json.workspace = true
[target.'cfg(unix)'.dependencies]
rustix = { version = "1.1", features = ["fs", "process"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security_Authorization", "Win32_Storage_FileSystem"] }
+25 -4
View File
@@ -321,17 +321,26 @@ fn sync_error_is_tolerable(e: &std::io::Error) -> bool {
/// writes through symlinks. Falls back to an in-place write when the directory
/// does not allow creating files. Returns the new mtime in ms.
pub fn write_atomic(path: &Path, bytes: &[u8]) -> IdeResult<f64> {
write_atomic_impl(path, bytes, true)
write_atomic_impl(path, bytes, true, false)
}
/// Replaces a file atomically without falling back to truncating it in place.
/// Preserves existing permissions and writes through symlinks; if a sibling
/// temporary file cannot be created, leaves the existing file untouched.
pub fn write_atomic_strict(path: &Path, bytes: &[u8]) -> IdeResult<f64> {
write_atomic_impl(path, bytes, false)
write_atomic_impl(path, bytes, false, false)
}
fn write_atomic_impl(path: &Path, bytes: &[u8], allow_in_place: bool) -> IdeResult<f64> {
/// Atomically creates/replaces a private file, owner-only from creation.
/// The parent must be a private directory (including an inheritable owner ACL
/// on Windows). Existing Unix permissions are restricted to the owner.
pub fn write_atomic_private(path: &Path, bytes: &[u8]) -> IdeResult<f64> {
write_atomic_impl(path, bytes, false, true)
}
fn write_atomic_impl(path: &Path, bytes: &[u8], allow_in_place: bool, private: bool) -> IdeResult<f64> {
#[cfg(not(unix))]
let _ = private;
let target = match fs::symlink_metadata(path) {
Ok(m) if m.file_type().is_symlink() => fs::canonicalize(path)?,
_ => path.to_path_buf(),
@@ -361,6 +370,11 @@ fn write_atomic_impl(path: &Path, bytes: &[u8], allow_in_place: bool) -> IdeResu
Err(e) => return Err(e.into()),
};
let result = (|| -> std::io::Result<()> {
#[cfg(unix)]
if private {
use std::os::unix::fs::PermissionsExt;
file.set_permissions(fs::Permissions::from_mode(0o600))?;
}
file.write_all(bytes)?;
if let Err(e) = file.sync_all() {
// virtio-fs (macOS VMs) and similar filesystems have no fsync/F_FULLFSYNC
@@ -374,7 +388,14 @@ fn write_atomic_impl(path: &Path, bytes: &[u8], allow_in_place: bool) -> IdeResu
}
}
if let Some(meta) = &existing {
fs::set_permissions(&tmp, meta.permissions())?;
#[allow(unused_mut)]
let mut permissions = meta.permissions();
#[cfg(unix)]
if private {
use std::os::unix::fs::PermissionsExt;
permissions.set_mode(0o600);
}
fs::set_permissions(&tmp, permissions)?;
}
drop(file);
fs::rename(&tmp, &target)
+3 -1
View File
@@ -5,6 +5,7 @@ mod bytes;
mod compare;
mod encoding;
mod ops;
mod private;
mod rules;
mod watch;
@@ -27,8 +28,9 @@ pub use compare::{
use encoding::SNIFF_LEN;
pub use encoding::{
LARGE_FILE_THRESHOLD, MAX_OPEN_SIZE, READ_ONLY_THRESHOLD, decode_properties_escapes, encode,
encode_properties_ascii, save_properties_text, write_atomic, write_atomic_strict,
encode_properties_ascii, save_properties_text, write_atomic, write_atomic_private, write_atomic_strict,
};
pub use private::create_private_dir;
pub use rules::ExcludeMatcher;
pub use watch::{WatchMode, Watcher};
+273
View File
@@ -0,0 +1,273 @@
//! Owner-only directories for host-managed plugin content.
use ide_core::{IdeError, IdeResult};
use std::fs;
use std::path::Path;
/// Creates one directory with private permissions from the outset. Existing
/// directories must belong to this user and may never be symbolic links.
/// The parent is expected to be an application-owned directory.
pub fn create_private_dir(path: &Path) -> IdeResult<()> {
#[cfg(unix)]
{
use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
match fs::DirBuilder::new().mode(0o700).create(path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(e.into()),
}
let dir: fs::File = rustix::fs::open(
path,
rustix::fs::OFlags::RDONLY
| rustix::fs::OFlags::DIRECTORY
| rustix::fs::OFlags::NOFOLLOW
| rustix::fs::OFlags::CLOEXEC,
rustix::fs::Mode::empty(),
)
.map_err(std::io::Error::from)?
.into();
let meta = dir.metadata()?;
if !meta.is_dir() || meta.uid() != rustix::process::geteuid().as_raw() {
return Err(IdeError::invalid("private directory must be owned by the current user and not a symlink"));
}
if meta.mode() & 0o7777 != 0o700 {
dir.set_permissions(fs::Permissions::from_mode(0o700))?;
}
}
#[cfg(windows)]
windows::create(path)?;
#[cfg(not(any(unix, windows)))]
return Err(IdeError::unavailable("private directories are unsupported on this platform"));
Ok(())
}
#[cfg(windows)]
mod windows {
use super::*;
use std::os::windows::{ffi::OsStrExt, fs::MetadataExt};
use windows_sys::Win32::Foundation::LocalFree;
use windows_sys::Win32::Security::Authorization::{
ConvertStringSecurityDescriptorToSecurityDescriptorW, GetNamedSecurityInfoW, SDDL_REVISION_1, SE_FILE_OBJECT,
SetNamedSecurityInfoW,
};
use windows_sys::Win32::Security::{
ACE_HEADER, ACL, DACL_SECURITY_INFORMATION, GetAce, GetSecurityDescriptorControl, GetSecurityDescriptorDacl,
PROTECTED_DACL_SECURITY_INFORMATION, SE_DACL_PROTECTED, SECURITY_ATTRIBUTES,
};
use windows_sys::Win32::Storage::FileSystem::{CreateDirectoryW, FILE_ATTRIBUTE_REPARSE_POINT};
struct Descriptor(*mut std::ffi::c_void);
impl Drop for Descriptor {
fn drop(&mut self) {
unsafe {
LocalFree(self.0);
}
}
}
fn already_private(path: &[u16], expected: *mut ACL) -> IdeResult<bool> {
let mut raw = std::ptr::null_mut();
let mut actual = std::ptr::null_mut();
let error = unsafe {
GetNamedSecurityInfoW(
path.as_ptr(),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION,
std::ptr::null_mut(),
std::ptr::null_mut(),
&mut actual,
std::ptr::null_mut(),
&mut raw,
)
};
if error != 0 {
return Err(std::io::Error::from_raw_os_error(error as i32).into());
}
let descriptor = Descriptor(raw);
let mut control = 0;
let mut revision = 0;
if unsafe { GetSecurityDescriptorControl(descriptor.0, &mut control, &mut revision) } == 0 {
return Err(std::io::Error::last_os_error().into());
}
if control & SE_DACL_PROTECTED == 0 || actual.is_null() {
return Ok(false);
}
// Compare only the single ACE, not unused ACL allocation space.
if unsafe { (*actual).AceCount != 1 || (*expected).AceCount != 1 } {
return Ok(false);
}
let mut actual_ace = std::ptr::null_mut();
let mut expected_ace = std::ptr::null_mut();
if unsafe { GetAce(actual, 0, &mut actual_ace) } == 0 || unsafe { GetAce(expected, 0, &mut expected_ace) } == 0
{
return Err(std::io::Error::last_os_error().into());
}
let size = unsafe { (*expected_ace.cast::<ACE_HEADER>()).AceSize };
if unsafe { (*actual_ace.cast::<ACE_HEADER>()).AceSize } != size {
return Ok(false);
}
Ok(unsafe {
std::slice::from_raw_parts(actual_ace.cast::<u8>(), usize::from(size))
== std::slice::from_raw_parts(expected_ace.cast::<u8>(), usize::from(size))
})
}
pub(super) fn create(path: &Path) -> IdeResult<()> {
// Owner rights only, protected against inherited public access;
// children inherit the same owner-only access from birth.
let sddl = "D:P(A;OICI;FA;;;OW)\0".encode_utf16().collect::<Vec<_>>();
let mut raw = std::ptr::null_mut();
if unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
sddl.as_ptr(),
SDDL_REVISION_1,
&mut raw,
std::ptr::null_mut(),
)
} == 0
{
return Err(std::io::Error::last_os_error().into());
}
let descriptor = Descriptor(raw);
let attrs = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: descriptor.0,
bInheritHandle: 0,
};
let wide = path.as_os_str().encode_wide().chain(Some(0)).collect::<Vec<_>>();
if unsafe { CreateDirectoryW(wide.as_ptr(), &attrs) } == 0 {
let e = std::io::Error::last_os_error();
if e.kind() != std::io::ErrorKind::AlreadyExists {
return Err(e.into());
}
}
let meta = fs::symlink_metadata(path)?;
if !meta.is_dir() || meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return Err(IdeError::invalid("private directory must not be a reparse point"));
}
let mut present = 0;
let mut defaulted = 0;
let mut dacl = std::ptr::null_mut();
if unsafe { GetSecurityDescriptorDacl(descriptor.0, &mut present, &mut dacl, &mut defaulted) } == 0
|| present == 0
|| dacl.is_null()
{
return Err(std::io::Error::last_os_error().into());
}
// Reapplying an inheritable DACL would walk every cached file.
if already_private(&wide, dacl)? {
return Ok(());
}
let error = unsafe {
SetNamedSecurityInfoW(
wide.as_ptr(),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
std::ptr::null_mut(),
std::ptr::null_mut(),
dacl,
std::ptr::null_mut(),
)
};
if error != 0 {
return Err(std::io::Error::from_raw_os_error(error as i32).into());
}
Ok(())
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::os::unix::fs::{PermissionsExt, symlink};
#[test]
fn private_creation_and_editor_save_preserve_owner_only_access() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("files");
create_private_dir(&dir).unwrap();
assert_eq!(fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o700);
let path = dir.join("fake-secret.txt");
crate::write_atomic_private(&path, b"fake data").unwrap();
assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
crate::save_text(&path, "edited fake data", "utf-8", false).unwrap();
assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
assert_eq!(fs::read_to_string(&path).unwrap(), "edited fake data");
let link = tmp.path().join("link");
symlink(&dir, &link).unwrap();
assert!(create_private_dir(&link).is_err());
}
}
#[cfg(all(test, windows))]
mod windows_tests {
use super::*;
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Foundation::LocalFree;
use windows_sys::Win32::Security::Authorization::{
ConvertSecurityDescriptorToStringSecurityDescriptorW, GetNamedSecurityInfoW, SDDL_REVISION_1, SE_FILE_OBJECT,
};
use windows_sys::Win32::Security::DACL_SECURITY_INFORMATION;
fn acl(path: &Path) -> String {
let path = path.as_os_str().encode_wide().chain(Some(0)).collect::<Vec<_>>();
let mut descriptor = std::ptr::null_mut();
let error = unsafe {
GetNamedSecurityInfoW(
path.as_ptr(),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
&mut descriptor,
)
};
assert_eq!(error, 0);
let mut string = std::ptr::null_mut();
assert_ne!(
unsafe {
ConvertSecurityDescriptorToStringSecurityDescriptorW(
descriptor,
SDDL_REVISION_1,
DACL_SECURITY_INFORMATION,
&mut string,
std::ptr::null_mut(),
)
},
0
);
unsafe {
let mut len = 0;
while *string.add(len) != 0 {
len += 1;
}
let result = String::from_utf16_lossy(std::slice::from_raw_parts(string, len));
LocalFree(string.cast());
LocalFree(descriptor);
result
}
}
#[test]
fn directory_and_editor_saved_file_have_only_owner_access() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("files");
create_private_dir(&dir).unwrap();
let directory_acl = acl(&dir);
assert!(directory_acl.starts_with("D:P"), "{directory_acl}");
assert_eq!(directory_acl.matches("(A;").count(), 1, "{directory_acl}");
assert!(directory_acl.contains(";;;OW)"), "{directory_acl}");
let path = dir.join("fake-secret.txt");
crate::write_atomic_private(&path, b"fake text").unwrap();
let initial_acl = acl(&path);
assert_eq!(initial_acl.matches("(A;").count(), 1, "{initial_acl}");
assert!(initial_acl.contains(";;;OW)"), "{initial_acl}");
create_private_dir(&dir).unwrap();
assert_eq!(acl(&dir), directory_acl);
assert_eq!(acl(&path), initial_acl);
crate::save_text(&path, "edited fake text", "utf-8", false).unwrap();
assert_eq!(acl(&path), initial_acl);
}
}
+18
View File
@@ -325,6 +325,24 @@ used to write outside it. `project.write` does the same inside the open
projects, creating missing directories. Both are new in API 1.3
(`engines.openidea = "^1.3"`).
`host::fs::plugin_dir()` (API 1.3) returns a private directory belonging only to
this plugin, separate from its installed code and key-value storage. Reading and
writing inside it needs no `fs.*` or `project.read` grant, including during
activation or provider calls without an open project. The host creates the
directory owner-only (0700 on Unix, an owner-only inheritable ACL on Windows),
and files written there are owner-only from creation (0600 on Unix). Paths are
checked after symlink resolution: another plugin's directory, adjacent paths,
and links escaping this directory receive no implicit grant. Explicit `fs.*`
grants retain their normal behavior.
Use it for generated editor content or caches larger than `storage`. Files and
the directory survive idle reclaim, plugin reloads, updates, and app restarts;
uninstall removes them. There is no TTL or stop-time deletion. A returned path
works with `host::open_location` and as an argument to an approved process;
normal UI, trust, and process approval rules still apply. Editor saves preserve
the private file's permissions. Filesystem writes keep the usual size limits,
encoding behavior, and protection of unsaved editor changes.
Logging, notifications, status bar items, tasks and run-time contributions
are always available. Calling a gated function without its capability returns
`Error::Denied("<capability>")` — the plugin keeps running and can degrade
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
61485dc08ad905dbb2d8d211ef8be8a6e88292d2d41ba11c15be54c672eccba0
710e95defa1995356f259b1fd69c7df8cfe1d0c1c55b04df0f0d7173b7cb61b9
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
1111db3957880758cf4ae2c34c95510846aef43c73ddeb03e901d5a10610544e
145b164b99c18b6e9d0d0c2704fff0fa878fc56761d5cb76669a2a0cc9ddb3aa
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
8d0c17fb643bb7368877ae7cfe342286bc1c8a1fc8fdc00c7491beb5c24390fe
897d2b653cb5b8a91ef806307efa6fefd773c0aaacc746bf87fd7d2bea4bf147
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
ab15b403fb606ea226b491098f7b06335a03735d46a80a2d6e00cb042d201960
9724a33ae85205245ec0bb94369d55e2df08aa328e872af2c03ad45c230d7865
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
ab64135fcdeee8579e9b9613b47cccd94f66ff46d7c3a0507320a1364ffe8921
d5245ffd43097795062f7bb887162c13d99d48b68f7883e4ff6adb41a663d157
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
5b73dc19e676472cb94ec6c020d7fc6279b59aa8e8439b3840ece799035f1a2b
2fbf40065eae7c9ef0e927dfae4fde1f698858b2ae3047db9c242a12e443d823
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
c6763b53f7d4228678827234dfd02e413bb604c867b4ead8cd185316e6482897
7b5480ba83b888a43bb641f95ad679d60b132548516b92d91d8d7e118220f2c3
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
1c735085dc8386adc94d4598eea8a2d5e582a98894f7742f83b97a9ddda7fb60
6762b2c436f8dd0a586759cbf0b2cc316560f581619fc32efb717f96505f720b
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
1d0dcf3288ec08122b64e51fceb0055f3627531b7152a3cccfe7d44ddd4887c2
f8e3d365d62382437b4af9d7889c135725cf437bb1ec3a1144466af72ef8f859
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
ae73ca009c7b36b3dd118a3a903c0132baf9c78baaceffe8d2a8a4ea8d182d1d
9e8e7a1fc6aa73e11655114e4cf8c642ea6ea2318b0711fb4e1a82fb0f353bae
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
730d2bc7b15d2598840ec8668555f89cdf77b800c2ce21df7601880c57be6b2b
428dd3b2ebc9bc248775e385fc431f0621e564533ed54f70d12bf5db950627cb
+15 -2
View File
@@ -369,6 +369,10 @@ mod imp {
Ok(entries.into_iter().map(|e| super::fs::DirEntry { name: e.name, kind: file_kind(e.kind) }).collect())
}
pub fn fs_plugin_dir() -> Result<String, Error> {
wit::fs::plugin_dir().map_err(error)
}
pub fn fs_write_file(path: &str, text: &str) -> Result<(), Error> {
wit::fs::write_file(path, text).map_err(error)
}
@@ -1032,8 +1036,9 @@ pub fn unsubscribe(kind: EventKind) {
}
/// Files outside the open projects, for a program the plugin integrates with
/// or a tool installed elsewhere. Every path needs an `fs.read:<path>`
/// (reading) or `fs.write:<path>` (writing) grant covering it; without one a
/// or a tool installed elsewhere. Outside [`fs::plugin_dir`], paths need an
/// `fs.read:<path>` (reading) or `fs.write:<path>` (writing) grant covering it;
/// without one a
/// call returns [`Error::Denied`] naming the capability the manifest has to
/// declare. Paths are absolute, or start with `~/` for the user's home. A file
/// inside a project is [`Project::read_file`] / [`Project::write_file`].
@@ -1041,6 +1046,14 @@ pub mod fs {
use super::imp;
use crate::types::{Error, FileKind, FileStat};
/// The plugin's private persistent directory (0700, owner-only ACL on
/// Windows), removed on uninstall. Files created here are owner-only and
/// need no `fs.*` grants. Suitable for editor content and large caches;
/// retained across idle reclaim, reload, and application restarts.
pub fn plugin_dir() -> Result<String, Error> {
imp::fs_plugin_dir()
}
/// An entry of a directory ([`read_dir`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DirEntry {
+35 -4
View File
@@ -518,7 +518,14 @@ pub mod current {
/// `fs.read:<base>` (or `fs.write:<base>`) covers `<base>` itself and what is
/// under it: `/a` covers `/a/b` but not `/ab`.
const PLUGIN_DIR: &str = "/openidea/plugin-data/mock/files";
fn require_fs(s: &State, kind: &str, path: &str) -> Result<(), Error> {
if (path == PLUGIN_DIR || path.starts_with(&format!("{PLUGIN_DIR}/")))
&& !path.split(['/', '\\']).any(|part| part == "..")
{
return Ok(());
}
let prefix = format!("{kind}:");
let covered = s.capabilities.iter().any(|c| match c.strip_prefix(&prefix) {
Some(base) => {
@@ -581,7 +588,7 @@ pub mod current {
.filter_map(|c| c.strip_prefix(kind))
.any(|base| base.trim_end_matches(['/', '\\']) == dir)
});
granted || has_children(s, dir)
dir == PLUGIN_DIR || granted || has_children(s, dir)
}
/// What a write has to get past: an `fs.write` grant, a path that is not a
@@ -899,6 +906,10 @@ pub mod current {
})
}
pub fn fs_plugin_dir() -> Result<String, Error> {
Ok(PLUGIN_DIR.to_owned())
}
pub fn fs_read_file(path: &str) -> Result<String, Error> {
with(|s| {
check_external(s, "fs.read", path)?;
@@ -938,7 +949,7 @@ pub mod current {
return Ok(FileStat { kind: FileKind::File, size: bytes.len() as u64, modified: None });
}
// A directory is a prefix of the files the mock holds.
if has_children(s, path) {
if path == PLUGIN_DIR || has_children(s, path) {
Ok(FileStat { kind: FileKind::Directory, size: 0, modified: None })
} else {
Err(Error::NotFound(path.into()))
@@ -988,7 +999,7 @@ pub mod current {
_ => {}
}
}
if entries.is_empty() {
if entries.is_empty() && path != PLUGIN_DIR {
return Err(Error::NotFound(path.into()));
}
Ok(entries.into_iter().map(|(name, kind)| crate::host::fs::DirEntry { name, kind }).collect())
@@ -1254,7 +1265,9 @@ pub mod current {
pub fn open_location(path: &str, line: u32, column: u32) -> Result<(), Error> {
with(|s| {
require(s, "project.read")?;
if require_fs(s, "fs.read", path).is_err() || !path.starts_with(PLUGIN_DIR) {
require(s, "project.read")?;
}
s.locations.push((path.into(), line, column));
Ok(())
})
@@ -1751,6 +1764,24 @@ mod tests {
assert_eq!(mock.diagnostics()["/project/a.java"].len(), 1);
}
#[test]
fn private_directory_needs_no_filesystem_grants() {
let _mock = MockHost::install().with_capabilities(&[]);
let dir = host::fs::plugin_dir().unwrap();
assert_eq!(host::fs::stat(&dir).unwrap().kind, FileKind::Directory);
assert!(host::fs::read_dir(&dir).unwrap().is_empty());
let file = format!("{dir}/fake.txt");
host::fs::write_file(&file, "fake text").unwrap();
assert_eq!(host::fs::read_file(&file).unwrap(), "fake text");
host::fs::write_bytes(&file, b"fake bytes").unwrap();
assert_eq!(host::fs::read_bytes(&file).unwrap(), b"fake bytes");
assert_eq!(host::fs::read_dir(&dir).unwrap()[0].name, "fake.txt");
for path in [format!("{dir}-other/file"), format!("{dir}/../other/file")] {
assert!(matches!(host::fs::read_file(&path), Err(Error::Denied(_))));
assert!(matches!(host::fs::write_file(&path, "x"), Err(Error::Denied(_))));
}
}
#[test]
fn fs_write_needs_a_grant_of_its_own() {
// `fs.read` does not let a plugin write.
+8 -2
View File
@@ -3,8 +3,9 @@ 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>` (reading) or `fs.write:<path>`
/// A plugin's own WASI has no preopened directory. Every path outside its
/// own `plugin-dir` is checked against its `fs.read:<path>` (reading) or
/// `fs.write:<path>`
/// (writing; it doesn't imply reading) grants; without one covering the path a
/// call returns `denied` naming the capability the manifest has to declare. A
/// file inside a project is `workspace.project.read-file` / `write-file`.
@@ -13,6 +14,11 @@ interface fs {
use types.{error};
use workspace.{file-kind, file-stat};
/// The plugin's persistent private directory, created owner-only (0700,
/// owner-only ACL on Windows), removed on uninstall. Reading and writing
/// under it needs no fs.* grant; files created there are owner-only.
plugin-dir: func() -> result<string, error>;
/// 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`.
+2
View File
@@ -139,6 +139,8 @@ interface views {
/// Opens `path` (project-relative or absolute, inside the project) in an
/// editor of the window the call came from, the caret at `line` and
/// `column` (0-based; UTF-16 column): a view's links to code.
/// An absolute path inside the plugin's own fs.plugin-dir also works,
/// without project.read or an open project, from a command or event.
open-location: func(path: string, line: u32, column: u32) -> result<_, error>;
}
+8 -2
View File
@@ -3,8 +3,9 @@ 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>` (reading) or `fs.write:<path>`
/// A plugin's own WASI has no preopened directory. Every path outside its
/// own `plugin-dir` is checked against its `fs.read:<path>` (reading) or
/// `fs.write:<path>`
/// (writing; it doesn't imply reading) grants; without one covering the path a
/// call returns `denied` naming the capability the manifest has to declare. A
/// file inside a project is `workspace.project.read-file` / `write-file`.
@@ -13,6 +14,11 @@ interface fs {
use types.{error};
use workspace.{file-kind, file-stat};
/// The plugin's persistent private directory, created owner-only (0700,
/// owner-only ACL on Windows), removed on uninstall. Reading and writing
/// under it needs no fs.* grant; files created there are owner-only.
plugin-dir: func() -> result<string, error>;
/// 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`.
+2
View File
@@ -139,6 +139,8 @@ interface views {
/// Opens `path` (project-relative or absolute, inside the project) in an
/// editor of the window the call came from, the caret at `line` and
/// `column` (0-based; UTF-16 column): a view's links to code.
/// An absolute path inside the plugin's own fs.plugin-dir also works,
/// without project.read or an open project, from a command or event.
open-location: func(path: string, line: u32, column: u32) -> result<_, error>;
}