diff --git a/src/cook/fetch.rs b/src/cook/fetch.rs index 0c0894a2f9..bb4e7eea2c 100644 --- a/src/cook/fetch.rs +++ b/src/cook/fetch.rs @@ -35,6 +35,91 @@ pub struct FetchResult { pub cached: bool, } +/// Build the shortest relative path that walks from `from` (a directory) to `to`. +/// +/// Both inputs must already be absolute and normalized (canonicalized). Returns +/// `None` when the two paths share no common prefix (e.g. different mount roots +/// on platforms where that is representable), in which case the caller should +/// fall back to an absolute link. +fn relative_path_from(from: &Path, to: &Path) -> Option { + let mut from_parts = from.components().peekable(); + let mut to_parts = to.components().peekable(); + + // Drop the shared prefix. + while let (Some(f), Some(t)) = (from_parts.peek(), to_parts.peek()) { + if f == t { + from_parts.next(); + to_parts.next(); + } else { + break; + } + } + + let mut rel = PathBuf::new(); + let mut climbed = false; + for _ in from_parts { + rel.push(".."); + climbed = true; + } + let mut descended = false; + for part in to_parts { + rel.push(part); + descended = true; + } + + if !climbed && !descended { + // `from` == `to`; a self-referential link is never what we want. + return None; + } + Some(rel) +} + +/// Point `link` at `target`, replacing whatever currently occupies `link`. +/// +/// This exists because `Path::exists()` follows symlinks, so the obvious +/// `if link.exists() { remove_dir_all(link) }` guard silently fails in the two +/// cases that matter most here: +/// +/// * `link` is a **dangling** symlink (e.g. left behind after the repo was +/// moved). `exists()` reports `false`, nothing is removed, and the +/// subsequent `symlink()` fails with `EEXIST`. +/// * `link` is a **live symlink to a directory**. `exists()` reports `true`, +/// but `remove_dir_all()` refuses to operate on a symlink, so the removal +/// fails and `symlink()` again fails with `EEXIST`. +/// +/// `symlink_metadata()` does not follow the final component, so it classifies +/// the entry itself and lets us pick the correct removal call. +/// +/// The link is written as a **relative** path whenever the two live under a +/// common ancestor. Recipe `source` links are committed build state that must +/// survive the checkout being moved or cloned to a different prefix; an +/// absolute link bakes in one machine's layout and dangles everywhere else — +/// exactly the breakage this function had to recover from. +pub(crate) fn force_symlink(target: &Path, link: &Path) -> std::io::Result<()> { + match fs::symlink_metadata(link) { + Ok(meta) => { + if meta.file_type().is_dir() { + fs::remove_dir_all(link)?; + } else { + // Regular file, or a symlink of any kind (including dangling + // ones and symlinks pointing at directories). + fs::remove_file(link)?; + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + + let link_dir = link.parent().unwrap_or_else(|| Path::new(".")); + let link_target = link_dir + .canonicalize() + .ok() + .and_then(|base| relative_path_from(&base, target)) + .unwrap_or_else(|| target.to_path_buf()); + + std::os::unix::fs::symlink(link_target, link) +} + pub(crate) fn cleanup_workspace_pollution(recipe_dir: &Path, logger: &PtyOut) { let recipes_root = recipe_dir.join("../.."); for file in &["Cargo.toml", "Cargo.lock"] { @@ -373,10 +458,7 @@ pub fn fetch_offline(recipe: &CookRecipe, logger: &PtyOut) -> Result {:?}: {}", path.display(), source_dir.display(), e) })?; } else { @@ -617,10 +699,7 @@ pub fn fetch(recipe: &CookRecipe, check_source: bool, logger: &PtyOut) -> Result path.display(), source_dir.display() ); - if source_dir.exists() { - std::fs::remove_dir_all(&source_dir).ok(); - } - std::os::unix::fs::symlink(path.canonicalize().unwrap_or(path.clone()), &source_dir).map_err(|e| { + force_symlink(&path.canonicalize().unwrap_or(path.clone()), &source_dir).map_err(|e| { format!("failed to symlink {:?} -> {:?}: {}", path.display(), source_dir.display(), e) })?; } else { @@ -1014,6 +1093,24 @@ fn fetch_will_build(recipe: &CookRecipe) -> bool { pub(crate) fn fetch_make_symlink(source_dir: &PathBuf, same_as: &String) -> Result<()> { let target_dir = Path::new(same_as).join("source"); + // An existing symlink is only reusable if it still points where the recipe + // says it should. A link left over from an earlier layout (e.g. the repo was + // moved, so the recorded path no longer resolves) would otherwise be kept + // forever, and the build would fail later with a confusing missing-source + // error instead of being repaired here. + let stale_link = source_dir.is_symlink() + && fs::read_link(source_dir) + .map(|current| current != target_dir) + .unwrap_or(true); + if stale_link { + fs::remove_file(source_dir).map_err(|err| { + format!( + "failed to remove stale symlink '{}': {}", + source_dir.display(), + err + ) + })?; + } if !source_dir.is_symlink() { if source_dir.is_dir() { bail_other_err!(