fix: make the link store hold what it says it holds
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user