Skip to main content

strixonomy_core/
path_jail.rs

1use std::path::{Component, Path, PathBuf};
2
3/// Canonicalize a workspace root directory.
4pub fn canonical_workspace_root(path: &Path) -> Result<PathBuf, String> {
5    path.canonicalize().map_err(|e| format!("workspace path invalid: {e}"))
6}
7
8/// Returns true if `path` is under any of the workspace roots.
9pub fn is_path_within_any(roots: &[PathBuf], path: &Path) -> bool {
10    roots.iter().any(|root| is_path_within(root, path))
11}
12
13/// Ensure `path` is under at least one workspace root.
14pub fn validate_workspace_scope_any(
15    requested: &Path,
16    workspace_roots: &[PathBuf],
17) -> Result<PathBuf, String> {
18    for root in workspace_roots {
19        if let Ok(resolved) = validate_workspace_scope(requested, root) {
20            return Ok(resolved);
21        }
22    }
23    Err("workspace URI is outside allowed workspace roots".to_string())
24}
25
26/// Resolve an LSP document URI under any registered workspace root.
27pub fn resolve_lsp_document_path_any(
28    uri: &str,
29    workspace_roots: &[PathBuf],
30) -> Result<PathBuf, String> {
31    for root in workspace_roots {
32        if let Ok(path) = resolve_lsp_document_path(uri, root) {
33            return Ok(path);
34        }
35    }
36    Err("document path is outside the indexed workspace".to_string())
37}
38
39/// Resolve a `file://` workspace URI to a canonical directory path.
40pub fn workspace_uri_to_path(uri: &str) -> Result<PathBuf, String> {
41    file_uri_to_path(uri).and_then(|p| canonical_workspace_root(&p))
42}
43
44/// Resolve a `file://` URI to a path (not necessarily canonical).
45pub fn file_uri_to_path(uri: &str) -> Result<PathBuf, String> {
46    let url = url::Url::parse(uri).map_err(|e| e.to_string())?;
47    if url.scheme() != "file" {
48        return Err(format!("only file:// URIs are supported, got {}", url.scheme()));
49    }
50    url.to_file_path().map_err(|_| format!("invalid file URI: {uri}"))
51}
52
53/// Resolve a document URI and ensure it lies under `workspace_root` (both canonical).
54pub fn resolve_document_path(uri: &str, workspace_root: &Path) -> Result<PathBuf, String> {
55    let path = file_uri_to_path(uri)?;
56    let canonical =
57        path.canonicalize().map_err(|e| format!("cannot resolve document path: {e}"))?;
58    if !is_path_within(workspace_root, &canonical) {
59        return Err("document path is outside the indexed workspace".to_string());
60    }
61    Ok(canonical)
62}
63
64/// Resolve an LSP document URI under `workspace_root` without requiring the file to exist.
65///
66/// Canonicalizes when the path exists on disk; otherwise resolves via the longest existing
67/// path prefix so symlink parents cannot escape the workspace.
68pub fn resolve_lsp_document_path(uri: &str, workspace_root: &Path) -> Result<PathBuf, String> {
69    let path = file_uri_to_path(uri)?;
70    resolve_path_in_workspace(
71        &path,
72        workspace_root,
73        "document path is outside the indexed workspace",
74    )
75}
76
77/// Ensure `path` is the workspace root or a subdirectory of it (after canonicalization).
78pub fn validate_workspace_scope(
79    requested: &Path,
80    workspace_root: &Path,
81) -> Result<PathBuf, String> {
82    resolve_path_in_workspace(
83        requested,
84        workspace_root,
85        "workspace URI is outside the allowed workspace root",
86    )
87}
88
89/// Resolve `path` under `workspace_root`, including non-existent paths.
90///
91/// Walks upward to the longest existing prefix, canonicalizes that prefix, and joins only
92/// the missing suffix. If any existing prefix resolves outside the root, rejects.
93///
94/// Also rejects a dangling (or out-of-root) **leaf/intermediate symlink** under the
95/// workspace: those would otherwise look like a plain non-existent path whose existing
96/// prefix is the parent directory.
97fn resolve_path_in_workspace(
98    path: &Path,
99    workspace_root: &Path,
100    outside_msg: &str,
101) -> Result<PathBuf, String> {
102    if path_has_parent_escape(path) {
103        return Err("path escapes workspace via ..".to_string());
104    }
105    let root = canonical_workspace_root(workspace_root)?;
106
107    if let Ok(canonical) = path.canonicalize() {
108        if path_is_under(&root, &canonical) {
109            return Ok(canonical);
110        }
111        return Err(outside_msg.to_string());
112    }
113
114    let absolute = if path.is_absolute() { path.to_path_buf() } else { root.join(path) };
115    let absolute = normalize_lexical(&absolute);
116
117    let (existing_prefix, missing_suffix) = split_existing_prefix(&absolute);
118    let canonical_prefix =
119        existing_prefix.canonicalize().map_err(|e| format!("cannot resolve path prefix: {e}"))?;
120
121    if !path_is_under(&root, &canonical_prefix) {
122        return Err(outside_msg.to_string());
123    }
124
125    reject_symlink_escape_from(&canonical_prefix, &missing_suffix, &root)?;
126
127    let candidate = if missing_suffix.as_os_str().is_empty() {
128        canonical_prefix
129    } else {
130        canonical_prefix.join(&missing_suffix)
131    };
132
133    if path_is_under(&root, &candidate) || is_path_within_lexical(&root, &candidate) {
134        Ok(candidate)
135    } else {
136        Err(outside_msg.to_string())
137    }
138}
139
140/// Walk `prefix` + each component of `suffix`, rejecting dangling symlinks or symlink
141/// targets outside `root`. `prefix` must already be canonical and inside `root`.
142fn reject_symlink_escape_from(prefix: &Path, suffix: &Path, root: &Path) -> Result<(), String> {
143    let mut current = prefix.to_path_buf();
144    for component in suffix.components() {
145        current.push(component);
146        let meta = match std::fs::symlink_metadata(&current) {
147            Ok(meta) => meta,
148            Err(_) => continue,
149        };
150        if !meta.file_type().is_symlink() {
151            continue;
152        }
153        match current.canonicalize() {
154            Ok(target) => {
155                if !path_is_under(root, &target) {
156                    return Err("path escapes workspace via symlink".to_string());
157                }
158            }
159            Err(_) => {
160                return Err("path escapes workspace via dangling symlink".to_string());
161            }
162        }
163    }
164    Ok(())
165}
166
167/// Split `path` into (longest existing prefix, remaining relative suffix).
168fn split_existing_prefix(path: &Path) -> (PathBuf, PathBuf) {
169    let mut prefix = path.to_path_buf();
170    let mut missing = PathBuf::new();
171    loop {
172        if prefix.exists() {
173            let mut suffix = PathBuf::new();
174            for component in missing.components().rev() {
175                suffix.push(component);
176            }
177            return (prefix, suffix);
178        }
179        match prefix.file_name() {
180            Some(name) => {
181                missing.push(name);
182                if !prefix.pop() {
183                    break;
184                }
185            }
186            None => break,
187        }
188    }
189    // Nothing exists; treat as relative to current dir (caller already made absolute).
190    (PathBuf::from("."), path.to_path_buf())
191}
192
193fn normalize_lexical(path: &Path) -> PathBuf {
194    let mut components = Vec::new();
195    for component in path.components() {
196        match component {
197            Component::ParentDir => {
198                components.pop();
199            }
200            Component::CurDir => {}
201            c => components.push(c),
202        }
203    }
204    components.iter().collect()
205}
206
207fn is_path_within_lexical(root: &Path, path: &Path) -> bool {
208    let root = normalize_lexical(root);
209    let path = normalize_lexical(path);
210    path_is_under(&root, &path)
211}
212
213/// Component-aware containment for already-canonical (or known-absolute) paths.
214///
215/// Rejects sibling-directory prefix traps (e.g. `/tmp/ws` must not contain `/tmp/ws_extra`).
216fn path_is_under(root: &Path, path: &Path) -> bool {
217    path == root || path.strip_prefix(root).is_ok()
218}
219
220/// True when two paths refer to the same filesystem location.
221///
222/// Uses exact equality, then both-sides canonicalize. Dual canonicalize failures
223/// do **not** count as a match (`None == None` would wrongly agree).
224pub fn paths_refer_to_same(a: &Path, b: &Path) -> bool {
225    a == b || a.canonicalize().ok().zip(b.canonicalize().ok()).is_some_and(|(x, y)| x == y)
226}
227
228/// Returns true if `path` is `workspace_root` or nested under it.
229pub fn is_path_within(workspace_root: &Path, path: &Path) -> bool {
230    let Ok(root) = workspace_root.canonicalize() else {
231        return false;
232    };
233    if let Ok(path) = path.canonicalize() {
234        return path_is_under(&root, &path);
235    }
236    // For non-existent paths, resolve via longest existing prefix — never trust lexical alone
237    // when a real prefix exists (symlink escape).
238    let absolute = if path.is_absolute() {
239        normalize_lexical(path)
240    } else {
241        normalize_lexical(&root.join(path))
242    };
243    let (existing_prefix, missing_suffix) = split_existing_prefix(&absolute);
244    let Ok(canonical_prefix) = existing_prefix.canonicalize() else {
245        return is_path_within_lexical(&root, &absolute);
246    };
247    if !path_is_under(&root, &canonical_prefix) {
248        return false;
249    }
250    if reject_symlink_escape_from(&canonical_prefix, &missing_suffix, &root).is_err() {
251        return false;
252    }
253    let candidate = if missing_suffix.as_os_str().is_empty() {
254        canonical_prefix
255    } else {
256        canonical_prefix.join(missing_suffix)
257    };
258    path_is_under(&root, &candidate) || is_path_within_lexical(&root, &candidate)
259}
260
261/// Reject paths that escape upward via `..` before canonicalize (symlink-free check).
262pub fn path_has_parent_escape(path: &Path) -> bool {
263    path.components().any(|c| matches!(c, Component::ParentDir))
264}
265
266/// Resolve a relative path for extraction under `extract_root` (e.g. git tree checkout).
267///
268/// Rejects `..`, absolute components, and paths that would escape `extract_root`.
269pub fn ensure_extract_path_within(extract_root: &Path, rel: &str) -> Result<PathBuf, String> {
270    if rel.is_empty() {
271        return Err("empty relative path".to_string());
272    }
273    let rel_path = Path::new(rel);
274    if path_has_parent_escape(rel_path) {
275        return Err("extract path escapes via ..".to_string());
276    }
277    for component in rel_path.components() {
278        match component {
279            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
280                return Err("invalid path component in extract".to_string());
281            }
282            _ => {}
283        }
284    }
285    let root = canonical_workspace_root(extract_root)?;
286    let candidate = normalize_lexical(&root.join(rel_path));
287    if !is_path_within_lexical(&root, &candidate) {
288        return Err("extract path outside extraction root".to_string());
289    }
290    Ok(candidate)
291}
292
293/// Discover a git repository root from any of the given workspace paths.
294pub fn discover_git_repo_root(paths: &[PathBuf]) -> Option<PathBuf> {
295    for path in paths {
296        let mut current = if path.is_dir() {
297            path.clone()
298        } else {
299            match path.parent() {
300                Some(p) => p.to_path_buf(),
301                None => continue,
302            }
303        };
304        loop {
305            if current.join(".git").exists() {
306                return current.canonicalize().ok().or_else(|| Some(current.clone()));
307            }
308            match current.parent() {
309                Some(p) => current = p.to_path_buf(),
310                None => break,
311            }
312        }
313    }
314    None
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use std::fs;
321
322    #[test]
323    fn validate_scope_allows_nonexistent_file_under_workspace() {
324        let dir = tempfile::tempdir().unwrap();
325        let root = canonical_workspace_root(dir.path()).unwrap();
326        let new_file = dir.path().join("module.ttl");
327        let resolved =
328            validate_workspace_scope(&new_file, &root).expect("new file under workspace");
329        assert!(is_path_within(&root, &resolved));
330        assert_eq!(resolved.file_name(), new_file.file_name());
331    }
332
333    #[test]
334    fn paths_refer_to_same_rejects_dual_canonicalize_failure() {
335        let missing_a = PathBuf::from("/definitely/missing/strixonomy-a-xyz.ttl");
336        let missing_b = PathBuf::from("/definitely/missing/strixonomy-b-xyz.ttl");
337        assert!(!paths_refer_to_same(&missing_a, &missing_b));
338        assert!(paths_refer_to_same(&missing_a, &missing_a));
339    }
340
341    #[test]
342    fn lsp_document_path_allows_nonexistent_file_under_workspace() {
343        let dir = tempfile::tempdir().unwrap();
344        let root = canonical_workspace_root(dir.path()).unwrap();
345        let new_file = dir.path().join("new.ttl");
346        let uri = url::Url::from_file_path(&new_file).unwrap().to_string();
347        let resolved = resolve_lsp_document_path(&uri, &root).expect("new file under workspace");
348        assert!(is_path_within(&root, &resolved));
349        assert_eq!(resolved.file_name(), new_file.file_name());
350    }
351
352    #[test]
353    fn document_must_be_under_workspace() {
354        let dir = tempfile::tempdir().unwrap();
355        let sub = dir.path().join("ontologies");
356        fs::create_dir_all(&sub).unwrap();
357        let ttl = sub.join("ex.ttl");
358        fs::write(&ttl, "@prefix ex: <http://ex/> .").unwrap();
359
360        let root = canonical_workspace_root(dir.path()).unwrap();
361        let uri = url::Url::from_file_path(&ttl).unwrap().to_string();
362        let resolved = resolve_document_path(&uri, &root).expect("under workspace");
363        assert_eq!(resolved, ttl.canonicalize().unwrap());
364    }
365
366    #[test]
367    fn rejects_path_outside_workspace() {
368        let dir = tempfile::tempdir().unwrap();
369        let outside = tempfile::tempdir().unwrap();
370        let ttl = outside.path().join("secret.ttl");
371        fs::write(&ttl, "@prefix ex: <http://ex/> .").unwrap();
372
373        let root = canonical_workspace_root(dir.path()).unwrap();
374        let uri = url::Url::from_file_path(&ttl).unwrap().to_string();
375        let err = resolve_document_path(&uri, &root).expect_err("must reject outside path");
376        assert!(err.contains("outside the indexed workspace"), "unexpected error: {err}");
377    }
378
379    #[test]
380    #[cfg(unix)]
381    fn rejects_nonexistent_file_under_symlink_outside_workspace() {
382        let dir = tempfile::tempdir().unwrap();
383        let outside = tempfile::tempdir().unwrap();
384        let link = dir.path().join("vendor");
385        std::os::unix::fs::symlink(outside.path(), &link).unwrap();
386
387        let root = canonical_workspace_root(dir.path()).unwrap();
388        let target = link.join("pwn.ttl");
389        let uri = url::Url::from_file_path(&target).unwrap().to_string();
390        let err = resolve_lsp_document_path(&uri, &root)
391            .expect_err("must reject create-through-symlink escape");
392        assert!(
393            err.contains("outside") || err.contains("escapes") || err.contains("symlink"),
394            "unexpected error: {err}"
395        );
396        let scope_err = validate_workspace_scope(&target, &root).expect_err("scope");
397        assert!(
398            scope_err.contains("outside") || scope_err.contains("escapes"),
399            "unexpected scope error: {scope_err}"
400        );
401        assert!(!is_path_within(&root, &target));
402    }
403
404    #[test]
405    #[cfg(unix)]
406    fn rejects_nested_missing_path_through_symlink_prefix() {
407        let dir = tempfile::tempdir().unwrap();
408        let outside = tempfile::tempdir().unwrap();
409        let link = dir.path().join("vendor");
410        std::os::unix::fs::symlink(outside.path(), &link).unwrap();
411
412        let root = canonical_workspace_root(dir.path()).unwrap();
413        let target = link.join("nested").join("pwn.ttl");
414        let err = validate_workspace_scope(&target, &root).expect_err("must reject");
415        assert!(err.contains("outside") || err.contains("escapes"), "unexpected error: {err}");
416    }
417
418    #[test]
419    #[cfg(unix)]
420    fn rejects_dangling_leaf_symlink_create_escape() {
421        let dir = tempfile::tempdir().unwrap();
422        let outside = tempfile::tempdir().unwrap();
423        let root = canonical_workspace_root(dir.path()).unwrap();
424        let leaf = dir.path().join("new.ttl");
425        let outside_target = outside.path().join("pwn.ttl");
426        std::os::unix::fs::symlink(&outside_target, &leaf).unwrap();
427
428        let err = validate_workspace_scope(&leaf, &root)
429            .expect_err("must reject dangling leaf symlink escape");
430        assert!(
431            err.contains("dangling symlink") || err.contains("symlink") || err.contains("outside"),
432            "unexpected error: {err}"
433        );
434        assert!(!is_path_within(&root, &leaf));
435
436        let uri = url::Url::from_file_path(&leaf).unwrap().to_string();
437        let lsp_err = resolve_lsp_document_path(&uri, &root).expect_err("lsp path");
438        assert!(
439            lsp_err.contains("dangling symlink")
440                || lsp_err.contains("symlink")
441                || lsp_err.contains("outside"),
442            "unexpected lsp error: {lsp_err}"
443        );
444        assert!(!outside_target.exists(), "must not create outside file during checks");
445    }
446
447    #[test]
448    #[cfg(unix)]
449    fn allows_symlink_leaf_that_resolves_inside_workspace() {
450        let dir = tempfile::tempdir().unwrap();
451        let root = canonical_workspace_root(dir.path()).unwrap();
452        let real = dir.path().join("real.ttl");
453        fs::write(&real, "@prefix ex: <http://ex/> .").unwrap();
454        let link = dir.path().join("alias.ttl");
455        std::os::unix::fs::symlink(&real, &link).unwrap();
456
457        let resolved = validate_workspace_scope(&link, &root).expect("in-workspace symlink");
458        assert_eq!(resolved, real.canonicalize().unwrap());
459    }
460
461    #[test]
462    fn rejects_parent_escape_components() {
463        let dir = tempfile::tempdir().unwrap();
464        let root = canonical_workspace_root(dir.path()).unwrap();
465        let escaped = dir.path().join("sub").join("..").join("..").join("etc").join("passwd");
466        let err = validate_workspace_scope(&escaped, &root).expect_err("must reject .. escape");
467        assert!(
468            err.contains("escapes workspace via ..") || err.contains("outside"),
469            "unexpected error: {err}"
470        );
471    }
472
473    #[test]
474    fn rejects_sibling_directory_prefix_trap() {
475        let dir = tempfile::tempdir().unwrap();
476        let sibling = dir.path().join("ws_extra");
477        fs::create_dir_all(&sibling).unwrap();
478        let secret = sibling.join("secret.ttl");
479        fs::write(&secret, "@prefix ex: <http://ex/> .").unwrap();
480
481        let root = dir.path().join("ws");
482        fs::create_dir_all(&root).unwrap();
483        let root = canonical_workspace_root(&root).unwrap();
484
485        assert!(
486            !is_path_within(&root, &secret.canonicalize().unwrap()),
487            "sibling directory must not match as workspace child"
488        );
489        let uri = url::Url::from_file_path(&secret).unwrap().to_string();
490        assert!(resolve_document_path(&uri, &root).is_err());
491    }
492
493    #[test]
494    fn validate_scope_any_accepts_paths_under_either_root() {
495        let a = tempfile::tempdir().unwrap();
496        let b = tempfile::tempdir().unwrap();
497        let root_a = canonical_workspace_root(a.path()).unwrap();
498        let root_b = canonical_workspace_root(b.path()).unwrap();
499        let file_in_b = b.path().join("module.ttl");
500        std::fs::write(&file_in_b, "@prefix ex: <http://ex/> .").unwrap();
501
502        let resolved = validate_workspace_scope_any(&file_in_b, &[root_a.clone(), root_b.clone()])
503            .expect("under second root");
504        assert!(is_path_within_any(&[root_a.clone(), root_b.clone()], &resolved));
505        assert!(
506            resolved.ends_with("module.ttl"),
507            "must return the scoped file path, not an empty default: {resolved:?}"
508        );
509        assert!(is_path_within(&root_b, &resolved));
510    }
511
512    #[test]
513    fn is_path_within_any_rejects_outside_all_roots() {
514        let a = tempfile::tempdir().unwrap();
515        let b = tempfile::tempdir().unwrap();
516        let outside = tempfile::tempdir().unwrap();
517        let root_a = canonical_workspace_root(a.path()).unwrap();
518        let root_b = canonical_workspace_root(b.path()).unwrap();
519        let secret = outside.path().join("secret.ttl");
520        std::fs::write(&secret, "@prefix ex: <http://ex/> .").unwrap();
521        let secret = secret.canonicalize().unwrap();
522
523        assert!(
524            !is_path_within_any(&[root_a.clone(), root_b.clone()], &secret),
525            "path outside every root must be rejected"
526        );
527        let err = validate_workspace_scope_any(&secret, &[root_a, root_b]).expect_err("outside");
528        assert!(err.contains("outside") || err.contains("escapes"), "unexpected error: {err}");
529    }
530
531    #[test]
532    fn resolve_lsp_document_path_any_rejects_outside() {
533        let a = tempfile::tempdir().unwrap();
534        let outside = tempfile::tempdir().unwrap();
535        let root_a = canonical_workspace_root(a.path()).unwrap();
536        let secret = outside.path().join("secret.ttl");
537        std::fs::write(&secret, "@prefix ex: <http://ex/> .").unwrap();
538        let uri = url::Url::from_file_path(&secret).unwrap().to_string();
539        let err = resolve_lsp_document_path_any(&uri, &[root_a]).expect_err("outside");
540        assert!(err.contains("outside"), "unexpected error: {err}");
541    }
542
543    #[test]
544    fn workspace_uri_to_path_rejects_non_file_scheme() {
545        let err = workspace_uri_to_path("https://example.org/ws").expect_err("scheme");
546        assert!(!err.is_empty());
547    }
548
549    #[test]
550    fn path_has_parent_escape_detects_dotdot() {
551        assert!(path_has_parent_escape(Path::new("a/../b")));
552        assert!(!path_has_parent_escape(Path::new("a/b")));
553    }
554
555    #[test]
556    fn ensure_extract_path_within_accepts_nested_relative() {
557        let dir = tempfile::tempdir().unwrap();
558        let nested = ensure_extract_path_within(dir.path(), "ontologies/ex.ttl").expect("nested");
559        assert_eq!(nested.file_name().and_then(|s| s.to_str()), Some("ex.ttl"));
560        assert!(is_path_within_lexical(&canonical_workspace_root(dir.path()).unwrap(), &nested));
561    }
562
563    #[test]
564    fn ensure_extract_path_within_rejects_parent_escape() {
565        let dir = tempfile::tempdir().unwrap();
566        let err = ensure_extract_path_within(dir.path(), "../outside.ttl").expect_err("..");
567        assert!(
568            err.contains("escapes via ..") || err.contains("invalid path"),
569            "unexpected error: {err}"
570        );
571    }
572
573    #[test]
574    fn ensure_extract_path_within_rejects_absolute() {
575        let dir = tempfile::tempdir().unwrap();
576        let err = ensure_extract_path_within(dir.path(), "/etc/passwd").expect_err("absolute");
577        assert!(
578            err.contains("invalid path component") || err.contains("outside"),
579            "unexpected error: {err}"
580        );
581    }
582
583    #[test]
584    fn ensure_extract_path_within_rejects_empty() {
585        let dir = tempfile::tempdir().unwrap();
586        let err = ensure_extract_path_within(dir.path(), "").expect_err("empty");
587        assert!(err.contains("empty"), "unexpected error: {err}");
588    }
589
590    #[test]
591    fn discover_git_repo_root_finds_nested_workspace() {
592        let dir = tempfile::tempdir().unwrap();
593        fs::create_dir_all(dir.path().join(".git")).unwrap();
594        let nested = dir.path().join("ontologies");
595        fs::create_dir_all(&nested).unwrap();
596        let found = discover_git_repo_root(&[nested]).expect("git root");
597        assert_eq!(found, dir.path().canonicalize().unwrap());
598    }
599
600    #[test]
601    fn discover_git_repo_root_none_without_git() {
602        let dir = tempfile::tempdir().unwrap();
603        assert!(discover_git_repo_root(&[dir.path().to_path_buf()]).is_none());
604    }
605}