diff --git a/crates/ide-host/tests/plugin_commit.rs b/crates/ide-host/tests/plugin_commit.rs index 095d10716..82fb3d1d7 100644 --- a/crates/ide-host/tests/plugin_commit.rs +++ b/crates/ide-host/tests/plugin_commit.rs @@ -232,7 +232,8 @@ fn the_commit_points_need_plugin_api_1_3() { let tmp = tempfile::tempdir().unwrap(); let host = PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); - let manifest = std::fs::read_to_string(fixture_dir().join("plugin.toml")).unwrap(); + // The manifest surgery below matches on `\n`; normalize so a CRLF checkout (Windows) behaves the same. + let manifest = std::fs::read_to_string(fixture_dir().join("plugin.toml")).unwrap().replace("\r\n", "\n"); for (what, manifest) in [ ( "commit.message", diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index a6cf5b29c..a1e7fa865 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -361,6 +361,9 @@ fn read_file_is_confined_to_the_project() { let err = f.run("misbehave", "read", Some("src/../../secret.txt")).unwrap_err(); assert!(err.contains("outside the project root"), "{err}"); assert!(f.run("misbehave", "read", Some("/etc/hosts")).is_err()); + // A missing path inside the project is `not-found`, like the mock reports. + let err = f.run("misbehave", "read", Some("src/missing.txt")).unwrap_err(); + assert!(err.contains("not found"), "{err}"); // Without a project there is nothing to read. let inv = Invocation::default(); let err = f.host.run_command("misbehave", "read", Some("src/notes.txt"), inv).unwrap_err().message; diff --git a/crates/ide-plugin/src/lib.rs b/crates/ide-plugin/src/lib.rs index dae692bd1..1725a06fc 100644 --- a/crates/ide-plugin/src/lib.rs +++ b/crates/ide-plugin/src/lib.rs @@ -20,6 +20,6 @@ pub use runtime::{ API_VERSION, CANCELLED, CallCancel, CallError, GuestError, HostApi, PluginEngine, Runtime, RuntimeSetup, check_api, component_api, host_api, }; -pub use sandbox::{MAX_READ_BYTES, read_text, resolve_in_root}; +pub use sandbox::{MAX_READ_BYTES, inside_root, read_text, resolve_in_root}; pub use types::*; pub use wasmtime::component::Component; diff --git a/crates/ide-plugin/src/sandbox.rs b/crates/ide-plugin/src/sandbox.rs index 23913a367..1756b1f9a 100644 --- a/crates/ide-plugin/src/sandbox.rs +++ b/crates/ide-plugin/src/sandbox.rs @@ -5,15 +5,53 @@ use std::path::{Path, PathBuf}; /// Largest file `read-file` returns. pub const MAX_READ_BYTES: u64 = 16 << 20; -/// Resolves `path` (absolute, or relative to `root`) and checks that the real -/// path — after following `..` and symlinks — stays inside `root`. -pub fn resolve_in_root(root: &Path, path: &str) -> Result { +/// The canonical `root` and the absolute form of `path` (absolute, or +/// relative to `root`) — before any symlink or existence check. +fn joined_path(root: &Path, path: &str) -> Result<(PathBuf, PathBuf), String> { if path.is_empty() { return Err("empty path".into()); } let root = root.canonicalize().map_err(|e| format!("project root unavailable: {e}"))?; let requested = Path::new(path); let joined = if requested.is_absolute() { requested.to_path_buf() } else { root.join(requested) }; + Ok((root, joined)) +} + +/// `joined` after `..`/symlink resolution as far as the file system allows: +/// the canonical path when it exists, else the canonicalized deepest existing +/// ancestor with the still-missing tail resolved textually — so the location +/// of a missing path is still judged where it would really land. +fn resolve_real(joined: &Path) -> PathBuf { + let mut tail = Vec::new(); + let mut candidate = joined.to_path_buf(); + let mut real = loop { + match candidate.canonicalize() { + Ok(real) => break real, + Err(_) => { + let Some(last) = candidate.components().next_back() else { + return joined.to_path_buf(); + }; + tail.push(last.as_os_str().to_owned()); + if !candidate.pop() { + return joined.to_path_buf(); + } + } + } + }; + for part in tail.iter().rev() { + if part == ".." { + real.pop(); + } else if part != "." { + real.push(part); + } + } + real +} + +/// Resolves `path` (absolute, or relative to `root`) and checks that the real +/// path — after following `..` and symlinks — stays inside `root`. +pub fn resolve_in_root(root: &Path, path: &str) -> Result { + let (root, joined) = joined_path(root, path)?; let real = joined.canonicalize().map_err(|_| format!("{path}: no such file"))?; if !real.starts_with(&root) { return Err(format!("{path}: access outside the project root is not allowed")); @@ -21,6 +59,15 @@ pub fn resolve_in_root(root: &Path, path: &str) -> Result { Ok(real) } +/// Whether `path` (absolute, or relative to `root`) lands inside `root`, +/// without requiring it to exist: `Ok(true)` also for a missing path, so +/// callers distinguish "outside" from "absent". Existing symlinks are +/// followed; a missing tail is judged at its would-be location. +pub fn inside_root(root: &Path, path: &str) -> Result { + let (root, joined) = joined_path(root, path)?; + Ok(resolve_real(&joined).starts_with(&root)) +} + /// Reads a UTF-8 file that `resolve_in_root` already approved. pub fn read_text(real: &Path) -> Result { let meta = std::fs::metadata(real).map_err(|e| e.to_string())?; @@ -62,6 +109,27 @@ mod tests { assert!(read_text(&resolve_in_root(&root, "src").unwrap()).unwrap_err().contains("not a file")); } + #[test] + fn inside_root_covers_missing_paths() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("proj"); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::write(root.join("src/a.txt"), "hi").unwrap(); + std::fs::write(tmp.path().join("secret.txt"), "s").unwrap(); + + assert!(inside_root(&root, "src/a.txt").unwrap()); + assert!(inside_root(&root, "src").unwrap()); + assert!(inside_root(&root, "missing.txt").unwrap()); + assert!(inside_root(&root, "missing/deep/nested.txt").unwrap()); + assert!(inside_root(&root, "missing/../src/a.txt").unwrap()); + assert!(!inside_root(&root, "../secret.txt").unwrap()); + assert!(!inside_root(&root, "missing/../../secret.txt").unwrap()); + assert!(!inside_root(&root, "missing/sub/../../../secret.txt").unwrap()); + let abs_out = tmp.path().join("secret.txt"); + assert!(!inside_root(&root, abs_out.to_str().unwrap()).unwrap()); + assert!(inside_root(&root, "").is_err()); + } + #[cfg(unix)] #[test] fn symlinks_cannot_escape() { @@ -71,6 +139,11 @@ mod tests { std::fs::write(tmp.path().join("secret.txt"), "s").unwrap(); std::os::unix::fs::symlink(tmp.path().join("secret.txt"), root.join("link")).unwrap(); assert!(resolve_in_root(&root, "link").unwrap_err().contains("outside")); + assert!(!inside_root(&root, "link").unwrap()); + assert!(!inside_root(&root, "link/../x").unwrap()); + std::os::unix::fs::symlink(tmp.path(), root.join("dirlink")).unwrap(); + assert!(!inside_root(&root, "dirlink/secret.txt").unwrap()); + assert!(!inside_root(&root, "dirlink/missing.txt").unwrap()); } #[test] diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs index 350f3d539..81a08e1f8 100644 --- a/crates/ide-plugin/src/wit_host.rs +++ b/crates/ide-plugin/src/wit_host.rs @@ -424,7 +424,10 @@ impl workspace::HostProject for StoreData { let root = self.table.get(&this)?.root.clone(); let real = match sandbox::resolve_in_root(&root, &path) { Ok(real) => real, - Err(e) => return Ok(Err(wit::Error::Invalid(e))), + Err(e) => { + let e = if e.ends_with("no such file") { wit::Error::NotFound(e) } else { wit::Error::Invalid(e) }; + return Ok(Err(e)); + } }; if let Some(text) = self.host.buffer_text(&real) { return Ok(Ok(text)); @@ -432,6 +435,57 @@ impl workspace::HostProject for StoreData { Ok(sandbox::read_text(&real).map_err(wit::Error::Failed)) } + fn exists(&mut self, this: Resource, path: String) -> Fallible { + if let Err(e) = self.require(Capability::ProjectRead) { + return Ok(Err(e)); + } + let root = self.table.get(&this)?.root.clone(); + match sandbox::inside_root(&root, &path) { + Err(e) => Ok(Err(wit::Error::Invalid(e))), + Ok(false) => { + Ok(Err(wit::Error::Invalid(format!("{path}: access outside the project root is not allowed")))) + } + Ok(true) => { + let requested = Path::new(&path); + let joined = if requested.is_absolute() { requested.to_path_buf() } else { root.join(requested) }; + Ok(Ok(joined.exists())) + } + } + } + + fn stat(&mut self, this: Resource, path: String) -> Fallible { + if let Err(e) = self.require(Capability::ProjectRead) { + return Ok(Err(e)); + } + let root = self.table.get(&this)?.root.clone(); + let real = match sandbox::resolve_in_root(&root, &path) { + Ok(real) => real, + Err(e) => { + let e = if e.ends_with("no such file") { wit::Error::NotFound(e) } else { wit::Error::Invalid(e) }; + return Ok(Err(e)); + } + }; + let meta = match std::fs::symlink_metadata(&real) { + Ok(m) => m, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(Err(wit::Error::NotFound(format!("{path}: no such file")))); + } + Err(e) => return Ok(Err(wit::Error::Failed(e.to_string()))), + }; + let kind = if meta.is_file() { + workspace::FileKind::File + } else if meta.is_dir() { + workspace::FileKind::Directory + } else if meta.is_symlink() { + workspace::FileKind::Symlink + } else { + workspace::FileKind::Other + }; + let modified = + meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs()); + Ok(Ok(workspace::FileStat { kind, size: meta.len(), modified })) + } + fn find_files(&mut self, this: Resource, glob: String, limit: u32) -> Fallible> { if let Err(e) = self.require(Capability::ProjectRead) { return Ok(Err(e)); diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm index b016857f5..512310a56 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 36c7fcb14..e4e7a0746 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 @@ -a85f313fdb635212cd96c7a9694cb64417bc40cddf5193a69b2873a7009f3f42 +e9f2e3818dab64aa24e3f1852eff301c2ba971e90157067b11b832cdcc361fd6 diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm index d12cb4711..29fd2096a 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 28a0544c1..91bac91d4 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 @@ -8c431ac710ce5b767bc8f15b93fbf02db7aef6d64656ca9ff92de92dc662e47a +ad6f103e549a51cc64aa9ce03e0759c7e247b98fa4d7d84f2d5cbbd32a56e47a diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm index cbedffa95..0150f1231 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 5120eaa6f..51895a5d7 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 @@ -5dabb045a170b49ca39de03040bd3ecb2e90edf67b6cd88a092c74e6d2752409 +bb5747f6e319f1852dbfd92c6a65e6e54a41a6d508fe96dc570a5c0684f7d322 diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm index 9f55c84a7..8fed17f4b 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 c300b8de5..6495924ea 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 @@ -58da8760b67495c4fb3d05f222f47884444c1191c67ecf6b2c0e8449687ff5c9 +f651ab979bc6b52c5bdde0417667393ffd442726b406b9064a431e0fad39b8be diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm index b4e3e8f27..410c59c70 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 c88c09b42..ee95222e0 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 @@ -0847aa7c377511e03963bc1a838eb0dda520e1d7c7a08681b32ea68ba541360a +7fd84d18cbcf8673c988e7d3d9a143883635f967ba2b3f679fb1f3aafaa9169c diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm index d6f2fc30e..1da6e94d3 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 69ff39593..628e1120d 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 @@ -bff0a5b416c3bcd8e94ba8cf082843913067ec126f2ab4db45353db3b71e61a0 +a2762b47de8abd3d1c4fff0130001c6a9347292f94bfffd3933db1a270c95076 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 c57f5e944..e00246b33 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 a8f34c7e9..0ffbe7fb7 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 @@ -c58bf55470bd795f9e664ad3a8bd1173c863b1b9405d2e81f1da1c4039884dd3 +a38d39b7d43d4f2fbcb31064a9037f3a6d58fb16b3e7bfeb0f85f5f13459dbff diff --git a/plugins/README.md b/plugins/README.md index 945f11ca9..da543d8af 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -351,7 +351,8 @@ then run `openidea-plugin check`. | `Plugin::activate` / `deactivate` | export `lifecycle` | Per instance: after a crash or idle reclaim a new one is activated. | | `TextEditor::document()` → `{version, text}` | `editor.text-editor.document` | `selections()` are those when the command ran (0-based lines, UTF-16 columns). | | `TextEditor::apply_edits(version, edits)` | `editor.text-editor.apply-edits` | One undoable change, only if the document is still at `version` — else `Error::Stale` and nothing changes. `transform_selections` retries once. | -| `Project::root()`, `read_file(path)` | `workspace.project` | Absolute or project-relative; `..`/symlinks escaping the root are rejected; UTF-8 only, ≤ 16 MiB; unsaved editor contents win. | +| `Project::root()`, `read_file(path)` | `workspace.project` | Absolute or project-relative; `..`/symlinks escaping the root are rejected; UTF-8 only, ≤ 16 MiB; unsaved editor contents win; a missing path is `Error::NotFound`. | +| `Project::exists(path)`, `stat(path)` | `workspace.project` | `exists` covers files and directories, hidden and ignored included, without reading content; `stat` gives kind, size and modified time (`Error::NotFound` for a missing path). | | `host::projects()` | `workspace.projects` | Every open project (for scheduled work without a window). | | `Project::vcs_status()`, `vcs_remotes()`, `vcs_fetch()` | `vcs` | `vcs_remotes` returns `(name, url)` pairs; fetch runs git, so only in trusted projects (call it inside a task). | | `host::pending_commit()`, `host::set_commit_message(revision, text)` | `commit` | The commit being prepared in the calling window and its message; see _Commits_. | diff --git a/plugins/official/ai-completion/dist/plugin.wasm b/plugins/official/ai-completion/dist/plugin.wasm index 3c75ed134..fb381145c 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 9b826c938..715a8a3cb 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -89515d507386505d244ed6ac19a85b7288f6d8ef30f19f4b770d8b0f84f33d63 +c9cbe5d5a6d2d5bf3bda9308b6e07c374a60b3f95714425e9f0d855bcaa38666 diff --git a/plugins/official/checkstyle/dist/plugin.wasm b/plugins/official/checkstyle/dist/plugin.wasm index b0ee5334e..d28bdc426 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 dda99d364..29814a869 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -a0b3353006032af9f9052c8413a00ff8069294d1c3e86ed520f9c4b8012094c1 +dada583da1a117277325077bf700a2b485433768836335683ff3f4d370e43334 diff --git a/plugins/official/csv-editor/dist/plugin.wasm b/plugins/official/csv-editor/dist/plugin.wasm index bfb8ce3b9..3fc0f3f05 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 cae540e1e..5574d116d 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -7b055db21ef99eea933df6b311248ac18ec8a57d57cbe9af0bc5f694c8e8d48a +720fc0e60bb11cabbf9658bbada16eb889e509ab436d62b17b2a64ef9651a559 diff --git a/plugins/official/git-toolbox/dist/plugin.wasm b/plugins/official/git-toolbox/dist/plugin.wasm index 285dacc0c..6ce3baa4b 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 4be824457..965e14771 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -d742d45a1bee8c5d0c4c2bab9341eb3d3fc7feae1c1229485330dd0b58374092 +4ced6756e3f94c0989a7c00948c3456954eaa0b08cb072a3b1790872eabc6a45 diff --git a/plugins/official/graphviz/dist/plugin.wasm b/plugins/official/graphviz/dist/plugin.wasm index 3006754db..2299fe45e 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 315bc573b..85650c065 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -511a8419aab4bde97c09a7a7134ee523dade17b771697382ffb521ad1e5798bf +ae7c04be3b7811e560c711077391bec14705a1acb769ba8a7e0212dc8e8ec5ff diff --git a/plugins/official/key-promoter/dist/plugin.wasm b/plugins/official/key-promoter/dist/plugin.wasm index 7800303fc..b96fa15a7 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 ffdfa4ac5..5c7365be8 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -8b5e78147995375b841f342c7441158d20d33388be808f08c233eca27c3b4d7f +df4f523b86e29e7f5f992eeb9b8fd9ab61b58ac8b9e05d20f1db457e9328f187 diff --git a/plugins/official/license-header/dist/plugin.wasm b/plugins/official/license-header/dist/plugin.wasm index 024bf460b..b6f0ef5cb 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 e01b11bf6..e1f166679 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -6925c7bdc12a5bd74e864ab4e706c155b0a994d5bbf27cc437f0220aed4cfc68 +0a4d7548b157513276176e4e02ec5d8a5a71f55f4098afd9963ec0f47312d6de diff --git a/plugins/official/mybatis/dist/plugin.wasm b/plugins/official/mybatis/dist/plugin.wasm index b24d3a634..5b05e7474 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 24b6717fe..b291da9ec 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -43c47ef0199e7c7550655bd571c75d4109cb6435ae0e99fa68123c8e4d823e48 +451c7bf3dcbdd6e57dc657cb7d1f4ddee8d262a859863ac71287b85eb474793c diff --git a/plugins/official/regex-tester/dist/plugin.wasm b/plugins/official/regex-tester/dist/plugin.wasm index 1f2cfa4b4..fa02945af 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 bbadfc664..94fe42e82 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -3a632f982c08c24cc2d67511f1fac3ff3b29229e552399b46e4d0aa2623200f7 +daf6fb2a5cff9210cc380f19dca42480d380166e028239d985abf37ebddd7a6c diff --git a/plugins/official/solidity/dist/plugin.wasm b/plugins/official/solidity/dist/plugin.wasm index 750876fee..b5b4c7104 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 68ddffbe2..81268eb86 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -588b3af37102b1871b40184509b299b39ecd15212ccdb58467badb889ca7bc03 +1364aed3a99e0769d53b444c51b873083aeb7088afa7dc6c8821cd312d0319ad diff --git a/plugins/official/string-tools/dist/plugin.wasm b/plugins/official/string-tools/dist/plugin.wasm index 688ab327c..edb2b3fdc 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 3f5453ccf..23d8f1f07 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -723d68d4de1ec8b6f246b1bd30e56f177df08f7bf3c17907b0ec9ef6cbdf1bf3 +d6d1a81f02c7346b365689bc3893745aa9d867e56c187cdd28efde758bfb0fe8 diff --git a/sdk/rust/openidea-plugin/src/host.rs b/sdk/rust/openidea-plugin/src/host.rs index a5aceb139..55a0b0e31 100644 --- a/sdk/rust/openidea-plugin/src/host.rs +++ b/sdk/rust/openidea-plugin/src/host.rs @@ -265,6 +265,26 @@ mod imp { p.find_files(glob, limit).map_err(error) } + pub fn project_exists(p: &ProjectRes, path: &str) -> Result { + p.exists(path).map_err(error) + } + + pub fn project_stat(p: &ProjectRes, path: &str) -> Result { + use wit::workspace::FileKind as K; + p.stat(path) + .map(|s| FileStat { + kind: match s.kind { + K::File => FileKind::File, + K::Directory => FileKind::Directory, + K::Symlink => FileKind::Symlink, + K::Other => FileKind::Other, + }, + size: s.size, + modified: s.modified, + }) + .map_err(error) + } + pub fn publish_diagnostics(path: &str, items: &[Diagnostic]) -> Result<(), Error> { use wit::diagnostics::{Diagnostic as D, RelatedInformation as R, Severity as S}; let items: Vec = items @@ -424,11 +444,25 @@ impl Project { } /// Reads a UTF-8 file inside the project (absolute or root-relative); - /// unsaved editor contents win. Needs `project.read`. + /// unsaved editor contents win. A missing path is `Error::NotFound`. + /// Needs `project.read`. pub fn read_file(&self, path: &str) -> Result { imp::project_read(&self.inner, path) } + /// Whether `path` names an existing file or directory inside the project + /// — including hidden and ignored ones — without reading any content. + /// Needs `project.read`. + pub fn exists(&self, path: &str) -> Result { + imp::project_exists(&self.inner, path) + } + + /// Metadata of an existing path inside the project; a missing path is + /// `Error::NotFound`. Needs `project.read`. + pub fn stat(&self, path: &str) -> Result { + imp::project_stat(&self.inner, path) + } + /// The repository containing the project (`None`: not under git). Needs `vcs.read`. pub fn vcs_status(&self) -> Result, Error> { imp::vcs_status(&self.inner) diff --git a/sdk/rust/openidea-plugin/src/mock.rs b/sdk/rust/openidea-plugin/src/mock.rs index 27c144a52..c49dd9b57 100644 --- a/sdk/rust/openidea-plugin/src/mock.rs +++ b/sdk/rust/openidea-plugin/src/mock.rs @@ -55,6 +55,7 @@ struct State { root: Option, other_projects: Vec, files: BTreeMap, + dirs: BTreeSet, editor: Option, selected_paths: Vec, notifications: Vec, @@ -149,6 +150,12 @@ impl MockHost { self.state.borrow_mut().files.insert(path.to_owned(), text.to_owned()); } + /// A project directory `exists` and `stat` report (project-relative or + /// absolute path); file paths count as existing too. + pub fn add_dir(&self, path: &str) { + self.state.borrow_mut().dirs.insert(path.to_owned()); + } + /// Sets the paths invocations report as selected. pub fn select_paths(&self, paths: &[&str]) { self.state.borrow_mut().selected_paths = paths.iter().map(|p| (*p).to_owned()).collect(); @@ -527,14 +534,31 @@ pub mod current { }) } + /// `path` inside `p`'s root as a project-relative key, or `Invalid` for a + /// path leaving the root (as the host does). + fn rel_in_root<'a>(p: &ProjectRes, path: &'a str) -> Result<&'a str, Error> { + let root = p.root.as_str(); + let rel = path.strip_prefix(root).map(|p| p.trim_start_matches('/')).unwrap_or(path); + if rel.starts_with('/') || rel.split('/').any(|seg| seg == "..") { + return Err(Error::Invalid(format!("{path}: access outside the project root is not allowed"))); + } + Ok(rel) + } + + fn path_known(s: &State, p: &ProjectRes, path: &str, rel: &str) -> bool { + let root = p.root.as_str(); + s.files.contains_key(rel) + || s.files.contains_key(path) + || s.dirs.contains(rel) + || s.dirs.contains(path) + || s.editor.as_ref().is_some_and(|e| e.path == path || e.path == format!("{root}/{rel}")) + } + pub fn project_read(p: &ProjectRes, path: &str) -> Result { with(|s| { require(s, "project.read")?; let root = p.root.as_str(); - let rel = path.strip_prefix(root).map(|p| p.trim_start_matches('/')).unwrap_or(path); - if rel.starts_with('/') || rel.split('/').any(|seg| seg == "..") { - return Err(Error::Invalid(format!("{path}: access outside the project root is not allowed"))); - } + let rel = rel_in_root(p, path)?; let open = s.editor.as_ref().filter(|e| e.path == path || e.path == format!("{root}/{rel}")); if let Some(e) = open { return Ok(e.text.clone()); @@ -543,6 +567,33 @@ pub mod current { }) } + pub fn project_exists(p: &ProjectRes, path: &str) -> Result { + with(|s| { + require(s, "project.read")?; + let rel = rel_in_root(p, path)?; + Ok(rel.is_empty() || path_known(s, p, path, rel)) + }) + } + + pub fn project_stat(p: &ProjectRes, path: &str) -> Result { + with(|s| { + require(s, "project.read")?; + let rel = rel_in_root(p, path)?; + if !path_known(s, p, path, rel) { + return if rel.is_empty() { + Ok(FileStat { kind: FileKind::Directory, size: 0, modified: None }) + } else { + Err(Error::NotFound(path.into())) + }; + } + let file = s.files.get(rel).or_else(|| s.files.get(path)); + let open = s.editor.as_ref().filter(|e| e.path == path || e.path == format!("{}/{rel}", p.root)); + let kind = if file.is_some() || open.is_some() { FileKind::File } else { FileKind::Directory }; + let size = file.or_else(|| open.map(|e| &e.text)).map_or(0, |t| t.len() as u64); + Ok(FileStat { kind, size, modified: None }) + }) + } + pub fn invocation_project(_: InvocationRef<'_>) -> Option { with(|s| s.root.clone().map(|root| MockProject { root })) } @@ -891,6 +942,35 @@ mod tests { assert!(mock.tasks()[0].ended); } + #[test] + fn exists_and_stat_report_missing_as_not_found() { + let mock = MockHost::install(); + mock.add_file("src/a.txt", "hello"); + mock.add_dir("src"); + mock.add_dir(".hidden"); + let project = mock.invocation().project().unwrap(); + assert!(project.exists("src/a.txt").unwrap()); + assert!(project.exists("src").unwrap()); + assert!(project.exists(".hidden").unwrap()); + assert!(project.exists("/project/src/a.txt").unwrap()); + assert!(!project.exists("missing.txt").unwrap()); + assert!(!project.exists("missing/dir").unwrap()); + assert!(matches!(project.exists("../outside"), Err(Error::Invalid(_)))); + assert!(matches!(project.read_file("missing.txt"), Err(Error::NotFound(_)))); + + let file = project.stat("src/a.txt").unwrap(); + assert_eq!((file.kind, file.size), (FileKind::File, 5)); + let dir = project.stat("src").unwrap(); + assert_eq!(dir.kind, FileKind::Directory); + assert!(matches!(project.stat("missing.txt"), Err(Error::NotFound(_)))); + assert!(matches!(project.stat("../outside"), Err(Error::Invalid(_)))); + + let read_only = MockHost::install().with_capabilities(&[]); + let project = read_only.invocation().project().unwrap(); + assert!(matches!(project.exists("x"), Err(Error::Denied(_)))); + assert!(matches!(project.stat("x"), Err(Error::Denied(_)))); + } + #[test] fn versioned_edits() { let mock = MockHost::install(); diff --git a/sdk/rust/openidea-plugin/src/types.rs b/sdk/rust/openidea-plugin/src/types.rs index 8538213a2..6f93cca82 100644 --- a/sdk/rust/openidea-plugin/src/types.rs +++ b/sdk/rust/openidea-plugin/src/types.rs @@ -294,6 +294,26 @@ pub struct RepositoryStatus { pub changed_files: u32, } +/// What a path inside a project is, as [`crate::host::Project::stat`] reports it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum FileKind { + File, + Directory, + Symlink, + #[default] + Other, +} + +/// Metadata of a path inside a project. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct FileStat { + pub kind: FileKind, + /// Bytes, for a file. + pub size: u64, + /// Seconds since the Unix epoch; `None` when the file system does not track it. + pub modified: Option, +} + /// A file the commit being prepared changes ([`crate::host::pending_commit`]). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct CommitChange { diff --git a/sdk/rust/openidea-plugin/wit/workspace.wit b/sdk/rust/openidea-plugin/wit/workspace.wit index f1d21fad2..bcf84e32e 100644 --- a/sdk/rust/openidea-plugin/wit/workspace.wit +++ b/sdk/rust/openidea-plugin/wit/workspace.wit @@ -3,14 +3,42 @@ package openidea:plugin@1.3.0; interface workspace { use types.{error}; + /// What a path inside a project is. + enum file-kind { + file, + directory, + symlink, + other, + } + + /// Metadata of a path inside a project, as `stat` reports it. + record file-stat { + kind: file-kind, + /// Bytes, for a file. + size: u64, + /// Seconds since the Unix epoch; `none` when the file system does + /// not track it. + modified: option, + } + /// A project open in the IDE. resource project { /// Absolute path of the project root. Requires `project.read`. root: func() -> result; /// Reads a UTF-8 text file inside the project (absolute, or relative /// to the root); paths leaving the root are refused. Unsaved editor - /// contents win over the disk. Requires `project.read`. + /// contents win over the disk. A missing path is `not-found`. + /// Requires `project.read`. read-file: func(path: string) -> result; + /// Whether `path` (absolute, or relative to the root) names an + /// existing file or directory — including hidden and ignored ones, + /// without reading any content. Paths leaving the root are refused. + /// Requires `project.read`. + exists: func(path: string) -> result; + /// Metadata of an existing path inside the project (absolute, or + /// relative to the root); a missing path is `not-found` and paths + /// leaving the root are refused. Requires `project.read`. + stat: func(path: string) -> result; /// Project files whose root-relative path matches `glob` (`**/*Mapper.xml`), /// at most `limit`, as absolute paths. Ignored and excluded files are /// skipped. Requires `project.read`. diff --git a/wit/workspace.wit b/wit/workspace.wit index f1d21fad2..bcf84e32e 100644 --- a/wit/workspace.wit +++ b/wit/workspace.wit @@ -3,14 +3,42 @@ package openidea:plugin@1.3.0; interface workspace { use types.{error}; + /// What a path inside a project is. + enum file-kind { + file, + directory, + symlink, + other, + } + + /// Metadata of a path inside a project, as `stat` reports it. + record file-stat { + kind: file-kind, + /// Bytes, for a file. + size: u64, + /// Seconds since the Unix epoch; `none` when the file system does + /// not track it. + modified: option, + } + /// A project open in the IDE. resource project { /// Absolute path of the project root. Requires `project.read`. root: func() -> result; /// Reads a UTF-8 text file inside the project (absolute, or relative /// to the root); paths leaving the root are refused. Unsaved editor - /// contents win over the disk. Requires `project.read`. + /// contents win over the disk. A missing path is `not-found`. + /// Requires `project.read`. read-file: func(path: string) -> result; + /// Whether `path` (absolute, or relative to the root) names an + /// existing file or directory — including hidden and ignored ones, + /// without reading any content. Paths leaving the root are refused. + /// Requires `project.read`. + exists: func(path: string) -> result; + /// Metadata of an existing path inside the project (absolute, or + /// relative to the root); a missing path is `not-found` and paths + /// leaving the root are refused. Requires `project.read`. + stat: func(path: string) -> result; /// Project files whose root-relative path matches `glob` (`**/*Mapper.xml`), /// at most `limit`, as absolute paths. Ignored and excluded files are /// skipped. Requires `project.read`.