diff --git a/Cargo.lock b/Cargo.lock index d2e519a43..1c3bb3101 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4783,12 +4783,14 @@ dependencies = [ "ignore", "notify", "parking_lot", + "rustix", "serde", "serde_json", "specta", "tempfile", "tracing", "trash", + "windows-sys 0.61.2", "zip", ] diff --git a/crates/ide-host/src/plugins/api.rs b/crates/ide-host/src/plugins/api.rs index 9513cb3a2..31b19d560 100644 --- a/crates/ide-host/src/plugins/api.rs +++ b/crates/ide-host/src/plugins/api.rs @@ -1794,6 +1794,16 @@ impl HostApi for PluginApi { serde_json::to_string(&value).ok() } + fn plugin_dir(&self) -> Result { + 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 { // 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)) + } } } diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index ad113fd45..1326eb716 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -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()); diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index 9b851917e..fab352c4f 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -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() { diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index 763913116..42491954b 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -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; + /// The plugin's private directory, independent of the current invocation. + fn plugin_dir(&self) -> Result { + 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. diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs index 470a4d082..eaa4e6061 100644 --- a/crates/ide-plugin/src/wit_host.rs +++ b/crates/ide-plugin/src/wit_host.rs @@ -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 { - 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 { - 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 for wit::Error { /// Files outside the open projects, gated by `fs.read:` and /// `fs.write:` grants. impl fs::Host for StoreData { + fn plugin_dir(&mut self) -> Fallible { + Ok(self.host.plugin_dir().map(|dir| to_ipc(&dir)).map_err(to_wit)) + } + fn read_file(&mut self, path: String) -> Fallible { let real = match self.granted_file(&path) { Ok(real) => real, diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm index da8b43557..55b9cf546 100644 Binary files a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources index 0b4a5e1b1..97c654186 100644 --- a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources @@ -1 +1 @@ -54bad5e7b66e800a70e9e8a3189be3c18b815b1020f0718e9f63b6d0d116f700 +b243e04f0c4e95d66cfa588c805b504b9dc0cfb80c699f4304b888e298812fb9 diff --git a/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm index 611d74da9..152ec98f0 100644 Binary files a/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm.sources index b5c48e2ad..adb199892 100644 --- a/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm.sources @@ -1 +1 @@ -d3094365391dba5c9d65f506b4faa7abf3e4cf3906b94bbd058995efda105922 +1079919eeaf79cff09ecba0e5be922dbb1c2989b5f44216c349cedf634c83939 diff --git a/crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm index 9784a1227..f4a32700b 100644 Binary files a/crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm.sources index 1a97fb186..0b2110f8d 100644 --- a/crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm.sources @@ -1 +1 @@ -3fe751d024341ab4f62aa30916c51708d371e52c8b9e7b7518332ed93b24395a +3be9380d804cf5446d80ff43d00442c64de80ff7d66560d0c78ff3eee7602531 diff --git a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm index 8a04abbd3..074636187 100644 Binary files a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources index c25b5370e..edc4bceb5 100644 --- a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources @@ -1 +1 @@ -1c5c1ce6d465aa201e39adf6c02e32f0d324d312b50371dd4f248b77ae8cda30 +9b1e49d2375cccada5952bc881b78fb56740f3f391950ce4a7fa661965e49dce diff --git a/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm index c8a379956..b99fb74c0 100644 Binary files a/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources index 596f3ec92..f4e5acd68 100644 --- a/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources @@ -1 +1 @@ -5f0877c4438f91dbb4069c733e9f2a214e366ec6e4c295c5c798a28ea75a5400 +e357dc8ec3348b94ab8761b682290e8351c29dc44a96a88032c62e6b7cc1f84a diff --git a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm index 1511dd15a..4641c77cb 100644 Binary files a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources index e950ab372..5014ca22a 100644 --- a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources @@ -1 +1 @@ -ff562c4c62faa7eaf112e12ab839e6bef4dcbdee42fa84d949e6857065432214 +2d217bbc64084a567bb9ab3b3211707bb3910775a0ff83e999224450b3147f16 diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm index cd24c43ae..2d95bd98a 100644 Binary files a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources index dc09e44a1..547e1387a 100644 --- a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources @@ -1 +1 @@ -ea1066f976a18f765057a217b9de7a6c9871da321c80e0f7d4ccac6642dd44dd +d17719e25a37452fff08809ba1d8d347db9b753a592f54874cc94f1f60b647ed diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm index 0a92a4c97..ebb48a2b5 100644 Binary files a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources index 2ea418335..258ce390d 100644 --- a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources @@ -1 +1 @@ -206f442d5360fc87b9e46ba5fdb7b7d0d4dc5ed550fd33181921c46463b5ea6f +e347601e9dcf2024eed831fdb1a97a4b804b1422cfb05cda73f8057cf76125e0 diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm index f5c8917f0..be22447b6 100644 Binary files a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources index 073deca1b..5ed332e97 100644 --- a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources @@ -1 +1 @@ -1d647b76ef93c768c23b63b93d666d96323d19cb5011865e1acb5a8d178e0c3e +6e6411816e1a398718d443318a688c874f724a874d7ccf151c12d9680b6354d2 diff --git a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm index 588e9cd9f..0a5ff06f7 100644 Binary files a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources index 6980f3a56..25c98a7bb 100644 --- a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources @@ -1 +1 @@ -b777e29256630e58a6e92ef95c67db8d091d0fc07af24e8b91b087eaac4d269c +2b5b1ba2fdd256a01dadecd70f402512d069a5650b39c015835424617ee9dad3 diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm index 8823b9f90..ac2956a08 100644 Binary files a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources index 2c98ccff0..08ef8eeab 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources @@ -1 +1 @@ -b82d03052dd638871e278a691ad1ed09108ad4999678e6462780d686967eaf95 +b191c05468f235a6fae36cef79a1cd81ab2c5cc75972fc79dc2ec96be6b1430a diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm index d8bcadbb8..d6f0415e4 100644 Binary files a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources index 097fc6877..3fcc9418c 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources @@ -1 +1 @@ -eb2efa2d8f0f1fd3579de8a965679755e99ed87b06faf528c20c6a558ca0e56c +9ca3febe3a08e7f747112a3639ee594c85008258b5474cc906d93af2d682aa91 diff --git a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm index c5571ad1b..cc5032eda 100644 Binary files a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources index 02aa83b03..29fcbddaa 100644 --- a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources @@ -1 +1 @@ -f72d5a14faaab7fc51dcefe37ba434615ffe47482772339e4aeffa0127f5ce79 +2e86bfc80245ba988e0377b3e9f3e94ce68d01d34a188ffdace5a9ec91bebaa4 diff --git a/crates/ide-plugin/tests/fixtures/private-dir/Cargo.lock b/crates/ide-plugin/tests/fixtures/private-dir/Cargo.lock new file mode 100644 index 000000000..2a17ed442 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/private-dir/Cargo.lock @@ -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" diff --git a/crates/ide-plugin/tests/fixtures/private-dir/Cargo.toml b/crates/ide-plugin/tests/fixtures/private-dir/Cargo.toml new file mode 100644 index 000000000..948d1777b --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/private-dir/Cargo.toml @@ -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" diff --git a/crates/ide-plugin/tests/fixtures/private-dir/README.md b/crates/ide-plugin/tests/fixtures/private-dir/README.md new file mode 100644 index 000000000..b404f45cd --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/private-dir/README.md @@ -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`. diff --git a/crates/ide-plugin/tests/fixtures/private-dir/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/private-dir/dist/plugin.wasm new file mode 100644 index 000000000..9facf5798 Binary files /dev/null and b/crates/ide-plugin/tests/fixtures/private-dir/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/private-dir/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/private-dir/dist/plugin.wasm.sources new file mode 100644 index 000000000..c06542e72 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/private-dir/dist/plugin.wasm.sources @@ -0,0 +1 @@ +1fe522ba55691c65026bca5a2a25ba298ee55f4800b4911a0ef38e3e9cb957c3 diff --git a/crates/ide-plugin/tests/fixtures/private-dir/plugin.toml b/crates/ide-plugin/tests/fixtures/private-dir/plugin.toml new file mode 100644 index 000000000..580941b7e --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/private-dir/plugin.toml @@ -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"] diff --git a/crates/ide-plugin/tests/fixtures/private-dir/src/lib.rs b/crates/ide-plugin/tests/fixtures/private-dir/src/lib.rs new file mode 100644 index 000000000..7e1aa24ab --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/private-dir/src/lib.rs @@ -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, 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 { + 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); diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm index bcf688013..6896d2afc 100644 Binary files a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources index 0eaec21b5..86ce748d0 100644 --- a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources @@ -1 +1 @@ -6d7583de2f1ce90bec0078e9d3e5fe63db2f1c0fa1e564dbe3c65327c1c6b045 +175611b829f280adf44c7a4f2d544dc78c9111bd9cb3cb4842a89e456f503249 diff --git a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm index b29ca8a0a..848444dba 100644 Binary files a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources index bf8d132c3..d129dd999 100644 --- a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources @@ -1 +1 @@ -f9be5a80ddd63d623ec0e5a68e7f102c56955958874481cea52d4aabd0493dea +3f52ba4bcb2615ad4fe61fae48ac88e6e06c7f0713cf8b41283d12efb614f8fd diff --git a/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm index 331ba7525..cced0fea5 100644 Binary files a/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm.sources index 21cb0413f..72ef034bb 100644 --- a/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm.sources @@ -1 +1 @@ -39163b3ce79c043c21fb2f87e980db4d2a3f0ae8c6678e1865875301416ba791 +40c382c554daa12d2fb83c3be98064ed7fc9122e435bd331ea6bf382f4d399dd diff --git a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm index 351822f48..ca2af3295 100644 Binary files a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources index 35eab5007..58b168532 100644 --- a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources @@ -1 +1 @@ -c060d8941dd7aba312c42ce5205bab91506be19450ac7b671381ce2088d44dfe +8039afdedc540c2d3278813b526eaf3d1e6a8a4b8f3bab498323837e59989cbc diff --git a/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm index 1b1674350..e8b0a13d8 100644 Binary files a/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm.sources index acd8beb0f..58d9c536a 100644 --- a/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm.sources @@ -1 +1 @@ -b6b4ebd96fb1a879947ba2b12b2497eb466150e7c44f60257cff869b2f1db941 +6d63e1eb1bb8b2c0adb41f33f5164b57ddd8860b0a8a712c046963500d48b25f diff --git a/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm index 36a526350..77534ce35 100644 Binary files a/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm.sources index 762ed05e2..01af947e0 100644 --- a/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm.sources @@ -1 +1 @@ -09863db73769c86bed14a4df0b0a4d480291aa1383172c406aba18a78e1d8989 +52879d53687affe3aa1ac2ef1f18bb673ca2f02615840a223c8250bb7b0a636d diff --git a/crates/ide-vfs/Cargo.toml b/crates/ide-vfs/Cargo.toml index dd4bfdf30..c04f5b725 100644 --- a/crates/ide-vfs/Cargo.toml +++ b/crates/ide-vfs/Cargo.toml @@ -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"] } diff --git a/crates/ide-vfs/src/encoding.rs b/crates/ide-vfs/src/encoding.rs index a34159d00..db544fb0b 100644 --- a/crates/ide-vfs/src/encoding.rs +++ b/crates/ide-vfs/src/encoding.rs @@ -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 { - 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 { - 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 { +/// 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 { + write_atomic_impl(path, bytes, false, true) +} + +fn write_atomic_impl(path: &Path, bytes: &[u8], allow_in_place: bool, private: bool) -> IdeResult { + #[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) diff --git a/crates/ide-vfs/src/lib.rs b/crates/ide-vfs/src/lib.rs index ab9d2dfdb..a7279af99 100644 --- a/crates/ide-vfs/src/lib.rs +++ b/crates/ide-vfs/src/lib.rs @@ -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}; diff --git a/crates/ide-vfs/src/private.rs b/crates/ide-vfs/src/private.rs new file mode 100644 index 000000000..9882616b3 --- /dev/null +++ b/crates/ide-vfs/src/private.rs @@ -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 { + 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::()).AceSize }; + if unsafe { (*actual_ace.cast::()).AceSize } != size { + return Ok(false); + } + Ok(unsafe { + std::slice::from_raw_parts(actual_ace.cast::(), usize::from(size)) + == std::slice::from_raw_parts(expected_ace.cast::(), 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::>(); + 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::() as u32, + lpSecurityDescriptor: descriptor.0, + bInheritHandle: 0, + }; + let wide = path.as_os_str().encode_wide().chain(Some(0)).collect::>(); + 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::>(); + 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); + } +} diff --git a/plugins/README.md b/plugins/README.md index 9f01c55f7..5791fa3d4 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -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("")` — the plugin keeps running and can degrade diff --git a/plugins/official/ai-completion/dist/plugin.wasm b/plugins/official/ai-completion/dist/plugin.wasm index 0984cc58a..9640db216 100644 Binary files a/plugins/official/ai-completion/dist/plugin.wasm and b/plugins/official/ai-completion/dist/plugin.wasm differ diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index d15539c1b..c8cdd1293 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -61485dc08ad905dbb2d8d211ef8be8a6e88292d2d41ba11c15be54c672eccba0 +710e95defa1995356f259b1fd69c7df8cfe1d0c1c55b04df0f0d7173b7cb61b9 diff --git a/plugins/official/checkstyle/dist/plugin.wasm b/plugins/official/checkstyle/dist/plugin.wasm index 7df3c2542..0d535e53a 100644 Binary files a/plugins/official/checkstyle/dist/plugin.wasm and b/plugins/official/checkstyle/dist/plugin.wasm differ diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index c809d19fa..94f53796b 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -1111db3957880758cf4ae2c34c95510846aef43c73ddeb03e901d5a10610544e +145b164b99c18b6e9d0d0c2704fff0fa878fc56761d5cb76669a2a0cc9ddb3aa diff --git a/plugins/official/csv-editor/dist/plugin.wasm b/plugins/official/csv-editor/dist/plugin.wasm index 8ee275de0..f9348b8e1 100644 Binary files a/plugins/official/csv-editor/dist/plugin.wasm and b/plugins/official/csv-editor/dist/plugin.wasm differ diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources index c2ab1fa28..f63423056 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -8d0c17fb643bb7368877ae7cfe342286bc1c8a1fc8fdc00c7491beb5c24390fe +897d2b653cb5b8a91ef806307efa6fefd773c0aaacc746bf87fd7d2bea4bf147 diff --git a/plugins/official/git-toolbox/dist/plugin.wasm b/plugins/official/git-toolbox/dist/plugin.wasm index 91c23389f..251966e71 100644 Binary files a/plugins/official/git-toolbox/dist/plugin.wasm and b/plugins/official/git-toolbox/dist/plugin.wasm differ diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources index 7d6a2eda2..4224ff9a6 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -ab15b403fb606ea226b491098f7b06335a03735d46a80a2d6e00cb042d201960 +9724a33ae85205245ec0bb94369d55e2df08aa328e872af2c03ad45c230d7865 diff --git a/plugins/official/graphviz/dist/plugin.wasm b/plugins/official/graphviz/dist/plugin.wasm index 030ee4338..c9dca6b8e 100644 Binary files a/plugins/official/graphviz/dist/plugin.wasm and b/plugins/official/graphviz/dist/plugin.wasm differ diff --git a/plugins/official/graphviz/dist/plugin.wasm.sources b/plugins/official/graphviz/dist/plugin.wasm.sources index b8b727487..04659899a 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -ab64135fcdeee8579e9b9613b47cccd94f66ff46d7c3a0507320a1364ffe8921 +d5245ffd43097795062f7bb887162c13d99d48b68f7883e4ff6adb41a663d157 diff --git a/plugins/official/key-promoter/dist/plugin.wasm b/plugins/official/key-promoter/dist/plugin.wasm index 153b89397..70aeeee3d 100644 Binary files a/plugins/official/key-promoter/dist/plugin.wasm and b/plugins/official/key-promoter/dist/plugin.wasm differ diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index f510f5bae..c89faa388 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -5b73dc19e676472cb94ec6c020d7fc6279b59aa8e8439b3840ece799035f1a2b +2fbf40065eae7c9ef0e927dfae4fde1f698858b2ae3047db9c242a12e443d823 diff --git a/plugins/official/license-header/dist/plugin.wasm b/plugins/official/license-header/dist/plugin.wasm index 801d55a30..ca272ee18 100644 Binary files a/plugins/official/license-header/dist/plugin.wasm and b/plugins/official/license-header/dist/plugin.wasm differ diff --git a/plugins/official/license-header/dist/plugin.wasm.sources b/plugins/official/license-header/dist/plugin.wasm.sources index e49404054..85b014072 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -c6763b53f7d4228678827234dfd02e413bb604c867b4ead8cd185316e6482897 +7b5480ba83b888a43bb641f95ad679d60b132548516b92d91d8d7e118220f2c3 diff --git a/plugins/official/mybatis/dist/plugin.wasm b/plugins/official/mybatis/dist/plugin.wasm index 47871af59..42d6f614d 100644 Binary files a/plugins/official/mybatis/dist/plugin.wasm and b/plugins/official/mybatis/dist/plugin.wasm differ diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index d4796da67..2283eb53b 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -1c735085dc8386adc94d4598eea8a2d5e582a98894f7742f83b97a9ddda7fb60 +6762b2c436f8dd0a586759cbf0b2cc316560f581619fc32efb717f96505f720b diff --git a/plugins/official/regex-tester/dist/plugin.wasm b/plugins/official/regex-tester/dist/plugin.wasm index 3d8d03049..0828885b2 100644 Binary files a/plugins/official/regex-tester/dist/plugin.wasm and b/plugins/official/regex-tester/dist/plugin.wasm differ diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index 0d3e0ce75..12cb7e196 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -1d0dcf3288ec08122b64e51fceb0055f3627531b7152a3cccfe7d44ddd4887c2 +f8e3d365d62382437b4af9d7889c135725cf437bb1ec3a1144466af72ef8f859 diff --git a/plugins/official/solidity/dist/plugin.wasm b/plugins/official/solidity/dist/plugin.wasm index 4257b3109..8dccd1dd7 100644 Binary files a/plugins/official/solidity/dist/plugin.wasm and b/plugins/official/solidity/dist/plugin.wasm differ diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index a61266d3c..623c361f2 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -ae73ca009c7b36b3dd118a3a903c0132baf9c78baaceffe8d2a8a4ea8d182d1d +9e8e7a1fc6aa73e11655114e4cf8c642ea6ea2318b0711fb4e1a82fb0f353bae diff --git a/plugins/official/string-tools/dist/plugin.wasm b/plugins/official/string-tools/dist/plugin.wasm index 58e1b37e9..4f7b8ac75 100644 Binary files a/plugins/official/string-tools/dist/plugin.wasm and b/plugins/official/string-tools/dist/plugin.wasm differ diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index ac204d044..d7bedcb6c 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -730d2bc7b15d2598840ec8668555f89cdf77b800c2ce21df7601880c57be6b2b +428dd3b2ebc9bc248775e385fc431f0621e564533ed54f70d12bf5db950627cb diff --git a/sdk/rust/openidea-plugin/src/host.rs b/sdk/rust/openidea-plugin/src/host.rs index 3e5f06ec3..66ffea31a 100644 --- a/sdk/rust/openidea-plugin/src/host.rs +++ b/sdk/rust/openidea-plugin/src/host.rs @@ -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 { + 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:` -/// (reading) or `fs.write:` (writing) grant covering it; without one a +/// or a tool installed elsewhere. Outside [`fs::plugin_dir`], paths need an +/// `fs.read:` (reading) or `fs.write:` (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 { + imp::fs_plugin_dir() + } + /// An entry of a directory ([`read_dir`]). #[derive(Clone, Debug, PartialEq, Eq)] pub struct DirEntry { diff --git a/sdk/rust/openidea-plugin/src/mock.rs b/sdk/rust/openidea-plugin/src/mock.rs index 7d2d1b50e..056b6fcac 100644 --- a/sdk/rust/openidea-plugin/src/mock.rs +++ b/sdk/rust/openidea-plugin/src/mock.rs @@ -518,7 +518,14 @@ pub mod current { /// `fs.read:` (or `fs.write:`) covers `` 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 { + Ok(PLUGIN_DIR.to_owned()) + } + pub fn fs_read_file(path: &str) -> Result { 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. diff --git a/sdk/rust/openidea-plugin/wit/fs.wit b/sdk/rust/openidea-plugin/wit/fs.wit index 5be15207f..7cd07fd71 100644 --- a/sdk/rust/openidea-plugin/wit/fs.wit +++ b/sdk/rust/openidea-plugin/wit/fs.wit @@ -3,8 +3,9 @@ package openidea:plugin@1.3.0; /// 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:` (reading) or `fs.write:` +/// A plugin's own WASI has no preopened directory. Every path outside its +/// own `plugin-dir` is checked against its `fs.read:` (reading) or +/// `fs.write:` /// (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; + /// 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`. diff --git a/sdk/rust/openidea-plugin/wit/views.wit b/sdk/rust/openidea-plugin/wit/views.wit index 6fa5e51c9..92db335e7 100644 --- a/sdk/rust/openidea-plugin/wit/views.wit +++ b/sdk/rust/openidea-plugin/wit/views.wit @@ -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>; } diff --git a/wit/fs.wit b/wit/fs.wit index 5be15207f..7cd07fd71 100644 --- a/wit/fs.wit +++ b/wit/fs.wit @@ -3,8 +3,9 @@ package openidea:plugin@1.3.0; /// 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:` (reading) or `fs.write:` +/// A plugin's own WASI has no preopened directory. Every path outside its +/// own `plugin-dir` is checked against its `fs.read:` (reading) or +/// `fs.write:` /// (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; + /// 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`. diff --git a/wit/views.wit b/wit/views.wit index 6fa5e51c9..92db335e7 100644 --- a/wit/views.wit +++ b/wit/views.wit @@ -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>; }