fix: follow a link anywhere under the store base

This commit is contained in:
2026-09-21 13:04:36 +00:00
parent c96211a28b
commit 22d735d52c
4 changed files with 46 additions and 8 deletions

View File

@@ -9,7 +9,7 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow, bail}; use anyhow::{Context, Result, anyhow, bail};
use self::store::{Leads, Repo, ignored, leads, resolve, symlink_metadata_opt, tracked}; use self::store::{Leads, Repo, held, ignored, leads, resolve, symlink_metadata_opt, tracked};
use crate::cli::link as cli; use crate::cli::link as cli;
use crate::ctx::Ctx; use crate::ctx::Ctx;
use crate::fsops::suffixed; use crate::fsops::suffixed;
@@ -104,7 +104,6 @@ pub fn restore(ctx: &Ctx, args: &cli::Restore) -> Result<()> {
fn restore_one(ctx: &Ctx, repo: &Repo, path: &Path, report: &Report) -> Result<()> { fn restore_one(ctx: &Ctx, repo: &Repo, path: &Path, report: &Report) -> Result<()> {
let src = resolve(path)?; let src = resolve(path)?;
let rel = repo.relative(&src)?; let rel = repo.relative(&src)?;
let stored = repo.store.join(&rel);
let Some(meta) = symlink_metadata_opt(&src)? else { let Some(meta) = symlink_metadata_opt(&src)? else {
bail!("{} does not exist", rel.display()); bail!("{} does not exist", rel.display());
@@ -116,14 +115,16 @@ fn restore_one(ctx: &Ctx, repo: &Repo, path: &Path, report: &Report) -> Result<(
); );
} }
// followed to wherever under the base it points, so a checkout that moved
// or a store laid out by an older ahab can still take its files back
let dest = read_link(&src)?; let dest = read_link(&src)?;
if dest != stored { let Some(stored) = held(repo, &src, &dest) else {
bail!( bail!(
"{} points at {}, which is not where the store keeps it", "{} points at {}, which is not in the store",
rel.display(), rel.display(),
dest.display() dest.display()
); );
} };
if symlink_metadata_opt(&stored)?.is_none() { if symlink_metadata_opt(&stored)?.is_none() {
bail!("{} is missing from the store", rel.display()); bail!("{} is missing from the store", rel.display());
} }

View File

@@ -5,7 +5,7 @@ use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use super::store::{Leads, Repo, leads, resolve, symlink_metadata_opt}; use super::store::{Leads, Repo, held, leads, resolve, symlink_metadata_opt};
use crate::cli::link as cli; use crate::cli::link as cli;
use crate::cmd::{Cmd, LsFiles}; use crate::cmd::{Cmd, LsFiles};
use crate::ctx::Ctx; use crate::ctx::Ctx;
@@ -179,10 +179,10 @@ fn classify(repo: &Repo, rel: &Path, mark: char) -> Result<Option<Exposed>> {
} }
let dest = read_link(&src)?; let dest = read_link(&src)?;
if dest == repo.store.join(rel) { if held(repo, &src, &dest).is_some() {
// it names the store, but what the store holds there can be a symlink of // 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 // its own leading straight back out, which is not being held at all
return Ok(match leads(&src, &repo.store) { return Ok(match leads(&src, &repo.base) {
Leads::Inside | Leads::Dangling => None, Leads::Inside | Leads::Dangling => None,
Leads::Outside(end) => Some(Exposed { Leads::Outside(end) => Some(Exposed {
mark, mark,

View File

@@ -289,6 +289,19 @@ pub(super) fn symlink_metadata_opt(path: &Path) -> Result<Option<std::fs::Metada
} }
} }
// 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<PathBuf> {
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 // where a chain of symlinks actually ends up
pub(super) enum Leads { pub(super) enum Leads {
// somewhere under the directory it was supposed to stay in // somewhere under the directory it was supposed to stay in

View File

@@ -282,3 +282,27 @@ fn two_checkouts_of_one_remote_keep_their_own_files() {
assert_eq!(fs::read(second.path(".env")).unwrap(), b"SECOND\n"); assert_eq!(fs::read(second.path(".env")).unwrap(), b"SECOND\n");
second.ahab(&["link", "list"]).ok().says("linked: .env"); second.ahab(&["link", "list"]).ok().says("linked: .env");
} }
#[test]
fn a_link_into_an_older_store_layout_still_checks_clean_and_restores() {
let case = Case::new("legacy");
// the flat layout, before the store was keyed by checkout
let old = case.store.parent().unwrap().join(".env");
fs::create_dir_all(old.parent().unwrap()).unwrap();
fs::write(&old, b"SECRET=1\n").unwrap();
case.link(&case.path(".env"), &old);
case.gitignore(".env\n");
case.ahab(&["link", "check", "--exit-code"]).ok();
case.ahab(&["link", "restore", ".env"]).ok();
assert!(!case.is_symlink(".env"));
assert_eq!(fs::read(case.path(".env")).unwrap(), b"SECRET=1\n");
assert!(!old.exists());
// a link that leaves the store altogether is still refused
case.link(&case.path("key"), &case.outside.join("key"));
case.ahab(&["link", "restore", "key"])
.failed()
.says("not in the store");
}