Mega Mind v0.5.4

refs.rs

Rust · 320 lines · 14.5 KB
//! Refs: the files an entry describes, their fingerprints, and staleness.

use super::{expand, Mind, Meta, RefState};
use anyhow::{bail, Result};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

#[derive(Debug, Clone, PartialEq)]
pub enum RefStatus {
    Fresh,
    Changed,
    Missing,
    Untracked,
    Unresolved(String),
}

impl RefStatus {
    pub fn label(&self) -> String {
        match self {
            RefStatus::Fresh => "ok".into(),
            RefStatus::Changed => "⚠ changed since this entry was written".into(),
            RefStatus::Missing => "⚠ missing (moved or deleted)".into(),
            RefStatus::Untracked => "not tracked (written before ref tracking)".into(),
            RefStatus::Unresolved(why) => format!("⚠ {why}"),
        }
    }
    pub fn is_stale(&self) -> bool {
        matches!(self, RefStatus::Changed | RefStatus::Missing | RefStatus::Unresolved(_))
    }
}

impl Mind {
    /// `project:path/in/repo` (or an absolute / ~ path) → (project root if any, absolute path).
    pub fn resolve_ref(&self, r: &str) -> Result<(Option<PathBuf>, PathBuf), String> {
        let r = r.split('#').next().unwrap_or(r).trim();
        // A network path is refused before anything resolves it (see `is_network_path`).
        if super::is_network_path(r) || r.split_once(':').is_some_and(|(_, rel)| super::is_network_path(rel)) {
            return Err(format!("ref {r:?} is a network path; refs must be files in a project on this machine"));
        }
        // Never a secrets file: its hash (and last commit) would be written into the synced mind.
        let name = r.rsplit(['/', '\\', ':']).next().unwrap_or(r).to_lowercase();
        if looks_like_secret_file(&name) {
            return Err(format!("ref {r:?} looks like a secrets file ({name}); refs can't point at those"));
        }
        if Path::new(r).is_absolute() || r.starts_with("~/") || r.starts_with("~\\") || has_drive_prefix(r) {
            return self.resolve_abs_ref(r);
        }
        let (proj, rel) = r
            .split_once(':')
            .ok_or_else(|| format!("ref {r:?} should look like `<project>:<path>`"))?;
        let p = self
            .projects
            .get(proj)
            .ok_or_else(|| format!("unknown project {proj:?} in ref (see `mind projects`)"))?;
        let rel = rel.trim_start_matches(['/', '\\']);
        if Path::new(rel).components().any(|c| matches!(c, std::path::Component::ParentDir)) {
            return Err(format!("ref {r:?} must stay inside the project (no `..`)"));
        }
        // One spelling per path (`app:./x`, `app:x//`): else one entry can list the same folder
        // hundreds of times and make every hook run git that often.
        if rel.contains("//") || rel.contains("\\\\") || Path::new(rel).components().any(|c| matches!(c, std::path::Component::CurDir))
            || (rel.len() > 1 && (rel.ends_with('/') || rel.ends_with('\\')))
        {
            return Err(format!("ref {r:?}: write the path plainly (no `./`, `//` or trailing `/`)"));
        }
        let root = expand(&p.path);
        if !root.is_absolute() {
            return Err(format!("project {:?} has a relative path ({}); re-add it with `mind project add` from its folder", p.path, p.path));
        }
        let joined = root.join(rel);
        // A drive prefix (Windows) or a symlink can lead outside the project: check the real path.
        if !joined.starts_with(&root) {
            return Err(format!("ref {r:?} must stay inside the project"));
        }
        if let (Ok(real), Ok(real_root)) = (joined.canonicalize(), root.canonicalize())
            && !real.starts_with(&real_root) {
                return Err(format!("ref {r:?} points outside the project (symlink)"));
            }
        Ok((Some(root), joined))
    }

