car-ffi-common 0.25.0

Shared logic for FFI bindings (NAPI, PyO3) — JSON wrappers for verify, multi-agent, scheduler
//! Sandboxed path resolution for `memory.persist` / `memory.load`.
//!
//! Pre-2026-05 the WS handler and the FFI bindings passed the
//! caller-supplied `path` straight to `std::fs::{read_to_string,
//! write}`. A 2026-05 audit found this was an arbitrary file
//! read/write primitive — `memory.persist("/etc/passwd")` /
//! `memory.load("../../shadow")` both worked.
//!
//! This module funnels every caller through the same gate:
//!
//! - Resolve a per-user **memory base** directory
//!   (`$HOME/.car/memory/` on POSIX, `%USERPROFILE%\.car\memory\`
//!   on Windows). Created on demand with `0700` perms on POSIX.
//! - Caller-supplied `path` is joined under the base when relative,
//!   or required to *resolve to a location under the base* when
//!   absolute. Symlinks are followed via canonicalize on the parent;
//!   anything that escapes is rejected.
//! - Embedded `..` segments are rejected even when they would in
//!   theory resolve back into the base — they almost always
//!   indicate intent to escape and the wins from allowing them are
//!   nil.
//! - Empty paths and bare directory names are rejected.
//!
//! The same helper covers reads and writes. For writes, the parent
//! is created on demand inside the base.

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

const BASE_SUBDIR: &str = ".car/memory";

/// Resolve `path` to a sandboxed, absolute filesystem location.
///
/// Returns the validated path on success. The parent directory is
/// created if missing (under the per-user memory base). The file
/// itself is NOT created — the caller writes to or reads from the
/// returned path with the usual `std::fs` ops.
///
/// Errors carry an `InvalidInput` kind with a human-readable
/// message — surface them through the FFI / WS error path
/// unchanged so callers see exactly why their path was rejected.
pub fn resolve(path: &str) -> io::Result<PathBuf> {
    if path.trim().is_empty() {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "memory path is empty",
        ));
    }

    // Reject embedded parent-dir segments before doing any IO. Even
    // if the result would canonicalize back into the base, we'd
    // rather make the rule visible at the API surface than rely on
    // a downstream check.
    let supplied = Path::new(path);
    if supplied
        .components()
        .any(|c| matches!(c, Component::ParentDir))
    {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "memory path must not contain `..` segments",
        ));
    }

    let base = ensure_base()?;

    // Build the candidate full path.
    let candidate = if supplied.is_absolute() {
        supplied.to_path_buf()
    } else {
        base.join(supplied)
    };

    // Final filename is required — refuse paths that resolve to a
    // directory.
    if candidate.file_name().is_none() {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "memory path must include a filename, not just a directory",
        ));
    }

    // Build the parent we'll create-on-demand. For `~/.car/memory/x`
    // that's `~/.car/memory`; for `~/.car/memory/sub/x` it's
    // `~/.car/memory/sub`. We then canonicalize the parent and check
    // it lives under the base — this catches symlinks pointing out.
    let parent = candidate.parent().ok_or_else(|| {
        io::Error::new(
            io::ErrorKind::InvalidInput,
            "memory path has no parent directory",
        )
    })?;

    std::fs::create_dir_all(parent)?;

    let real_parent = parent.canonicalize()?;
    let real_base = base.canonicalize()?;
    if !real_parent.starts_with(&real_base) {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            format!(
                "memory path resolved outside the sandbox \
                 (resolved parent {} is not under {})",
                real_parent.display(),
                real_base.display(),
            ),
        ));
    }

    let file_name = candidate.file_name().expect("checked above");
    Ok(real_parent.join(file_name))
}

