oy-cli 0.11.7

OpenCode launcher and deterministic MCP helpers for repository audit and review workflows
Documentation
use anyhow::{Context, Result, bail};
use std::path::{Component, Path, PathBuf};

use super::super::ToolContext;

fn candidate_path(root: &Path, path: &str) -> Result<PathBuf> {
    let raw = Path::new(path);
    if raw.components().any(|c| matches!(c, Component::ParentDir)) {
        bail!("path outside workspace is not allowed: {path}");
    }
    if !raw.is_absolute() && raw.components().any(|c| matches!(c, Component::Prefix(_))) {
        bail!("path outside workspace is not allowed: {path}");
    }

    Ok(if raw.is_absolute() {
        raw.to_path_buf()
    } else {
        root.join(raw)
    })
}

pub(crate) fn resolve_existing_path(ctx: &ToolContext, path: &str) -> Result<PathBuf> {
    let root = ctx
        .root()
        .canonicalize()
        .context("failed to resolve workspace root")?;
    let resolved = candidate_path(&root, path)?
        .canonicalize()
        .with_context(|| format!("path does not exist: {path}"))?;
    if !resolved.starts_with(&root) {
        bail!(
            "path outside workspace is not allowed: {path} -> {}",
            resolved.display()
        );
    }
    Ok(resolved)
}

pub(super) fn resolve_read_path(ctx: &ToolContext, path: &str) -> Result<PathBuf> {
    resolve_existing_path(ctx, path)
}

pub(super) fn resolve_existing_paths(ctx: &ToolContext, path: &str) -> Result<Vec<PathBuf>> {
    match resolve_existing_path(ctx, path) {
        Ok(path) => Ok(vec![path]),
        Err(full_path_error) => {
            let parts = path.split_whitespace().collect::<Vec<_>>();
            if parts.len() <= 1 {
                return Err(full_path_error);
            }
            let mut out = Vec::new();
            for part in parts {
                out.push(resolve_existing_path(ctx, part)?);
            }
            out.sort();
            out.dedup();
            Ok(out)
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;

    #[test]
    fn absolute_path_inside_workspace_is_allowed() {
        let dir = tempfile::tempdir().unwrap();
        let file = dir.path().join("src.rs");
        fs::write(&file, "fn main() {}\n").unwrap();
        let ctx = ToolContext::new(dir.path().canonicalize().unwrap());

        let resolved = resolve_existing_path(&ctx, file.to_str().unwrap()).unwrap();

        assert_eq!(resolved, file.canonicalize().unwrap());
    }

    #[test]
    fn absolute_path_outside_workspace_is_rejected() {
        let dir = tempfile::tempdir().unwrap();
        let outside = tempfile::tempdir().unwrap();
        let file = outside.path().join("src.rs");
        fs::write(&file, "fn main() {}\n").unwrap();
        let ctx = ToolContext::new(dir.path().canonicalize().unwrap());

        let err = resolve_existing_path(&ctx, file.to_str().unwrap()).unwrap_err();

        assert!(err.to_string().contains("path outside workspace"));
    }

    #[test]
    fn parent_traversal_is_rejected_before_resolution() {
        let dir = tempfile::tempdir().unwrap();
        let ctx = ToolContext::new(dir.path().canonicalize().unwrap());

        let err = resolve_existing_path(&ctx, "../src.rs").unwrap_err();

        assert!(err.to_string().contains("path outside workspace"));
    }
}