cordance-cli 0.1.1

Cordance CLI — installs the `cordance` binary. The umbrella package `cordance` re-exports this entry; either install command works.
Documentation
//! Path canonicalisation guard for every tool that takes a `target`.
//!
//! Adversarial reading §4.2 (path traversal) is the binding requirement:
//! every untrusted `target` argument must be canonicalised and must resolve
//! under one of the server's allow-listed roots before any I/O happens. The
//! default allow-list is `[server_cwd]` — additional roots come from
//! `cordance.toml [mcp].allowed_roots`.

use std::path::{Path, PathBuf};

use camino::Utf8PathBuf;

use super::error::McpToolError;

/// Server-side roots a `target` argument is allowed to canonicalise into.
///
/// Construct via [`AllowedRoots::from_config`] once per `cordance serve`
/// invocation; the `Vec<PathBuf>` is the canonicalised list, so subsequent
/// `validate_target` calls compare against it directly.
#[derive(Clone, Debug)]
pub struct AllowedRoots {
    roots: Vec<PathBuf>,
}

impl AllowedRoots {
    /// Build the allow-list from the server's working directory plus any
    /// additional roots declared in `cordance.toml`.
    ///
    /// Entries that fail to canonicalise are dropped (the validator already
    /// rejects un-canonicalisable input; an un-canonicalisable allowed root
    /// would never match anything anyway). The server CWD is always present;
    /// if it cannot be canonicalised, callers receive an empty allow-list
    /// and every `validate_target` will reject — that is the fail-closed
    /// behaviour the adversarial review requires.
    #[must_use]
    pub fn from_config(server_cwd: &Path, extra: &[String]) -> Self {
        let mut roots = Vec::with_capacity(extra.len() + 1);
        if let Some(p) = canonicalise(server_cwd) {
            roots.push(p);
        }
        for raw in extra {
            let candidate = if Path::new(raw).is_absolute() {
                PathBuf::from(raw)
            } else {
                server_cwd.join(raw)
            };
            if let Some(p) = canonicalise(&candidate) {
                if !roots.iter().any(|existing| existing == &p) {
                    roots.push(p);
                }
            }
        }
        Self { roots }
    }

    /// Read-only view used by tests.
    #[must_use]
    pub fn roots(&self) -> &[PathBuf] {
        &self.roots
    }

    /// Returns true when `candidate` is exactly equal to, or a descendant of,
    /// at least one allowed root.
    fn permits(&self, candidate: &Path) -> bool {
        self.roots
            .iter()
            .any(|root| candidate == root || candidate.starts_with(root))
    }
}

/// Apply the path-canonicalisation guard to a single `target` parameter.
///
/// `raw` is the value supplied by the MCP peer. The returned [`Utf8PathBuf`]
/// is the absolute, canonicalised, lossless-UTF-8 form — safe to feed into
/// `pack`, `advise`, drift, etc.
///
/// # Errors
///
/// - [`McpToolError::InvalidPathSyntax`] when `raw` contains a NUL byte.
/// - [`McpToolError::TargetCanonicalisationFailed`] when the path does not
///   exist or the OS refuses to resolve it.
/// - [`McpToolError::TargetOutsideAllowedRoots`] when the canonical path
///   falls outside every allow-listed root.
pub fn validate_target(raw: &str, allowed: &AllowedRoots) -> Result<Utf8PathBuf, McpToolError> {
    if raw.is_empty() {
        return Err(McpToolError::InvalidPathSyntax(
            "target is empty".to_string(),
        ));
    }
    if raw.as_bytes().contains(&0) {
        return Err(McpToolError::InvalidPathSyntax(
            "target contains NUL byte".to_string(),
        ));
    }

    // Resolve `.` and `..` against the *first* allowed root (the server CWD)
    // before canonicalising. This means a peer can pass `"."` or a project
    // sub-directory by relative path and have it interpreted relative to the
    // launch directory rather than to wherever the cordance process happens
    // to chdir later.
    let base = allowed
        .roots
        .first()
        .ok_or_else(|| McpToolError::Internal("allow-list has no server root".to_string()))?;
    let absolute = if Path::new(raw).is_absolute() {
        PathBuf::from(raw)
    } else {
        base.join(raw)
    };

    let canonical = canonicalise(&absolute).ok_or_else(|| {
        // Round-2 redteam #3: never echo the canonical or raw path back
        // to the MCP peer — the peer should learn *that* the target is
        // unreachable, not *what* the host filesystem looks like. The
        // server's stderr log (via `tracing`) is the audit channel.
        tracing::warn!(
            raw = %raw,
            base = %base.display(),
            "mcp validation: target failed to canonicalise"
        );
        McpToolError::TargetCanonicalisationFailed
    })?;

    if !allowed.permits(&canonical) {
        tracing::warn!(
            raw = %raw,
            canonical = %canonical.display(),
            allowed_roots = ?allowed.roots,
            "mcp validation: target rejected by allow-list"
        );
        return Err(McpToolError::TargetOutsideAllowedRoots);
    }

    Utf8PathBuf::from_path_buf(canonical).map_err(|p| {
        tracing::warn!(
            raw = %raw,
            canonical = %p.display(),
            "mcp validation: canonical path is not valid UTF-8"
        );
        McpToolError::InvalidPathSyntax(
            "canonical path is not valid UTF-8".to_string(),
        )
    })
}

