use fs_err as fs; use std::env; use std::ffi::{OsStr, OsString}; use std::os::unix::ffi::{OsStrExt, OsStringExt}; use std::path::{Component, Path, PathBuf}; use anyhow::{Context, Result, anyhow}; use crate::cmd::{CheckIgnore, Cmd, ConfigGet, LsFiles, RevParse}; use crate::ctx::Ctx; use crate::output::note; // a checkout with no remote to name it after const LOCAL_NAMESPACE: &str = "_local"; pub(super) struct Repo { pub(super) root: PathBuf, pub(super) store: PathBuf, // the configured store root, above the per-repository directories pub(super) base: PathBuf, } impl Repo { pub(super) fn discover(ctx: &Ctx, store: Option<&Path>) -> Result { let root = git_root(ctx)?; let base = normalized(match store { Some(store) => store.to_path_buf(), None => store_root()?, })?; Ok(Self { store: base.join(repo_components(ctx, &root)?), root, base, }) } pub(super) fn relative(&self, src: &Path) -> Result { let rel = src.strip_prefix(&self.root).map_err(|_| { anyhow!( "{} is outside the repository {}", src.display(), self.root.display() ) })?; // empty means the whole repo if rel.as_os_str().is_empty() { return Err(anyhow!( "refusing to externalize the repository root itself" )); } if rel.starts_with(".git") { return Err(anyhow!("refusing to externalize anything under .git")); } Ok(rel.to_path_buf()) } } pub(super) fn resolve(path: &Path) -> Result { let abs = std::path::absolute(path) .with_context(|| format!("resolving absolute path of {}", path.display()))?; // the parent must exist so symlinked components resolve like git's toplevel let Some(name) = abs.file_name().map(OsString::from) else { return Ok(fs::canonicalize(&abs)?); }; let parent = fs::canonicalize(abs.parent().unwrap_or(Path::new("/")))?; Ok(parent.join(name)) } // absolute, with `.` and `..` folded out. the store path is written into every // symlink `add` creates, where a relative one would resolve from the link's own // directory rather than the working one, and it is compared against the // repository root, which a `..` would slip past fn normalized(path: PathBuf) -> Result { let absolute = std::path::absolute(&path) .with_context(|| format!("resolving absolute path of {}", path.display()))?; let mut out = PathBuf::new(); for part in absolute.components() { match part { Component::CurDir => {} Component::ParentDir => { out.pop(); } part => out.push(part), } } Ok(out) } fn git_root(ctx: &Ctx) -> Result { // bytes, since the checkout can live at a path that is not utf-8, and with // the cause kept: git not being installed and the directory not being a // repository are different problems with the same one-line answer otherwise let root = RevParse .capture_bytes(ctx) .context("asking git for the repository root")?; let root = root.strip_suffix(b"\n").unwrap_or(&root); if root.is_empty() { return Err(anyhow!("git reported an empty repository root")); } Ok(fs::canonicalize(PathBuf::from(OsString::from_vec( root.to_vec(), )))?) } // the remote names the project and the checkout names its clone, so two clones // never share a directory fn repo_components(ctx: &Ctx, root: &Path) -> Result { Ok(project_components(ctx, root)?.join(checkout_name(root))) } // `-`: the directory for a human, the hash of the canonical path // for two checkouts in directories of one name fn checkout_name(root: &Path) -> String { let hash = fingerprint(root.as_os_str().as_bytes()); match root.file_name() { Some(dir) => format!("{}-{hash}", cleaned(&dir.to_string_lossy())), None => hash, } } fn project_components(ctx: &Ctx, root: &Path) -> Result { if let Some(url) = git_origin_url(ctx) { if let Some(components) = components_from_remote(&url) { return Ok(components); } note!( ctx, "could not parse git remote `{url}`, falling back to the checkout name" ); } let name = root .file_name() .ok_or_else(|| anyhow!("cannot derive a store path for {}", root.display()))?; Ok(Path::new(LOCAL_NAMESPACE).join(sanitize_name(name))) } fn git_origin_url(ctx: &Ctx) -> Option { let url = ConfigGet { key: "remote.origin.url", } .capture(ctx) .ok()?; let url = url.trim().to_string(); (!url.is_empty()).then_some(url) } fn components_from_remote(url: &str) -> Option { let url = url.trim(); let url = url.strip_suffix(".git").unwrap_or(url); // `scheme://[user@]host[:port]/path`, or scp-like `[user@]host:path` let (authority, path) = match url.split_once("://") { Some((_, after)) => after.split_once('/')?, None => url.split_once(':')?, }; let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h); let host = host.split_once(':').map_or(host, |(h, _)| h); // a local remote has no host to key on if host.is_empty() { return None; } let mut components = PathBuf::from(sanitize(&host.to_lowercase())); let mut depth = 0; for part in path.split('/').filter(|p| !p.is_empty()) { components.push(sanitize(part)); depth += 1; } (depth > 0).then_some(components) } fn cleaned(s: &str) -> String { let out: String = s .chars() .map(|c| { if c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_') { c } else { '_' } }) .collect(); // `.` and `..` are legal characters but not legal components match out.chars().all(|c| c == '.') { true => "_".repeat(out.len()), false => out, } } fn sanitize(s: &str) -> String { let out = cleaned(s); // every replaced character maps to the same `_`, so `my~api` and `my:api` // would otherwise share one directory with the plain `my_api`. a component // that came through untouched keeps its name, so the common remote keeps // the store path it already has match out == s { true => out, false => format!("{out}-{}", fingerprint(s.as_bytes())), } } // a checkout name is bytes like any other path. a lossy rendering turns every // byte it cannot read into the same replacement character, so sanitize would // see two different names as one and fingerprint them identically: the bytes // themselves are what has to be fingerprinted fn sanitize_name(name: &OsStr) -> String { match name.to_str() { Some(text) => sanitize(text), None => format!( "{}-{}", cleaned(&name.to_string_lossy()), fingerprint(name.as_bytes()) ), } } // fnv-1a: the store path has to stay put across rust releases, which the hashers // in std explicitly do not promise fn fingerprint(bytes: &[u8]) -> String { let mut hash: u64 = 0xcbf2_9ce4_8422_2325; for byte in bytes { hash ^= u64::from(*byte); hash = hash.wrapping_mul(0x100_0000_01b3); } format!("{:08x}", hash as u32) } fn store_root() -> Result { if let Some(xdg) = non_empty_var("XDG_DATA_HOME") { return Ok(PathBuf::from(xdg).join("ahab")); } let home = non_empty_var("HOME") .filter(|v| !v.is_empty()) .ok_or_else(|| anyhow!("neither XDG_DATA_HOME nor HOME is set"))?; Ok(PathBuf::from(home).join(".local/share/ahab")) } fn non_empty_var(name: &str) -> Option { env::var_os(name).filter(|v| !v.is_empty()) } pub(super) fn tracked(ctx: &Ctx, repo: &Repo, rel: &Path) -> Result { // bytes: the listing echoes back the path asked about, which is not obliged // to be utf-8, and failing to read it would refuse the path for the wrong // reason rather than answering whether git tracks it let listed = LsFiles::tracked(&repo.root) .limited_to(&[rel]) .capture_bytes(ctx)?; Ok(!listed.is_empty()) } // check-ignore says 0 for ignored and 1 for not; anything else means it could // not answer at all, which is not the same as "not ignored". git cannot even be // asked about a path whose name looks like pathspec magic, since it rejects the // magic rather than the name, and `:(literal)` is not accepted by this command pub(super) fn ignored(ctx: &Ctx, repo: &Repo, rel: &Path) -> Result { let status = CheckIgnore::new(&repo.root, rel).probe_status(ctx)?; match status.code() { Some(0) => Ok(true), Some(1) => Ok(false), _ => Err(anyhow!("`git check-ignore` exited with {status}")), } } pub(super) fn symlink_metadata_opt(path: &Path) -> Result> { // symlink_metadata does not follow the link, so a symlink shows as one match fs::symlink_metadata(path) { Ok(meta) => Ok(Some(meta)), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(e) => Err(e.into()), } } // where a link into the store points, if it is one: absolute, under the base. // the directory need not be this checkout's own, since a moved checkout or an // older layout still holds the file pub(super) fn held(repo: &Repo, link: &Path, dest: &Path) -> Option { let dest = match dest.is_absolute() { true => dest.to_path_buf(), false => link.parent()?.join(dest), }; let dest = normalized(dest).ok()?; dest.starts_with(&repo.base).then_some(dest) } // where a chain of symlinks actually ends up pub(super) enum Leads { // somewhere under the directory it was supposed to stay in Inside, // out of it, at this path Outside(PathBuf), // nowhere: a broken link, or too many hops for the kernel to follow Dangling, } // comparing a link's target against an expected path only says what it claims; // this says where following it really arrives, which is what decides whether a // path is held by the store or merely points at something that is not pub(super) fn leads(path: &Path, root: &Path) -> Leads { let Ok(end) = fs::canonicalize(path) else { return Leads::Dangling; }; // the root can be reached through a symlink of its own, so resolve it too // rather than comparing a resolved path against an unresolved prefix let root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); match end.starts_with(&root) { true => Leads::Inside, false => Leads::Outside(end), } } #[cfg(test)] mod tests { use super::{ checkout_name, components_from_remote, fingerprint, normalized, sanitize, sanitize_name, }; use std::collections::HashSet; use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt; use std::path::{Path, PathBuf}; #[test] fn parses_every_spelling_of_a_remote() { let cases = [ ( "git@git.aflabs.org:urnik/afurnik.git", "git.aflabs.org/urnik/afurnik", ), ( "https://git.aflabs.org/urnik/afurnik.git", "git.aflabs.org/urnik/afurnik", ), ( "https://git.aflabs.org/urnik/afurnik", "git.aflabs.org/urnik/afurnik", ), ( "https://git.aflabs.org/urnik/afurnik/", "git.aflabs.org/urnik/afurnik", ), ( "ssh://git@git.aflabs.org:22/urnik/afurnik.git", "git.aflabs.org/urnik/afurnik", ), ( "git@GIT.Aflabs.org:urnik/AFurnik.git", "git.aflabs.org/urnik/AFurnik", ), ( "git@git.aflabs.org:urnik/internal/afurnik.git", "git.aflabs.org/urnik/internal/afurnik", ), ]; for (url, want) in cases { assert_eq!( components_from_remote(url), Some(PathBuf::from(want)), "url: {url}" ); } } #[test] fn rejects_remotes_without_a_host() { assert_eq!(components_from_remote("not-a-url"), None); assert_eq!(components_from_remote("/srv/git/afurnik.git"), None); assert_eq!(components_from_remote("file:///srv/git/afurnik.git"), None); assert_eq!(components_from_remote("https://git.aflabs.org/"), None); } #[test] fn sanitize_never_yields_a_traversal() { for name in ["..", ".", "a/b", "../..", "a/../b"] { let out = sanitize(name); assert!(!out.contains('/'), "{name} -> {out}"); assert!(out != "." && out != "..", "{name} -> {out}"); } // a name that needed no replacing keeps the store path it already has assert_eq!(sanitize(".env"), ".env"); assert_eq!(sanitize("afurnik"), "afurnik"); assert_eq!(sanitize("git.aflabs.org"), "git.aflabs.org"); } #[test] fn sanitize_keeps_names_apart_that_replacing_would_collapse() { // every disallowed character maps to `_`, so without the fingerprint // these would all share one store directory with a plain `my_api` let names = ["my~api", "my:api", "my api", "my/api", "my%api"]; for name in names { assert_ne!(sanitize(name), sanitize("my_api"), "name: {name}"); } let distinct: HashSet = names.iter().map(|name| sanitize(name)).collect(); assert_eq!(distinct.len(), names.len(), "{distinct:?}"); } #[test] fn a_fingerprint_does_not_drift_with_the_toolchain() { // std's hashers make no such promise, and a moved store loses the files assert_eq!(fingerprint(b""), "84222325"); assert_eq!(fingerprint(b"my~api"), fingerprint(b"my~api")); assert_ne!(fingerprint(b"my~api"), fingerprint(b"my:api")); } #[test] fn a_checkout_name_that_is_not_utf_8_keeps_its_own_directory() { // both render to the same replacement character, so a fingerprint taken // of the rendering rather than the bytes cannot tell them apart let one = OsStr::from_bytes(b"proj\xe9"); let two = OsStr::from_bytes(b"proj\xff"); assert_ne!(sanitize_name(one), sanitize_name(two)); // and a name that is utf-8 is keyed exactly as before assert_eq!(sanitize_name(OsStr::new("afurnik")), "afurnik"); } #[test] fn a_checkout_is_named_by_its_directory_and_its_path() { let one = checkout_name(Path::new("/home/a/afurnik")); let two = checkout_name(Path::new("/home/b/afurnik")); assert!(one.starts_with("afurnik-"), "{one}"); assert_ne!(one, two); // the root itself has no directory to be named after assert_eq!(checkout_name(Path::new("/")), fingerprint(b"/")); } #[test] fn a_store_root_is_absolute_with_the_dots_folded_out() { // it is written into every symlink add creates, and compared against the // repository root, so it needs exactly one spelling let cases = [ ("/a/b/../c", "/a/c"), ("/a/./b", "/a/b"), ("/a/b/../../c", "/c"), ("/../..", "/"), ]; for (from, want) in cases { assert_eq!( normalized(PathBuf::from(from)).unwrap(), PathBuf::from(want), "from: {from}" ); } } }