    /// An absolute or `~/` ref is allowed only inside a registered project (agents open refs, so a
    /// ref must never point them at keys or tokens elsewhere on the machine).
    fn resolve_abs_ref(&self, r: &str) -> Result<(Option<PathBuf>, PathBuf), String> {
        let abs = expand(r);
        // Inside a registered project by the text first, so nothing outside one is ever looked up.
        if !self.projects.values().any(|p| abs.starts_with(expand(&p.path))) {
            return Err(format!(
                "ref {r:?} is outside every registered project: write it as `<project>:<path>` \
                 (add the folder with `mind project add`) or mention the path in the text instead"
            ));
        }
        if !abs.is_absolute()
            || abs.components().any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir))
        {
            return Err(format!("ref {r:?} must be a plain path inside a project (no `..`)"));
        }
        let real = abs.canonicalize().unwrap_or_else(|_| abs.clone());
        let best = self
            .projects
            .values()
            .filter_map(|p| {
                let root = expand(&p.path);
                let real_root = root.canonicalize().unwrap_or_else(|_| root.clone());
                (abs.starts_with(&root) && real.starts_with(&real_root)).then(|| (root.components().count(), root))
            })
            .max();
        match best {
            Some((_, root)) => Ok((Some(root), abs)),
            None => Err(format!(
                "ref {r:?} is outside every registered project: write it as `<project>:<path>` \
                 (add the folder with `mind project add`) or mention the path in the text instead"
            )),
        }
    }

    /// Where git may look at `abs`: the root of the registered project holding it (its own repo,
    /// never a folder a ref names, which could be a repo planted in a synced mind) and the path
    /// inside it. None when no project holds it or the project is inside the mind.
    pub fn git_location(&self, abs: &Path) -> Option<(PathBuf, String)> {
        let real = abs.canonicalize().ok()?;
        let mind = self.home.canonicalize().unwrap_or_else(|_| self.home.clone());
        let (root, _) = self
            .projects
            .values()
            .filter_map(|p| {
                let root = expand(&p.path).canonicalize().ok()?;
                (real.starts_with(&root) && !root.starts_with(&mind)).then(|| (root.clone(), root.components().count()))
            })
            .max_by_key(|(_, n)| *n)?;
        let rel = real.strip_prefix(&root).ok()?.to_string_lossy().replace('\\', "/");
        Some((root, if rel.is_empty() { ".".into() } else { rel }))
    }

    /// Current fingerprint of a ref: content hash (files) or git tree listing (dirs).
    pub fn ref_state_now(&self, r: &str) -> Result<RefState, RefStatus> {
        let (_, abs) = self.resolve_ref(r).map_err(RefStatus::Unresolved)?;
        let hash = if abs.is_file() {
            file_hash(&abs).ok_or(RefStatus::Missing)?
        } else if abs.is_dir() {
            let (root, rel) = self.git_location(&abs).ok_or(RefStatus::Untracked)?;
            let listing = project_git(&root)
                .args(["ls-files", "-s", "-m", "--", &rel])
                .output()
                .ok()
                .filter(|o| o.status.success())
                .map(|o| o.stdout)
                .unwrap_or_default();
            if listing.is_empty() {
                return Err(RefStatus::Untracked);
            }
            // Uncommitted edits too (the listing alone only names modified files, so a second
            // edit to an already-modified file looked unchanged).
            let edits = project_git(&root)
                .args(["diff", "--no-ext-diff", "--no-textconv", "--no-color", "HEAD", "--", &rel])
                .output()
                .ok()
                .filter(|o| o.status.success())
                .map(|o| o.stdout)
                .unwrap_or_default();
            // A folder with no uncommitted edits keeps its earlier fingerprint (no needless stale wave).
            if edits.is_empty() {
                hex::encode(Sha256::digest(&listing))
            } else {
                let mut h = Sha256::new();
                h.update(&listing);
                h.update(&edits);
                hex::encode(h.finalize())
            }
        } else {
            return Err(RefStatus::Missing);
        };
        let commit = match self.git_location(&abs) {
            Some((root, rel)) => project_git(&root)
                .args(["log", "-1", "--format=%H", "--", &rel])
                .output()
                .ok()
                .filter(|o| o.status.success())
                .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
                .unwrap_or_default(),
            None => String::new(),
        };
        Ok(RefState { hash, commit })
    }

    /// Content fingerprint only (no git history lookup): what staleness checks need.
    pub fn ref_hash_now(&self, r: &str) -> Result<String, RefStatus> {
        let (_, abs) = self.resolve_ref(r).map_err(RefStatus::Unresolved)?;
        if abs.is_file() {
            return file_hash(&abs).ok_or(RefStatus::Missing);
        }
        // Folders: once per folder within one hook run (`cache_folder_hashes`), else fresh.
        use std::collections::HashMap;
        use std::sync::{Mutex, OnceLock};
        static DIRS: OnceLock<Mutex<HashMap<PathBuf, Result<String, RefStatus>>>> = OnceLock::new();
        let caching = FOLDER_CACHE.load(std::sync::atomic::Ordering::Relaxed);
        let cache = DIRS.get_or_init(|| Mutex::new(HashMap::new()));
        if caching && let Some(h) = cache.lock().ok().and_then(|c| c.get(&abs).cloned()) {
            return h;
        }
        let h = self.ref_state_now(r).map(|s| s.hash);
        if caching && let Ok(mut c) = cache.lock() {
            c.insert(abs, h.clone());
        }
        h
    }

    pub fn ref_status(&self, meta: &Meta, r: &str) -> RefStatus {
        match (self.ref_hash_now(r), meta.ref_state.get(r)) {
            (Err(s), _) => s,
            (Ok(_), None) => RefStatus::Untracked,
            (Ok(now), Some(then)) if now == then.hash => RefStatus::Fresh,
            (Ok(_), Some(_)) => RefStatus::Changed,
        }
    }

    pub fn stale_refs(&self, meta: &Meta) -> Vec<(String, RefStatus)> {
        // Each file once (`a.rs#L1` and `a.rs#L9` are one file), and a cap per entry, so a pulled
        // entry with hundreds of refs to a big file can't make every hook time out.
        checked_refs(&meta.refs)
            .into_iter()
            .map(|r| (r.clone(), self.ref_status(meta, r)))
            .filter(|(_, s)| s.is_stale())
            .collect()
    }

    /// Snapshot every ref's current state into the frontmatter.
    pub fn stamp_refs(&self, meta: &mut Meta) -> Result<()> {
        let mut bad = vec![];
        meta.ref_state.clear();
        for r in &meta.refs {
            match self.ref_state_now(r) {
                Ok(s) => {
                    meta.ref_state.insert(r.clone(), s);
                }
                Err(RefStatus::Untracked) => {}
                Err(s) => bad.push(format!("  {r}: {}", s.label())),
            }
        }
        if !bad.is_empty() {
            bail!("these refs don't resolve to existing files:\n{}", bad.join("\n"));
        }
        Ok(())
    }
}

