mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 17:02:17 +00:00
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:
1 parent
a5db5cdda8
commit
04b1b863df
84 files changed
+1100
-51
No files matched your search
Generated
+2
@@ -4783,12 +4783,14 @@ dependencies = [
|
||||
"ignore",
|
||||
"notify",
|
||||
"parking_lot",
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
"trash",
|
||||
"windows-sys 0.61.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"] }
|
||||
@@ -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)
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
61485dc08ad905dbb2d8d211ef8be8a6e88292d2d41ba11c15be54c672eccba0
|
||||
710e95defa1995356f259b1fd69c7df8cfe1d0c1c55b04df0f0d7173b7cb61b9
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
1111db3957880758cf4ae2c34c95510846aef43c73ddeb03e901d5a10610544e
|
||||
145b164b99c18b6e9d0d0c2704fff0fa878fc56761d5cb76669a2a0cc9ddb3aa
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
8d0c17fb643bb7368877ae7cfe342286bc1c8a1fc8fdc00c7491beb5c24390fe
|
||||
897d2b653cb5b8a91ef806307efa6fefd773c0aaacc746bf87fd7d2bea4bf147
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
ab15b403fb606ea226b491098f7b06335a03735d46a80a2d6e00cb042d201960
|
||||
9724a33ae85205245ec0bb94369d55e2df08aa328e872af2c03ad45c230d7865
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
ab64135fcdeee8579e9b9613b47cccd94f66ff46d7c3a0507320a1364ffe8921
|
||||
d5245ffd43097795062f7bb887162c13d99d48b68f7883e4ff6adb41a663d157
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
5b73dc19e676472cb94ec6c020d7fc6279b59aa8e8439b3840ece799035f1a2b
|
||||
2fbf40065eae7c9ef0e927dfae4fde1f698858b2ae3047db9c242a12e443d823
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
c6763b53f7d4228678827234dfd02e413bb604c867b4ead8cd185316e6482897
|
||||
7b5480ba83b888a43bb641f95ad679d60b132548516b92d91d8d7e118220f2c3
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
1c735085dc8386adc94d4598eea8a2d5e582a98894f7742f83b97a9ddda7fb60
|
||||
6762b2c436f8dd0a586759cbf0b2cc316560f581619fc32efb717f96505f720b
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
1d0dcf3288ec08122b64e51fceb0055f3627531b7152a3cccfe7d44ddd4887c2
|
||||
f8e3d365d62382437b4af9d7889c135725cf437bb1ec3a1144466af72ef8f859
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
ae73ca009c7b36b3dd118a3a903c0132baf9c78baaceffe8d2a8a4ea8d182d1d
|
||||
9e8e7a1fc6aa73e11655114e4cf8c642ea6ea2318b0711fb4e1a82fb0f353bae
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
730d2bc7b15d2598840ec8668555f89cdf77b800c2ce21df7601880c57be6b2b
|
||||
428dd3b2ebc9bc248775e385fc431f0621e564533ed54f70d12bf5db950627cb
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user