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);
}