use std::path::{Component, Path, PathBuf};
pub fn canonical_workspace_root(path: &Path) -> Result<PathBuf, String> {
path.canonicalize().map_err(|e| format!("workspace path invalid: {e}"))
}
pub fn is_path_within_any(roots: &[PathBuf], path: &Path) -> bool {
roots.iter().any(|root| is_path_within(root, path))
}
pub fn validate_workspace_scope_any(
requested: &Path,
workspace_roots: &[PathBuf],
) -> Result<PathBuf, String> {
for root in workspace_roots {
if let Ok(resolved) = validate_workspace_scope(requested, root) {
return Ok(resolved);
}
}
Err("workspace URI is outside allowed workspace roots".to_string())
}
pub fn resolve_lsp_document_path_any(
uri: &str,
workspace_roots: &[PathBuf],
) -> Result<PathBuf, String> {
for root in workspace_roots {
if let Ok(path) = resolve_lsp_document_path(uri, root) {
return Ok(path);
}
}
Err("document path is outside the indexed workspace".to_string())
}
pub fn workspace_uri_to_path(uri: &str) -> Result<PathBuf, String> {
file_uri_to_path(uri).and_then(|p| canonical_workspace_root(&p))
}
pub fn file_uri_to_path(uri: &str) -> Result<PathBuf, String> {
let url = url::Url::parse(uri).map_err(|e| e.to_string())?;
if url.scheme() != "file" {
return Err(format!("only file:// URIs are supported, got {}", url.scheme()));
}
url.to_file_path().map_err(|_| format!("invalid file URI: {uri}"))
}
pub fn resolve_document_path(uri: &str, workspace_root: &Path) -> Result<PathBuf, String> {
let path = file_uri_to_path(uri)?;
let canonical =
path.canonicalize().map_err(|e| format!("cannot resolve document path: {e}"))?;
if !is_path_within(workspace_root, &canonical) {
return Err("document path is outside the indexed workspace".to_string());
}
Ok(canonical)
}
pub fn resolve_lsp_document_path(uri: &str, workspace_root: &Path) -> Result<PathBuf, String> {
let path = file_uri_to_path(uri)?;
resolve_path_in_workspace(
&path,
workspace_root,
"document path is outside the indexed workspace",
)
}
pub fn validate_workspace_scope(
requested: &Path,
workspace_root: &Path,
) -> Result<PathBuf, String> {
resolve_path_in_workspace(
requested,
workspace_root,
"workspace URI is outside the allowed workspace root",
)
}
fn resolve_path_in_workspace(
path: &Path,
workspace_root: &Path,
outside_msg: &str,
) -> Result<PathBuf, String> {
if path_has_parent_escape(path) {
return Err("path escapes workspace via ..".to_string());
}
let root = canonical_workspace_root(workspace_root)?;
if let Ok(canonical) = path.canonicalize() {
if path_is_under(&root, &canonical) {
return Ok(canonical);
}
return Err(outside_msg.to_string());
}
let absolute = if path.is_absolute() { path.to_path_buf() } else { root.join(path) };
let absolute = normalize_lexical(&absolute);
let (existing_prefix, missing_suffix) = split_existing_prefix(&absolute);
let canonical_prefix =
existing_prefix.canonicalize().map_err(|e| format!("cannot resolve path prefix: {e}"))?;
if !path_is_under(&root, &canonical_prefix) {
return Err(outside_msg.to_string());
}
let candidate = if missing_suffix.as_os_str().is_empty() {
canonical_prefix
} else {
canonical_prefix.join(missing_suffix)
};
if path_is_under(&root, &candidate) || is_path_within_lexical(&root, &candidate) {
Ok(candidate)
} else {
Err(outside_msg.to_string())
}
}
fn split_existing_prefix(path: &Path) -> (PathBuf, PathBuf) {
let mut prefix = path.to_path_buf();
let mut missing = PathBuf::new();
loop {
if prefix.exists() {
let mut suffix = PathBuf::new();
for component in missing.components().rev() {
suffix.push(component);
}
return (prefix, suffix);
}
match prefix.file_name() {
Some(name) => {
missing.push(name);
if !prefix.pop() {
break;
}
}
None => break,
}
}
(PathBuf::from("."), path.to_path_buf())
}
fn normalize_lexical(path: &Path) -> PathBuf {
let mut components = Vec::new();
for component in path.components() {
match component {
Component::ParentDir => {
components.pop();
}
Component::CurDir => {}
c => components.push(c),
}
}
components.iter().collect()
}
fn is_path_within_lexical(root: &Path, path: &Path) -> bool {
let root = normalize_lexical(root);
let path = normalize_lexical(path);
path_is_under(&root, &path)
}
fn path_is_under(root: &Path, path: &Path) -> bool {
path == root || path.strip_prefix(root).is_ok()
}
pub fn is_path_within(workspace_root: &Path, path: &Path) -> bool {
let Ok(root) = workspace_root.canonicalize() else {
return false;
};
if let Ok(path) = path.canonicalize() {
return path_is_under(&root, &path);
}
let absolute = if path.is_absolute() {
normalize_lexical(path)
} else {
normalize_lexical(&root.join(path))
};
let (existing_prefix, missing_suffix) = split_existing_prefix(&absolute);
let Ok(canonical_prefix) = existing_prefix.canonicalize() else {
return is_path_within_lexical(&root, &absolute);
};
if !path_is_under(&root, &canonical_prefix) {
return false;
}
let candidate = if missing_suffix.as_os_str().is_empty() {
canonical_prefix
} else {
canonical_prefix.join(missing_suffix)
};
path_is_under(&root, &candidate) || is_path_within_lexical(&root, &candidate)
}
pub fn path_has_parent_escape(path: &Path) -> bool {
path.components().any(|c| matches!(c, Component::ParentDir))
}
pub fn ensure_extract_path_within(extract_root: &Path, rel: &str) -> Result<PathBuf, String> {
if rel.is_empty() {
return Err("empty relative path".to_string());
}
let rel_path = Path::new(rel);
if path_has_parent_escape(rel_path) {
return Err("extract path escapes via ..".to_string());
}
for component in rel_path.components() {
match component {
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err("invalid path component in extract".to_string());
}
_ => {}
}
}
let root = canonical_workspace_root(extract_root)?;
let candidate = normalize_lexical(&root.join(rel_path));
if !is_path_within_lexical(&root, &candidate) {
return Err("extract path outside extraction root".to_string());
}
Ok(candidate)
}
pub fn discover_git_repo_root(paths: &[PathBuf]) -> Option<PathBuf> {
for path in paths {
let mut current = if path.is_dir() {
path.clone()
} else {
match path.parent() {
Some(p) => p.to_path_buf(),
None => continue,
}
};
loop {
if current.join(".git").exists() {
return current.canonicalize().ok().or_else(|| Some(current.clone()));
}
match current.parent() {
Some(p) => current = p.to_path_buf(),
None => break,
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn validate_scope_allows_nonexistent_file_under_workspace() {
let dir = tempfile::tempdir().unwrap();
let root = canonical_workspace_root(dir.path()).unwrap();
let new_file = dir.path().join("module.ttl");
let resolved =
validate_workspace_scope(&new_file, &root).expect("new file under workspace");
assert!(is_path_within(&root, &resolved));
assert_eq!(resolved.file_name(), new_file.file_name());
}
#[test]
fn lsp_document_path_allows_nonexistent_file_under_workspace() {
let dir = tempfile::tempdir().unwrap();
let root = canonical_workspace_root(dir.path()).unwrap();
let new_file = dir.path().join("new.ttl");
let uri = url::Url::from_file_path(&new_file).unwrap().to_string();
let resolved = resolve_lsp_document_path(&uri, &root).expect("new file under workspace");
assert!(is_path_within(&root, &resolved));
assert_eq!(resolved.file_name(), new_file.file_name());
}
#[test]
fn document_must_be_under_workspace() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("ontologies");
fs::create_dir_all(&sub).unwrap();
let ttl = sub.join("ex.ttl");
fs::write(&ttl, "@prefix ex: <http://ex/> .").unwrap();
let root = canonical_workspace_root(dir.path()).unwrap();
let uri = url::Url::from_file_path(&ttl).unwrap().to_string();
let resolved = resolve_document_path(&uri, &root).expect("under workspace");
assert_eq!(resolved, ttl.canonicalize().unwrap());
}
#[test]
fn rejects_path_outside_workspace() {
let dir = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let ttl = outside.path().join("secret.ttl");
fs::write(&ttl, "@prefix ex: <http://ex/> .").unwrap();
let root = canonical_workspace_root(dir.path()).unwrap();
let uri = url::Url::from_file_path(&ttl).unwrap().to_string();
assert!(resolve_document_path(&uri, &root).is_err());
}
#[test]
#[cfg(unix)]
fn rejects_nonexistent_file_under_symlink_outside_workspace() {
let dir = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let link = dir.path().join("vendor");
std::os::unix::fs::symlink(outside.path(), &link).unwrap();
let root = canonical_workspace_root(dir.path()).unwrap();
let target = link.join("pwn.ttl");
let uri = url::Url::from_file_path(&target).unwrap().to_string();
assert!(
resolve_lsp_document_path(&uri, &root).is_err(),
"must reject create-through-symlink escape"
);
assert!(validate_workspace_scope(&target, &root).is_err());
assert!(!is_path_within(&root, &target));
}
#[test]
#[cfg(unix)]
fn rejects_nested_missing_path_through_symlink_prefix() {
let dir = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let link = dir.path().join("vendor");
std::os::unix::fs::symlink(outside.path(), &link).unwrap();
let root = canonical_workspace_root(dir.path()).unwrap();
let target = link.join("nested").join("pwn.ttl");
assert!(validate_workspace_scope(&target, &root).is_err());
}
#[test]
fn rejects_sibling_directory_prefix_trap() {
let dir = tempfile::tempdir().unwrap();
let sibling = dir.path().join("ws_extra");
fs::create_dir_all(&sibling).unwrap();
let secret = sibling.join("secret.ttl");
fs::write(&secret, "@prefix ex: <http://ex/> .").unwrap();
let root = dir.path().join("ws");
fs::create_dir_all(&root).unwrap();
let root = canonical_workspace_root(&root).unwrap();
assert!(
!is_path_within(&root, &secret.canonicalize().unwrap()),
"sibling directory must not match as workspace child"
);
let uri = url::Url::from_file_path(&secret).unwrap().to_string();
assert!(resolve_document_path(&uri, &root).is_err());
}
#[test]
fn validate_scope_any_accepts_paths_under_either_root() {
let a = tempfile::tempdir().unwrap();
let b = tempfile::tempdir().unwrap();
let root_a = canonical_workspace_root(a.path()).unwrap();
let root_b = canonical_workspace_root(b.path()).unwrap();
let file_in_b = b.path().join("module.ttl");
std::fs::write(&file_in_b, "@prefix ex: <http://ex/> .").unwrap();
let resolved = validate_workspace_scope_any(&file_in_b, &[root_a.clone(), root_b.clone()])
.expect("under second root");
assert!(is_path_within_any(&[root_a, root_b], &resolved));
}
}