openlatch-client 0.1.18

OpenLatch runtime enforcement node — the capture-and-enforce client for the AI Operations Platform
//! Path semantics that differ between POSIX and Windows.
//!
//! Two of them bite this crate, and neither shows up on Linux:
//!
//! 1. **Verbatim prefixes.** `fs::canonicalize` returns `\\?\C:\Users\…` on
//!    Windows. The prefix is correct for the filesystem APIs and wrong for
//!    everywhere else it leaks — a CloudEvent attribute, an error message, a
//!    comparison against a path that was never canonicalised.
//! 2. **Case.** Windows filesystems are case-insensitive but case-preserving,
//!    while `PathBuf`'s `Hash`/`Eq` are neither. `C:\Users\me\.claude` and
//!    `c:\users\me\.claude` are one file to the OS and two keys to a
//!    `HashSet<PathBuf>` — which is how the config monitor ends up emitting a
//!    duplicate event for a file the native hook and the FS watcher both
//!    reported, each in its own casing.
//!
//! [`dedup_key`] answers "are these the same file?" for in-process bookkeeping
//! only. It deliberately does not touch the wire: `configpathhash` is a
//! SHA-256 the platform correlates on, and re-basing it on a folded path would
//! rewrite the identity of every already-reported file.

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

/// Windows verbatim prefix — `\\?\` — and its UNC form, `\\?\UNC\`.
const VERBATIM: &str = r"\\?\";
const VERBATIM_UNC: &str = r"\\?\UNC\";

/// A path as it should appear to a human or on the wire, with the verbatim
/// prefix removed.
///
/// `\\?\UNC\server\share` becomes `\\server\share`, the form the user typed;
/// `\\?\C:\x` becomes `C:\x`. POSIX paths cannot carry the prefix, so this is
/// the identity there and the logic stays testable on every platform.
pub fn display_path(path: &Path) -> String {
    strip_verbatim(&path.to_string_lossy())
}

fn strip_verbatim(raw: &str) -> String {
    if let Some(rest) = raw.strip_prefix(VERBATIM_UNC) {
        return format!(r"\\{rest}");
    }
    match raw.strip_prefix(VERBATIM) {
        Some(rest) => rest.to_string(),
        None => raw.to_string(),
    }
}

/// Key for in-process "same file?" bookkeeping — cache lookups, dedup sets,
/// already-registered checks.
///
/// Never use it as a display path (it is case-folded on Windows) and never
/// hash it onto the wire (see the module docs).
pub fn dedup_key(path: &Path) -> PathBuf {
    key_with(path, cfg!(windows))
}

/// The body of [`dedup_key`] with the platform decision passed in, so the
/// Windows behaviour is exercised by the Linux test run rather than only by
/// `.github/workflows/windows-checks.yml`.
fn key_with(path: &Path, case_insensitive: bool) -> PathBuf {
    let stripped = strip_verbatim(&path.to_string_lossy());
    if case_insensitive {
        PathBuf::from(stripped.to_lowercase())
    } else {
        PathBuf::from(stripped)
    }
}

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

    #[test]
    fn display_strips_the_verbatim_prefix() {
        assert_eq!(
            display_path(Path::new(r"\\?\C:\Users\me\.claude\settings.json")),
            r"C:\Users\me\.claude\settings.json"
        );
    }

    #[test]
    fn display_rewrites_verbatim_unc_to_its_plain_form() {
        assert_eq!(
            display_path(Path::new(r"\\?\UNC\server\share\settings.json")),
            r"\\server\share\settings.json"
        );
    }

    #[test]
    fn display_leaves_ordinary_paths_alone() {
        assert_eq!(
            display_path(Path::new("/home/me/.claude/settings.json")),
            "/home/me/.claude/settings.json"
        );
        assert_eq!(
            display_path(Path::new(r"C:\Users\me\settings.json")),
            r"C:\Users\me\settings.json"
        );
    }

    #[test]
    fn case_insensitive_key_folds_case_and_prefix() {
        let watcher = Path::new(r"C:\Users\Me\.claude\Settings.json");
        let hook = Path::new(r"\\?\c:\users\me\.claude\settings.json");

        assert_eq!(
            key_with(watcher, true),
            key_with(hook, true),
            "same file reported in two casings must produce one key"
        );
    }

    #[test]
    fn case_sensitive_key_keeps_distinct_paths_distinct() {
        let lower = Path::new("/home/me/settings.json");
        let upper = Path::new("/home/Me/settings.json");

        assert_ne!(
            key_with(lower, false),
            key_with(upper, false),
            "POSIX casing is significant and must not be folded"
        );
    }

    #[test]
    fn dedup_key_matches_this_platform() {
        let path = Path::new("Settings.json");
        let expected = key_with(path, cfg!(windows));
        assert_eq!(dedup_key(path), expected);
    }
}