cordance-core 0.1.1

Cordance core types, schemas, and ports. No I/O.
Documentation
//! Operator-trusted filesystem locations for caches and other state that
//! must not be writable by a target repo.
//!
//! The doctrine cache used to live at `<target>/.cordance/cache/doctrine/`,
//! but a hostile target can pre-populate that path with a self-consistent
//! fake git repo and bypass the loader's pin-verification (round-4
//! redteam #1). Moving it under `dirs::cache_dir()` puts it on the operator's
//! own filesystem, outside any target's reach.

use camino::Utf8PathBuf;
use sha2::{Digest, Sha256};

/// Process-global override (used by tests and operators with constrained
/// FS layouts). When `CORDANCE_DOCTRINE_CACHE_DIR` is set, it wins over
/// `dirs::cache_dir()`.
const DOCTRINE_CACHE_ENV: &str = "CORDANCE_DOCTRINE_CACHE_DIR";

/// Last-resort relative directory used when neither the env override is set
/// nor `dirs::cache_dir()`/`dirs::home_dir()` returns a value. Only the
/// final fallback case is plausible on production-ish hosts — typically a
/// bare-bones container with no `$HOME`.
const FALLBACK_CACHE_REL: &str = ".cordance-cache/doctrine";

/// True when `segment` matches any canonical path-policy segment using
/// ASCII-case-insensitive comparison.
///
/// Cordance path-policy segment names are ASCII by contract (`.git`,
/// `.cordance`, `target`, etc.). Default-case-insensitive filesystems such
/// as NTFS resolve case variants to the same surface, so policy checks must
/// compare ASCII-insensitively while still preserving exact segment
/// boundaries.
#[must_use]
pub fn segment_matches_any_ascii_case_insensitive(segment: &str, names: &[&str]) -> bool {
    names.iter().any(|name| segment.eq_ignore_ascii_case(name))
}

/// True when two path segment runs are equal under ASCII-case-insensitive
/// comparison.
#[must_use]
pub fn segments_match_ascii_case_insensitive(left: &[&str], right: &[&str]) -> bool {
    left.len() == right.len()
        && left
            .iter()
            .zip(right.iter())
            .all(|(a, b)| a.eq_ignore_ascii_case(b))
}

/// Return the operator-trusted root for the doctrine cache.
///
/// Resolution order:
///  1. `$CORDANCE_DOCTRINE_CACHE_DIR` if set.
///  2. `dirs::cache_dir() / "cordance" / "doctrine"` (e.g.
///     `~/.cache/cordance/doctrine` on Linux, `%LOCALAPPDATA%\cordance\doctrine` on Windows).
///  3. Fallback to `~/.cordance-cache/doctrine` if `dirs::cache_dir()` returns None
///     (uncommon — typically only on bare-bones containers).
#[must_use]
pub fn doctrine_cache_root() -> Utf8PathBuf {
    if let Ok(env_val) = std::env::var(DOCTRINE_CACHE_ENV) {
        if !env_val.is_empty() {
            return Utf8PathBuf::from(env_val);
        }
    }

    if let Some(cache_dir) = dirs::cache_dir() {
        let joined = cache_dir.join("cordance").join("doctrine");
        if let Ok(utf8) = Utf8PathBuf::from_path_buf(joined) {
            return utf8;
        }
    }

    // Last-resort fallback: prefer rooting under $HOME so we still land
    // outside any target tree. If even that's unavailable, return the
    // relative path — the caller will `create_dir_all` it under the
    // operator-controlled CWD.
    if let Some(home) = dirs::home_dir() {
        let joined = home.join(FALLBACK_CACHE_REL);
        if let Ok(utf8) = Utf8PathBuf::from_path_buf(joined) {
            return utf8;
        }
    }

    Utf8PathBuf::from(FALLBACK_CACHE_REL)
}