/// Resolve and create the per-user memory base directory. Returns
/// the base path on success.
pub fn ensure_base() -> io::Result<PathBuf> {
    let home = home_dir()
        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no HOME / USERPROFILE"))?;
    let base = home.join(BASE_SUBDIR);
    std::fs::create_dir_all(&base)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        // Ignore failure — caller's perms may already be permissive
        // enough; we only tighten on a fresh create.
        let _ = std::fs::set_permissions(&base, std::fs::Permissions::from_mode(0o700));
    }
    Ok(base)
}

fn home_dir() -> Option<PathBuf> {
    if let Some(h) = std::env::var_os("HOME") {
        return Some(PathBuf::from(h));
    }
    if let Some(h) = std::env::var_os("USERPROFILE") {
        return Some(PathBuf::from(h));
    }
    None
}

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

    /// Tests run in parallel and all share `$HOME` — so we point HOME
    /// at a per-test tempdir to keep `~/.car/memory` from collapsing
    /// across tests. The `serial_test` crate isn't on the workspace
    /// path; instead each test owns its own temp HOME and reads it
    /// before the helper does any work. Returns the tempdir guard +
    /// its memory base.
    fn isolated_home() -> (tempfile::TempDir, PathBuf) {
        let tmp = tempfile::TempDir::new().unwrap();
        std::env::set_var("HOME", tmp.path());
        let base = tmp.path().join(BASE_SUBDIR);
        std::fs::create_dir_all(&base).unwrap();
        (tmp, base)
    }

    // The resolve() function reads $HOME at call time, so each test
    // sets it inside the test body. Tests serialize on the crate-wide
    // [`crate::env_test_lock`] — a single process-global lock shared
    // with `auth_token::tests` / `proxy::tests`, so a `memory_path`
    // test writing `HOME` can't race an `auth_token` test reading it.

    #[test]
    fn relative_path_lands_under_base() {
        let _g = crate::env_test_lock();
        let (_tmp, base) = isolated_home();
        let resolved = resolve("snapshot.json").unwrap();
        assert_eq!(resolved.parent().unwrap(), base.canonicalize().unwrap());
        assert_eq!(resolved.file_name().unwrap(), "snapshot.json");
    }

    #[test]
    fn nested_relative_path_creates_subdir() {
        let _g = crate::env_test_lock();
        let (_tmp, base) = isolated_home();
        let resolved = resolve("project-a/snap.json").unwrap();
        assert!(resolved.starts_with(&base.canonicalize().unwrap()));
        assert!(resolved.parent().unwrap().exists());
    }

    #[test]
    fn parent_segment_is_rejected_even_when_safe() {
        let _g = crate::env_test_lock();
        let (_tmp, _base) = isolated_home();
        let err = resolve("../../etc/passwd").unwrap_err();
        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
        assert!(err.to_string().contains(".."));
    }

    #[test]
    fn absolute_path_outside_sandbox_is_rejected() {
        let _g = crate::env_test_lock();
        let (_tmp, _base) = isolated_home();
        let err = resolve("/etc/hosts").unwrap_err();
        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
        assert!(err.to_string().contains("sandbox"));
    }

    #[test]
    fn empty_path_is_rejected() {
        let _g = crate::env_test_lock();
        let (_tmp, _base) = isolated_home();
        assert!(resolve("").is_err());
        assert!(resolve("   ").is_err());
    }

    #[test]
    fn directory_only_path_is_rejected() {
        let _g = crate::env_test_lock();
        let (_tmp, _base) = isolated_home();
        let err = resolve("/").unwrap_err();
        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
    }

    #[test]
    #[cfg(unix)]
    fn symlink_pointing_outside_sandbox_is_rejected() {
        let _g = crate::env_test_lock();
        let (_tmp, base) = isolated_home();
        // Plant a symlink under the base that points to /etc.
        let link = base.join("escape");
        std::os::unix::fs::symlink("/etc", &link).unwrap();

        // Even resolving "escape/passwd" — which would pass a naive
        // string-prefix check — must be rejected once the symlink
        // is followed.
        let err = resolve("escape/passwd").unwrap_err();
        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
    }
}