дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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
Цей коміт міститься в:
1 джерело
4cfdb972eb
коміт
303d5950bf
47 files changed
+354
-32
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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]
|
||||
|
||||
@@ -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 @@
|
||||
a85f313fdb635212cd96c7a9694cb64417bc40cddf5193a69b2873a7009f3f42
|
||||
e9f2e3818dab64aa24e3f1852eff301c2ba971e90157067b11b832cdcc361fd6
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
8c431ac710ce5b767bc8f15b93fbf02db7aef6d64656ca9ff92de92dc662e47a
|
||||
ad6f103e549a51cc64aa9ce03e0759c7e247b98fa4d7d84f2d5cbbd32a56e47a
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
5dabb045a170b49ca39de03040bd3ecb2e90edf67b6cd88a092c74e6d2752409
|
||||
bb5747f6e319f1852dbfd92c6a65e6e54a41a6d508fe96dc570a5c0684f7d322
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
58da8760b67495c4fb3d05f222f47884444c1191c67ecf6b2c0e8449687ff5c9
|
||||
f651ab979bc6b52c5bdde0417667393ffd442726b406b9064a431e0fad39b8be
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
0847aa7c377511e03963bc1a838eb0dda520e1d7c7a08681b32ea68ba541360a
|
||||
7fd84d18cbcf8673c988e7d3d9a143883635f967ba2b3f679fb1f3aafaa9169c
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
bff0a5b416c3bcd8e94ba8cf082843913067ec126f2ab4db45353db3b71e61a0
|
||||
a2762b47de8abd3d1c4fff0130001c6a9347292f94bfffd3933db1a270c95076
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
c58bf55470bd795f9e664ad3a8bd1173c863b1b9405d2e81f1da1c4039884dd3
|
||||
a38d39b7d43d4f2fbcb31064a9037f3a6d58fb16b3e7bfeb0f85f5f13459dbff
|
||||
@@ -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 @@
|
||||
89515d507386505d244ed6ac19a85b7288f6d8ef30f19f4b770d8b0f84f33d63
|
||||
c9cbe5d5a6d2d5bf3bda9308b6e07c374a60b3f95714425e9f0d855bcaa38666
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
a0b3353006032af9f9052c8413a00ff8069294d1c3e86ed520f9c4b8012094c1
|
||||
dada583da1a117277325077bf700a2b485433768836335683ff3f4d370e43334
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
7b055db21ef99eea933df6b311248ac18ec8a57d57cbe9af0bc5f694c8e8d48a
|
||||
720fc0e60bb11cabbf9658bbada16eb889e509ab436d62b17b2a64ef9651a559
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
d742d45a1bee8c5d0c4c2bab9341eb3d3fc7feae1c1229485330dd0b58374092
|
||||
4ced6756e3f94c0989a7c00948c3456954eaa0b08cb072a3b1790872eabc6a45
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
511a8419aab4bde97c09a7a7134ee523dade17b771697382ffb521ad1e5798bf
|
||||
ae7c04be3b7811e560c711077391bec14705a1acb769ba8a7e0212dc8e8ec5ff
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
8b5e78147995375b841f342c7441158d20d33388be808f08c233eca27c3b4d7f
|
||||
df4f523b86e29e7f5f992eeb9b8fd9ab61b58ac8b9e05d20f1db457e9328f187
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
6925c7bdc12a5bd74e864ab4e706c155b0a994d5bbf27cc437f0220aed4cfc68
|
||||
0a4d7548b157513276176e4e02ec5d8a5a71f55f4098afd9963ec0f47312d6de
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
43c47ef0199e7c7550655bd571c75d4109cb6435ae0e99fa68123c8e4d823e48
|
||||
451c7bf3dcbdd6e57dc657cb7d1f4ddee8d262a859863ac71287b85eb474793c
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
3a632f982c08c24cc2d67511f1fac3ff3b29229e552399b46e4d0aa2623200f7
|
||||
daf6fb2a5cff9210cc380f19dca42480d380166e028239d985abf37ebddd7a6c
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
588b3af37102b1871b40184509b299b39ecd15212ccdb58467badb889ca7bc03
|
||||
1364aed3a99e0769d53b444c51b873083aeb7088afa7dc6c8821cd312d0319ad
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
723d68d4de1ec8b6f246b1bd30e56f177df08f7bf3c17907b0ec9ef6cbdf1bf3
|
||||
d6d1a81f02c7346b365689bc3893745aa9d867e56c187cdd28efde758bfb0fe8
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in new issue
Заблокувати користувача