ontocore-core 0.12.0

Core types and workspace scanner for OntoCore (ontocore-*)
Documentation
use std::path::{Component, Path, PathBuf};

/// Canonicalize a workspace root directory.
pub fn canonical_workspace_root(path: &Path) -> Result<PathBuf, String> {
    path.canonicalize().map_err(|e| format!("workspace path invalid: {e}"))
}

/// Returns true if `path` is under any of the workspace roots.
pub fn is_path_within_any(roots: &[PathBuf], path: &Path) -> bool {
    roots.iter().any(|root| is_path_within(root, path))
}

/// Ensure `path` is under at least one workspace root.
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())
}

/// Resolve an LSP document URI under any registered workspace root.
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())
}

/// Resolve a `file://` workspace URI to a canonical directory path.
pub fn workspace_uri_to_path(uri: &str) -> Result<PathBuf, String> {
    file_uri_to_path(uri).and_then(|p| canonical_workspace_root(&p))
}

/// Resolve a `file://` URI to a path (not necessarily canonical).
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}"))
}

/// Resolve a document URI and ensure it lies under `workspace_root` (both canonical).
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)
}

/// Resolve an LSP document URI under `workspace_root` without requiring the file to exist.
///
/// Canonicalizes when the path exists on disk; otherwise resolves via the longest existing
/// path prefix so symlink parents cannot escape the workspace.
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",
    )
}

/// Ensure `path` is the workspace root or a subdirectory of it (after canonicalization).
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",
    )
}

/// Resolve `path` under `workspace_root`, including non-existent paths.
///
/// Walks upward to the longest existing prefix, canonicalizes that prefix, and joins only
/// the missing suffix. If any existing prefix resolves outside the root, rejects.
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())
    }
}

/// Split `path` into (longest existing prefix, remaining relative suffix).
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,
        }
    }
    // Nothing exists; treat as relative to current dir (caller already made absolute).
    (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 == root || path.starts_with(&root)
}

/// Component-aware containment for already-canonical (or known-absolute) paths.
fn path_is_under(root: &Path, path: &Path) -> bool {
    path == root || path.starts_with(root)
}

/// Returns true if `path` is `workspace_root` or nested under it.
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);
    }
    // For non-existent paths, resolve via longest existing prefix — never trust lexical alone
    // when a real prefix exists (symlink escape).
    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)
}

/// Reject paths that escape upward via `..` before canonicalize (symlink-free check).
pub fn path_has_parent_escape(path: &Path) -> bool {
    path.components().any(|c| matches!(c, Component::ParentDir))
}

/// Resolve a relative path for extraction under `extract_root` (e.g. git tree checkout).
///
/// Rejects `..`, absolute components, and paths that would escape `extract_root`.
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)
}

/// Discover a git repository root from any of the given workspace paths.
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 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));
    }
}