/// Return the per-fallback-repo subdirectory for the doctrine cache.
///
/// Two operators pointing the same Cordance at different fallback repos
/// should not share a cache directory (or one operator's stale clone
/// would be served for the other's pack). We namespace by the SHA-256 of
/// the fallback URL, truncated to 16 hex chars (collision-resistant for
/// any plausible deployment).
#[must_use]
pub fn doctrine_cache_dir_for_url(fallback_repo: &str) -> Utf8PathBuf {
    let mut hasher = Sha256::new();
    hasher.update(fallback_repo.as_bytes());
    let digest = hasher.finalize();
    // 16 hex chars = 8 bytes = 2^64 namespace. Collision-resistant for any
    // plausible number of fallback repos in any plausible deployment.
    let prefix = hex::encode(&digest[..8]);
    doctrine_cache_root().join(prefix)
}

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

    /// Process-global lock that serialises tests touching
    /// `CORDANCE_DOCTRINE_CACHE_DIR`. Without this, parallel test execution
    /// races on the env var (which is shared across threads) and one test
    /// observes another test's override.
    static ENV_LOCK: Mutex<()> = Mutex::new(());

    /// Set the env override for the duration of the test, restoring the
    /// previous value (if any) on drop. Holds `ENV_LOCK` while the override
    /// is in effect so other env-touching tests in this module wait their
    /// turn rather than racing on the global env.
    struct EnvOverrideGuard {
        key: &'static str,
        prev: Option<String>,
        _guard: std::sync::MutexGuard<'static, ()>,
    }

    impl EnvOverrideGuard {
        fn set(key: &'static str, value: &str) -> Self {
            // unwrap is acceptable in tests: a poisoned mutex means a prior
            // test already failed, in which case we should surface that.
            let guard = ENV_LOCK
                .lock()
                .unwrap_or_else(std::sync::PoisonError::into_inner);
            let prev = std::env::var(key).ok();
            std::env::set_var(key, value);
            Self {
                key,
                prev,
                _guard: guard,
            }
        }
    }

    impl Drop for EnvOverrideGuard {
        fn drop(&mut self) {
            match &self.prev {
                Some(v) => std::env::set_var(self.key, v),
                None => std::env::remove_var(self.key),
            }
        }
    }

    #[test]
    fn doctrine_cache_root_honours_env_override() {
        let override_path = "/tmp/cordance-doctrine-cache-test-override";
        let _guard = EnvOverrideGuard::set(DOCTRINE_CACHE_ENV, override_path);
        let resolved = doctrine_cache_root();
        assert_eq!(resolved, Utf8PathBuf::from(override_path));
    }

    #[test]
    fn doctrine_cache_root_ignores_empty_env_override() {
        // Empty string must not bypass the dirs::cache_dir() path — we want
        // explicit absence (unset) to fall through, but explicit empty
        // string is operator error and we treat it like unset.
        let _guard = EnvOverrideGuard::set(DOCTRINE_CACHE_ENV, "");
        let resolved = doctrine_cache_root();
        // The resolved path is either cache_dir()/cordance/doctrine OR the
        // home-dir fallback — never the empty string.
        assert!(
            !resolved.as_str().is_empty(),
            "doctrine_cache_root must not return an empty path when env override is empty"
        );
    }

    #[test]
    fn doctrine_cache_dir_for_url_is_deterministic() {
        // Pin the env so a parallel test can't slip a different override
        // between our two doctrine_cache_dir_for_url calls.
        let _guard = EnvOverrideGuard::set(DOCTRINE_CACHE_ENV, "/tmp/cordance-determinism-test");
        let url = "https://github.com/0ryant/engineering-doctrine";
        let a = doctrine_cache_dir_for_url(url);
        let b = doctrine_cache_dir_for_url(url);
        assert_eq!(a, b, "same URL must produce the same cache dir");
    }

    #[test]
    fn doctrine_cache_dir_for_url_distinguishes_urls() {
        // Pin the env so the comparison is between two URL hashes only.
        let _guard = EnvOverrideGuard::set(DOCTRINE_CACHE_ENV, "/tmp/cordance-distinguish-test");
        let a = doctrine_cache_dir_for_url("https://github.com/0ryant/engineering-doctrine");
        let b = doctrine_cache_dir_for_url("https://github.com/0ryant/other-doctrine");
        assert_ne!(a, b, "different URLs must produce different cache dirs");
    }

    #[test]
    fn doctrine_cache_dir_for_url_is_under_root() {
        let _guard = EnvOverrideGuard::set(DOCTRINE_CACHE_ENV, "/tmp/cordance-paths-test");
        let url = "https://github.com/0ryant/engineering-doctrine";
        let root = doctrine_cache_root();
        let leaf = doctrine_cache_dir_for_url(url);
        assert!(
            leaf.starts_with(&root),
            "url-namespaced dir {leaf} must live under cache root {root}"
        );
    }

    #[test]
    fn doctrine_cache_root_returns_absolute_when_env_unset() {
        // When the env override is unset, the result should be absolute
        // on any host where either `dirs::cache_dir()` or `dirs::home_dir()`
        // resolves — which is every host other than a contrived
        // `HOME=""` container. CI/dev hosts satisfy one of these. If
        // neither resolves, the relative fallback is acceptable; we
        // assert only on the realistic case.
        let guard = ENV_LOCK
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        let prev = std::env::var(DOCTRINE_CACHE_ENV).ok();
        std::env::remove_var(DOCTRINE_CACHE_ENV);
        let resolved = doctrine_cache_root();
        // Restore env for parallel tests.
        if let Some(v) = prev {
            std::env::set_var(DOCTRINE_CACHE_ENV, v);
        }
        drop(guard);
        if dirs::cache_dir().is_some() || dirs::home_dir().is_some() {
            assert!(
                resolved.is_absolute(),
                "doctrine_cache_root must be absolute when dirs::* resolves; got {resolved}"
            );
        }
    }

    #[test]
    fn url_namespace_prefix_is_16_hex_chars() {
        let _guard = EnvOverrideGuard::set(DOCTRINE_CACHE_ENV, "/tmp/cordance-paths-prefix-test");
        let url = "https://example.com/repo";
        let dir = doctrine_cache_dir_for_url(url);
        let leaf = dir
            .file_name()
            .expect("namespaced dir must have a final component");
        assert_eq!(leaf.len(), 16, "URL namespace prefix must be 16 hex chars");
        assert!(
            leaf.chars().all(|c| c.is_ascii_hexdigit()),
            "URL namespace prefix must be ascii hex; got {leaf}"
        );
    }

    #[test]
    fn segment_policy_helpers_are_ascii_case_insensitive_and_segment_exact() {
        let names = [".cordance", ".git", "target"];
        assert!(segment_matches_any_ascii_case_insensitive(".GIT", &names));
        assert!(segment_matches_any_ascii_case_insensitive(
            ".Cordance",
            &names
        ));
        assert!(segment_matches_any_ascii_case_insensitive("Target", &names));
        assert!(!segment_matches_any_ascii_case_insensitive(
            "myTarget", &names
        ));
        assert!(!segment_matches_any_ascii_case_insensitive(
            ".Cordance-cache",
            &names
        ));

        assert!(segments_match_ascii_case_insensitive(
            &[".CLAUDE", "Sessions"],
            &[".claude", "sessions"],
        ));
        assert!(!segments_match_ascii_case_insensitive(
            &[".CLAUDE", "mySessions"],
            &[".claude", "sessions"],
        ));
    }
}