/// Windows `C:x` / `C:\\x` (Path::is_absolute misses drive-relative ones). Not a drive elsewhere:
/// there `x:path` is a project ref.
fn has_drive_prefix(r: &str) -> bool {
    let b = r.as_bytes();
    cfg!(windows) && b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':'
}

/// git run at a project's root, with nothing in the repo able to start a program: no fsmonitor, no
/// hooks, no external diff or text conversion, no implicitly found bare repo, literal pathspecs.
pub fn project_git(root: &Path) -> Command {
    let mut c = Command::new("git");
    c.arg("-C").arg(root);
    for kv in ["core.fsmonitor=false", "safe.bareRepository=explicit", "diff.external=", "core.pager=cat"] {
        c.arg("-c").arg(kv);
    }
    c.arg("-c").arg(format!("core.hooksPath={}", super::app_cache_dir().join("no-hooks").display()));
    c.arg("--literal-pathspecs");
    c
}

/// At most this many distinct refs of one entry are checked for staleness.
pub const MAX_REFS_CHECKED: usize = 50;
/// Files larger than this are fingerprinted by size and modified time instead of their contents.
pub const MAX_HASHED_BYTES: u64 = 16 << 20;

/// An entry's refs as the staleness checks see them: each file once (`#…` ignored), at most
/// `MAX_REFS_CHECKED`.
pub fn checked_refs(refs: &[String]) -> Vec<&String> {
    let mut seen = std::collections::HashSet::new();
    refs.iter().filter(|r| seen.insert(r.split('#').next().unwrap_or(r).trim().to_string())).take(MAX_REFS_CHECKED).collect()
}

/// A file's SHA-256, cached for this process by (path, size, modified time): the hooks check the
/// same refs several times per run.
fn file_hash(path: &Path) -> Option<String> {
    use std::collections::HashMap;
    use std::sync::{Mutex, OnceLock};
    type Key = (PathBuf, u64, Option<std::time::SystemTime>);
    static CACHE: OnceLock<Mutex<HashMap<Key, String>>> = OnceLock::new();
    let m = fs::metadata(path).ok()?;
    let key = (path.to_path_buf(), m.len(), m.modified().ok());
    let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
    if let Some(h) = cache.lock().ok()?.get(&key) {
        return Some(h.clone());
    }
    // Large files are fingerprinted by size and modified time, not read whole (the hooks run on
    // every prompt, and a ref can name any file in the project).
    let h = if m.len() > MAX_HASHED_BYTES {
        let t = m.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_nanos());
        format!("large:{}:{t}", m.len())
    } else {
        hex::encode(Sha256::digest(fs::read(path).ok()?))
    };
    cache.lock().ok()?.insert(key, h.clone());
    Some(h)
}

/// File names that hold keys or credentials.
pub fn looks_like_secret_file(name: &str) -> bool {
    name == ".env" || name.starts_with(".env.") && !name.ends_with(".example") && !name.ends_with(".sample")
        || [".netrc", "_netrc", ".npmrc", ".pypirc", ".pgpass", "credentials", "credentials.json", ".git-credentials", "kubeconfig", "id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"]
            .contains(&name)
        || [".pem", ".key", ".p12", ".pfx", ".keystore", ".jks"].iter().any(|e| name.ends_with(e))
}

static FOLDER_CACHE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);

/// Hooks are one short run each: remember folder fingerprints for its length, so many entries
/// naming the same folder run git once.
pub fn cache_folder_hashes() {
    FOLDER_CACHE.store(true, std::sync::atomic::Ordering::Relaxed);
}