From 22d735d52cbdb70a6faa10b637c5147680debf02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Mon, 21 Sep 2026 13:04:36 +0000 Subject: [PATCH] fix: follow a link anywhere under the store base --- src/commands/link.rs | 11 ++++++----- src/commands/link/check.rs | 6 +++--- src/commands/link/store.rs | 13 +++++++++++++ tests/link_store.rs | 24 ++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/src/commands/link.rs b/src/commands/link.rs index 2e20f49..a20a518 100644 --- a/src/commands/link.rs +++ b/src/commands/link.rs @@ -9,7 +9,7 @@ use std::path::{Path, PathBuf}; 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::ctx::Ctx; 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<()> { let src = resolve(path)?; let rel = repo.relative(&src)?; - let stored = repo.store.join(&rel); let Some(meta) = symlink_metadata_opt(&src)? else { 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)?; - if dest != stored { + let Some(stored) = held(repo, &src, &dest) else { bail!( - "{} points at {}, which is not where the store keeps it", + "{} points at {}, which is not in the store", rel.display(), dest.display() ); - } + }; if symlink_metadata_opt(&stored)?.is_none() { bail!("{} is missing from the store", rel.display()); } diff --git a/src/commands/link/check.rs b/src/commands/link/check.rs index b0f36f9..2e8b58f 100644 --- a/src/commands/link/check.rs +++ b/src/commands/link/check.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; 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::cmd::{Cmd, LsFiles}; use crate::ctx::Ctx; @@ -179,10 +179,10 @@ fn classify(repo: &Repo, rel: &Path, mark: char) -> Result> { } 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 // 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::Outside(end) => Some(Exposed { mark, diff --git a/src/commands/link/store.rs b/src/commands/link/store.rs index 6cbf78a..ff6007e 100644 --- a/src/commands/link/store.rs +++ b/src/commands/link/store.rs @@ -289,6 +289,19 @@ pub(super) fn symlink_metadata_opt(path: &Path) -> Result 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 diff --git a/tests/link_store.rs b/tests/link_store.rs index 50e5c6a..960daf7 100644 --- a/tests/link_store.rs +++ b/tests/link_store.rs @@ -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"); 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"); +}