fix: make the link store hold what it says it holds

This commit is contained in:
2026-09-09 12:03:16 +00:00
parent e146b0460a
commit 6b2d86ff5a
6 changed files with 413 additions and 49 deletions

View File

@@ -5,7 +5,7 @@ use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow};
use super::store::{Repo, resolve, symlink_metadata_opt};
use super::store::{Leads, Repo, leads, resolve, symlink_metadata_opt};
use crate::cmd::{Cmd, LsFiles};
use crate::ctx::Ctx;
use crate::output::{line, text, write_bytes};
@@ -33,6 +33,11 @@ pub fn check(
}
}
// and says nothing about tracked paths, where a symlink out still counts
for entry in list_tracked(ctx, &repo, &pathspecs)? {
exposed.extend(classify_tracked(&repo, &path_from(&entry))?);
}
exposed.sort_by(|a, b| a.name.cmp(&b.name));
if porcelain || null {
@@ -139,6 +144,8 @@ impl Section {
const UNTRACKED: char = '?';
const IGNORED: char = '!';
const ELSEWHERE: char = '>';
// no git equivalent: a tracked path, which only ever appears as a symlink out
const TRACKED: char = 'T';
struct Exposed {
mark: char,
@@ -178,7 +185,16 @@ fn classify(repo: &Repo, rel: &Path, mark: char) -> Result<Option<Exposed>> {
let dest = read_link(&src)?;
if dest == repo.store.join(rel) {
return Ok(None);
// it names the store, but what the store holds there can be a symlink of
// its own leading straight back out, which is not being held at all
return Ok(match leads(&src, &repo.store) {
Leads::Inside | Leads::Dangling => None,
Leads::Outside(end) => Some(Exposed {
mark,
name,
dest: Some(end),
}),
});
}
Ok(Some(Exposed {
@@ -188,6 +204,29 @@ fn classify(repo: &Repo, rel: &Path, mark: char) -> Result<Option<Exposed>> {
}))
}
// git tracks symlinks, so one can lead out of the repository without ever
// showing up in an untracked or ignored listing; `add` cannot externalize it
// either, so all check can do is say it is there
fn classify_tracked(repo: &Repo, rel: &Path) -> Result<Option<Exposed>> {
let src = repo.root.join(rel);
let Some(meta) = symlink_metadata_opt(&src)? else {
return Ok(None);
};
if !meta.is_symlink() {
return Ok(None);
}
Ok(match leads(&src, &repo.root) {
Leads::Inside | Leads::Dangling => None,
Leads::Outside(end) => Some(Exposed {
mark: TRACKED,
name: rel.to_path_buf(),
dest: Some(end),
}),
})
}
fn walk(repo: &Repo, rel: &Path, mark: char) -> Result<(usize, Vec<Exposed>)> {
let dir = repo.root.join(rel);
let mut handled = 0;
@@ -210,8 +249,12 @@ fn walk(repo: &Repo, rel: &Path, mark: char) -> Result<(usize, Vec<Exposed>)> {
}
}
// nothing below is in the store, so collapse to one line
if handled == 0 && !exposed.is_empty() {
// nothing below is in the store, so collapse to one line -- unless some of
// it leads out of the repository, which is a different thing to report and
// carries a destination the one line would drop
let all_content = exposed.iter().all(|item| item.dest.is_none());
if handled == 0 && !exposed.is_empty() && all_content {
let mut name = rel.as_os_str().to_owned();
name.push("/");
@@ -257,6 +300,12 @@ fn list_others(
Ok(split_nul(&listing.capture_bytes(ctx)?))
}
fn list_tracked(ctx: &Ctx, repo: &Repo, pathspecs: &[PathBuf]) -> Result<Vec<Vec<u8>>> {
let listing = LsFiles::tracked(&repo.root).limited_to(pathspecs);
Ok(split_nul(&listing.capture_bytes(ctx)?))
}
// every listing check reads is asked for with -z, and kept as the bytes git
// wrote: a path is not obliged to be utf-8, and one that is not used to fail
// the whole listing rather than the one entry

View File

@@ -1,8 +1,8 @@
use fs_err as fs;
use std::env;
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
use std::path::{Path, PathBuf};
use std::ffi::{OsStr, OsString};
use std::os::unix::ffi::{OsStrExt, OsStringExt};
use std::path::{Component, Path, PathBuf};
use anyhow::{Context, Result, anyhow};
@@ -23,10 +23,10 @@ pub(super) struct Repo {
impl Repo {
pub(super) fn discover(ctx: &Ctx, store: Option<&Path>) -> Result<Self> {
let root = git_root(ctx)?;
let base = match store {
let base = normalized(match store {
Some(store) => store.to_path_buf(),
None => store_root()?,
};
})?;
Ok(Self {
store: base.join(repo_components(ctx, &root)?),
@@ -69,6 +69,28 @@ pub(super) fn resolve(path: &Path) -> Result<PathBuf> {
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<PathBuf> {
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<PathBuf> {
// 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
@@ -101,7 +123,7 @@ fn repo_components(ctx: &Ctx, root: &Path) -> Result<PathBuf> {
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.to_string_lossy())))
Ok(Path::new(LOCAL_NAMESPACE).join(sanitize_name(name)))
}
fn git_origin_url(ctx: &Ctx) -> Option<String> {
@@ -141,7 +163,7 @@ fn components_from_remote(url: &str) -> Option<PathBuf> {
(depth > 0).then_some(components)
}
fn sanitize(s: &str) -> String {
fn cleaned(s: &str) -> String {
let out: String = s
.chars()
.map(|c| {
@@ -154,10 +176,51 @@ fn sanitize(s: &str) -> String {
.collect();
// `.` and `..` are legal characters but not legal components
if out.chars().all(|c| c == '.') {
return "_".repeat(out.len());
match out.chars().all(|c| c == '.') {
true => "_".repeat(out.len()),
false => out,
}
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<PathBuf> {
@@ -186,10 +249,18 @@ pub(super) fn tracked(ctx: &Ctx, repo: &Repo, rel: &Path) -> Result<bool> {
Ok(!listed.is_empty())
}
pub(super) fn ignored(ctx: &Ctx, repo: &Repo, rel: &Path) -> bool {
CheckIgnore::new(&repo.root, rel)
.quietly_succeeds(ctx)
.unwrap_or(false)
// 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<bool> {
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<Option<std::fs::Metadata>> {
@@ -201,9 +272,40 @@ pub(super) fn symlink_metadata_opt(path: &Path) -> Result<Option<std::fs::Metada
}
}
// 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::{components_from_remote, sanitize};
use super::{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::PathBuf;
#[test]
@@ -258,9 +360,70 @@ mod tests {
#[test]
fn sanitize_never_yields_a_traversal() {
assert_eq!(sanitize(".."), "__");
assert_eq!(sanitize("."), "_");
assert_eq!(sanitize("a/b"), "a_b");
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<String> = 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_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}"
);
}
}
}