1use std::path::{Component, Path, PathBuf};
2
3pub fn canonical_workspace_root(path: &Path) -> Result<PathBuf, String> {
5 path.canonicalize().map_err(|e| format!("workspace path invalid: {e}"))
6}
7
8pub fn is_path_within_any(roots: &[PathBuf], path: &Path) -> bool {
10 roots.iter().any(|root| is_path_within(root, path))
11}
12
13pub 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
26pub 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
39pub 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
44pub 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
53pub 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
64pub 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
77pub 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
89fn 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
140fn 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(¤t) {
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
167fn 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 (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
213fn path_is_under(root: &Path, path: &Path) -> bool {
217 path == root || path.strip_prefix(root).is_ok()
218}
219
220pub 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
228pub 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 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
261pub fn path_has_parent_escape(path: &Path) -> bool {
263 path.components().any(|c| matches!(c, Component::ParentDir))
264}
265
266pub 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
293pub 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}