fix(plugin): report missing files as not-found and add project exists/stat (#1964)

* fix(plugin): report missing files as not-found and add project exists/stat

read-file of a missing path now returns error::not-found in the host,
matching the SDK mock, instead of error::invalid. project gains
exists(path) for a cheap existence check (files and directories, hidden
and ignored included) and stat(path) for kind/size/modified, both
gated by project.read and refusing paths outside the root.

Fixes #1957

* chore: silence clippy in sandbox test and rebuild plugin components

- drop an unnecessary to_path_buf clippy flags on unix
- rebuild every checked-in plugin.wasm: the SDK and wit/ changed, so
  their source fingerprints moved

* test(plugin): cover missing-file not-found and fix CRLF-only Windows failure
Цей коміт міститься в:
arookieofc authored and GitHub committed 2026-11-07 18:01:33 +08:00
1 джерело 4cfdb972eb
коміт 303d5950bf
47 files changed
+354 -32

No files matched your search

+2 -1
Переглянути файл
@@ -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",
+3
Переглянути файл
@@ -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;
+1 -1
Переглянути файл
@@ -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;
+76 -3
Переглянути файл
@@ -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<PathBuf, String> {
/// 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<PathBuf, String> {
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<PathBuf, String> {
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<bool, String> {
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<String, String> {
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]
+55 -1
Переглянути файл
@@ -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<ProjectRes>, path: String) -> Fallible<bool> {
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<ProjectRes>, path: String) -> Fallible<workspace::FileStat> {
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<ProjectRes>, glob: String, limit: u32) -> Fallible<Vec<String>> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
a85f313fdb635212cd96c7a9694cb64417bc40cddf5193a69b2873a7009f3f42
e9f2e3818dab64aa24e3f1852eff301c2ba971e90157067b11b832cdcc361fd6
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
8c431ac710ce5b767bc8f15b93fbf02db7aef6d64656ca9ff92de92dc662e47a
ad6f103e549a51cc64aa9ce03e0759c7e247b98fa4d7d84f2d5cbbd32a56e47a
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
5dabb045a170b49ca39de03040bd3ecb2e90edf67b6cd88a092c74e6d2752409
bb5747f6e319f1852dbfd92c6a65e6e54a41a6d508fe96dc570a5c0684f7d322
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
58da8760b67495c4fb3d05f222f47884444c1191c67ecf6b2c0e8449687ff5c9
f651ab979bc6b52c5bdde0417667393ffd442726b406b9064a431e0fad39b8be
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
0847aa7c377511e03963bc1a838eb0dda520e1d7c7a08681b32ea68ba541360a
7fd84d18cbcf8673c988e7d3d9a143883635f967ba2b3f679fb1f3aafaa9169c
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
bff0a5b416c3bcd8e94ba8cf082843913067ec126f2ab4db45353db3b71e61a0
a2762b47de8abd3d1c4fff0130001c6a9347292f94bfffd3933db1a270c95076
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c58bf55470bd795f9e664ad3a8bd1173c863b1b9405d2e81f1da1c4039884dd3
a38d39b7d43d4f2fbcb31064a9037f3a6d58fb16b3e7bfeb0f85f5f13459dbff
+2 -1
Переглянути файл
@@ -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_. |
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
89515d507386505d244ed6ac19a85b7288f6d8ef30f19f4b770d8b0f84f33d63
c9cbe5d5a6d2d5bf3bda9308b6e07c374a60b3f95714425e9f0d855bcaa38666
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
a0b3353006032af9f9052c8413a00ff8069294d1c3e86ed520f9c4b8012094c1
dada583da1a117277325077bf700a2b485433768836335683ff3f4d370e43334
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
7b055db21ef99eea933df6b311248ac18ec8a57d57cbe9af0bc5f694c8e8d48a
720fc0e60bb11cabbf9658bbada16eb889e509ab436d62b17b2a64ef9651a559
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
d742d45a1bee8c5d0c4c2bab9341eb3d3fc7feae1c1229485330dd0b58374092
4ced6756e3f94c0989a7c00948c3456954eaa0b08cb072a3b1790872eabc6a45
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
511a8419aab4bde97c09a7a7134ee523dade17b771697382ffb521ad1e5798bf
ae7c04be3b7811e560c711077391bec14705a1acb769ba8a7e0212dc8e8ec5ff
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
8b5e78147995375b841f342c7441158d20d33388be808f08c233eca27c3b4d7f
df4f523b86e29e7f5f992eeb9b8fd9ab61b58ac8b9e05d20f1db457e9328f187
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
6925c7bdc12a5bd74e864ab4e706c155b0a994d5bbf27cc437f0220aed4cfc68
0a4d7548b157513276176e4e02ec5d8a5a71f55f4098afd9963ec0f47312d6de
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
43c47ef0199e7c7550655bd571c75d4109cb6435ae0e99fa68123c8e4d823e48
451c7bf3dcbdd6e57dc657cb7d1f4ddee8d262a859863ac71287b85eb474793c
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
3a632f982c08c24cc2d67511f1fac3ff3b29229e552399b46e4d0aa2623200f7
daf6fb2a5cff9210cc380f19dca42480d380166e028239d985abf37ebddd7a6c
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
588b3af37102b1871b40184509b299b39ecd15212ccdb58467badb889ca7bc03
1364aed3a99e0769d53b444c51b873083aeb7088afa7dc6c8821cd312d0319ad
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
723d68d4de1ec8b6f246b1bd30e56f177df08f7bf3c17907b0ec9ef6cbdf1bf3
d6d1a81f02c7346b365689bc3893745aa9d867e56c187cdd28efde758bfb0fe8
+35 -1
Переглянути файл
@@ -265,6 +265,26 @@ mod imp {
p.find_files(glob, limit).map_err(error)
}
pub fn project_exists(p: &ProjectRes, path: &str) -> Result<bool, Error> {
p.exists(path).map_err(error)
}
pub fn project_stat(p: &ProjectRes, path: &str) -> Result<FileStat, Error> {
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<D> = 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<String, Error> {
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<bool, Error> {
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<FileStat, Error> {
imp::project_stat(&self.inner, path)
}
/// The repository containing the project (`None`: not under git). Needs `vcs.read`.
pub fn vcs_status(&self) -> Result<Option<RepositoryStatus>, Error> {
imp::vcs_status(&self.inner)
+84 -4
Переглянути файл
@@ -55,6 +55,7 @@ struct State {
root: Option<String>,
other_projects: Vec<String>,
files: BTreeMap<String, String>,
dirs: BTreeSet<String>,
editor: Option<Editor>,
selected_paths: Vec<String>,
notifications: Vec<Notification>,
@@ -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<String, Error> {
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<bool, Error> {
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<FileStat, Error> {
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<ProjectRes> {
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();
+20
Переглянути файл
@@ -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<u64>,
}
/// A file the commit being prepared changes ([`crate::host::pending_commit`]).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct CommitChange {
+29 -1
Переглянути файл
@@ -3,14 +3,42 @@ package openidea:[email protected];
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<u64>,
}
/// A project open in the IDE.
resource project {
/// Absolute path of the project root. Requires `project.read`.
root: func() -> result<string, error>;
/// 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<string, error>;
/// 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<bool, error>;
/// 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<file-stat, error>;
/// 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`.
+29 -1
Переглянути файл
@@ -3,14 +3,42 @@ package openidea:[email protected];
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<u64>,
}
/// A project open in the IDE.
resource project {
/// Absolute path of the project root. Requires `project.read`.
root: func() -> result<string, error>;
/// 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<string, error>;
/// 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<bool, error>;
/// 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<file-stat, error>;
/// 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`.