/// Canonicalise without leaking Windows extended-length (`\\?\`) prefixes.
///
/// `dunce::canonicalize` falls back to `std::fs::canonicalize` on non-Windows
/// platforms but strips `\\?\` on Windows, which keeps the prefix-match in
/// [`AllowedRoots::permits`] working when one side is `\\?\C:\…` and the
/// other is `C:\…`.
fn canonicalise(p: &Path) -> Option<PathBuf> {
    dunce::canonicalize(p).ok()
}

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

    fn fresh_cwd() -> std::path::PathBuf {
        let dir = tempfile::tempdir().expect("tempdir");
        // tempdir returns a TempDir that auto-cleans; keep it alive by
        // calling .keep() and trust the OS tmp cleanup for the test process.
        dir.keep()
    }

    #[test]
    fn dot_resolves_to_cwd() {
        let cwd = fresh_cwd();
        let allowed = AllowedRoots::from_config(&cwd, &[]);
        let resolved = validate_target(".", &allowed).expect("`.` should be allowed");
        assert!(resolved.starts_with(
            Utf8PathBuf::from_path_buf(canonicalise(&cwd).expect("canon"))
                .expect("utf8 cwd"),
        ));
    }

    #[test]
    fn empty_target_rejected() {
        let cwd = fresh_cwd();
        let allowed = AllowedRoots::from_config(&cwd, &[]);
        let err = validate_target("", &allowed).expect_err("empty should error");
        match err {
            McpToolError::InvalidPathSyntax(_) => {}
            other => panic!("expected InvalidPathSyntax, got {other:?}"),
        }
    }

    #[test]
    fn nul_byte_rejected() {
        let cwd = fresh_cwd();
        let allowed = AllowedRoots::from_config(&cwd, &[]);
        let err = validate_target("foo\0bar", &allowed).expect_err("NUL should error");
        match err {
            McpToolError::InvalidPathSyntax(msg) => assert!(msg.contains("NUL")),
            other => panic!("expected InvalidPathSyntax, got {other:?}"),
        }
    }

    #[test]
    fn nonexistent_target_rejected() {
        let cwd = fresh_cwd();
        let allowed = AllowedRoots::from_config(&cwd, &[]);
        let err = validate_target("definitely-does-not-exist-xyz", &allowed)
            .expect_err("nonexistent should error");
        match err {
            McpToolError::TargetCanonicalisationFailed => {}
            other => panic!("expected TargetCanonicalisationFailed, got {other:?}"),
        }
    }

    #[test]
    fn parent_escape_rejected() {
        let cwd = fresh_cwd();
        let allowed = AllowedRoots::from_config(&cwd, &[]);
        // Build a path that DOES canonicalise (the parent of a real tempdir
        // is real) but is not under the allow-listed CWD.
        let parent = cwd
            .parent()
            .expect("tempdir always has a parent")
            .to_path_buf();
        let parent_str = parent
            .to_str()
            .expect("parent path should be valid UTF-8");
        let err = validate_target(parent_str, &allowed)
            .expect_err("parent of cwd must be outside allow-list");
        match err {
            McpToolError::TargetOutsideAllowedRoots => {}
            other => panic!("expected TargetOutsideAllowedRoots, got {other:?}"),
        }
    }

    #[test]
    fn dot_dot_escape_rejected() {
        let cwd = fresh_cwd();
        let allowed = AllowedRoots::from_config(&cwd, &[]);
        // `../..` canonicalises to a real ancestor on every supported OS;
        // the allow-list guard is what must reject it.
        let err = validate_target("../..", &allowed)
            .expect_err("`../..` should escape the cwd allow-list");
        match err {
            McpToolError::TargetOutsideAllowedRoots => {}
            other => panic!("expected TargetOutsideAllowedRoots, got {other:?}"),
        }
    }

    #[test]
    fn validation_error_display_omits_raw_input() {
        // Round-2 redteam #3: the McpToolError variants must Display without
        // any path bytes regardless of the raw input that triggered them.
        let cwd = fresh_cwd();
        let allowed = AllowedRoots::from_config(&cwd, &[]);
        let raw = "/etc/passwd-marker-xyz";
        let err = validate_target(raw, &allowed).expect_err("must reject");
        let rendered = err.to_string();
        assert!(
            !rendered.contains("/etc"),
            "error Display leaked input path: {rendered:?}"
        );
        assert!(
            !rendered.contains("passwd-marker-xyz"),
            "error Display leaked input fragment: {rendered:?}"
        );
        assert!(
            !rendered.contains(cwd.to_str().expect("cwd utf8")),
            "error Display leaked cwd: {rendered:?}"
        );
    }

    #[test]
    fn allow_list_can_widen_to_sibling() {
        let parent = fresh_cwd();
        let child = parent.join("child");
        std::fs::create_dir_all(&child).expect("mkdir child");
        let sibling = parent.join("sibling");
        std::fs::create_dir_all(&sibling).expect("mkdir sibling");

        // CWD is `child`; allow-list adds the explicit `sibling` directory.
        let allowed = AllowedRoots::from_config(
            &child,
            &[sibling
                .to_str()
                .expect("sibling path should be valid UTF-8")
                .to_string()],
        );
        let sib_str = sibling
            .to_str()
            .expect("sibling path should be valid UTF-8");
        validate_target(sib_str, &allowed).expect("sibling should be reachable when allow-listed");
    }

    #[test]
    fn allow_list_with_no_server_root_is_fail_closed() {
        // Build by hand to simulate the (defensive) "cwd refused to
        // canonicalise" path. Every target must reject.
        let allowed = AllowedRoots { roots: vec![] };
        let err = validate_target(".", &allowed).expect_err("empty allow-list must reject");
        match err {
            McpToolError::Internal(_) => {}
            other => panic!("expected Internal, got {other:?}"),
        }
    }
}