context: copy symlinks as symlinks and improve error messages

This commit is contained in:
2026-07-17 10:12:46 +02:00
parent 2b27b7b06e
commit c4b59a4376
11 changed files with 382 additions and 54 deletions
+33 -11
View File
@@ -292,21 +292,43 @@ impl<'a> ContextCommand<'a> {
/// Run command and obtain exit status
pub fn status(&mut self) -> io::Result<std::process::ExitStatus> {
self.context.driver().as_ref().unwrap().run(
&self.program,
&self.args,
&self.env,
self.cwd.as_deref(),
)
let program = self.program.clone();
self.context
.driver()
.as_ref()
.unwrap()
.run(&self.program, &self.args, &self.env, self.cwd.as_deref())
.map_err(|e| contextualize_spawn_error(&program, e))
}
/// Run command, capturing output
pub fn output(&mut self) -> io::Result<std::process::Output> {
self.context.driver().as_ref().unwrap().run_output(
&self.program,
&self.args,
&self.env,
self.cwd.as_deref(),
let program = self.program.clone();
self.context
.driver()
.as_ref()
.unwrap()
.run_output(&self.program, &self.args, &self.env, self.cwd.as_deref())
.map_err(|e| contextualize_spawn_error(&program, e))
}
}
/// Wrap an I/O error from launching a command with a more helpful message.
///
/// In particular, a `NotFound` error (e.g. "No such file or directory") is almost always
/// caused by the requested program not being installed or not on `PATH`; we make that
/// explicit instead of leaking the raw OS error.
fn contextualize_spawn_error(program: &str, e: io::Error) -> io::Error {
if e.kind() == io::ErrorKind::NotFound {
io::Error::new(
e.kind(),
format!(
"Could not run '{program}': {e}. \
The program does not seem to be installed or is not on PATH; \
install the corresponding package and retry.",
),
)
} else {
io::Error::new(e.kind(), format!("Could not run '{program}': {e}"))
}
}
+9
View File
@@ -2,6 +2,7 @@
/// Context driver: Does nothing
use super::api::ContextDriver;
use std::io;
use std::os::unix::fs::symlink;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::SystemTime;
@@ -112,6 +113,14 @@ impl ContextDriver for LocalDriver {
}
fn copy_dir_recursive(src: &Path, dest: &Path) -> io::Result<()> {
// Reproduce symlinks as symlinks rather than following them, so that
// dangling/absolute symlinks do not abort the copy.
if std::fs::symlink_metadata(src)?.file_type().is_symlink() {
let target = std::fs::read_link(src)?;
let _ = std::fs::remove_file(dest);
return symlink(&target, dest);
}
if src.is_dir() {
std::fs::create_dir_all(dest)?;
for entry in std::fs::read_dir(src)? {
+33
View File
@@ -147,4 +147,37 @@ mod tests {
"subcontent"
);
}
/// Copying a tree containing a dangling symlink must reproduce the link
/// itself rather than failing to follow it (which previously surfaced as
/// a bare "No such file or directory (os error 2)").
#[test]
fn test_context_copy_preserves_dangling_symlink() {
use std::os::unix::fs::symlink;
let temp_dir = tempfile::tempdir().unwrap();
let ctx = Context::new(ContextConfig::Local);
let src_dir = temp_dir.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
// A regular file alongside the symlink, to ensure normal copies still work.
std::fs::write(src_dir.join("real.txt"), "data").unwrap();
// A dangling symlink pointing to a non-existent target.
symlink("/nonexistent/target", src_dir.join("dangling")).unwrap();
let dest_dir = temp_dir.path().join("dest");
ctx.copy_path(&src_dir, &dest_dir).unwrap();
// The regular file was copied.
assert_eq!(
ctx.read_file(&dest_dir.join("real.txt")).unwrap(),
"data"
);
// The symlink was reproduced as a symlink (not followed).
let meta = std::fs::symlink_metadata(dest_dir.join("dangling")).unwrap();
assert!(meta.file_type().is_symlink());
assert_eq!(
std::fs::read_link(dest_dir.join("dangling")).unwrap(),
std::path::Path::new("/nonexistent/target")
);
}
}
+74 -7
View File
@@ -1,6 +1,8 @@
use super::api::{Context, ContextCommand, ContextDriver};
use log::debug;
use std::fs;
use std::io;
use std::os::unix::fs::symlink;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -9,23 +11,88 @@ pub struct UnshareDriver {
pub parent: Option<Arc<super::api::Context>>,
}
/// Recursively copy a directory and all its contents
/// Recursively copy a directory and all its contents.
///
/// Symlinks are copied as symlinks (preserving the link target rather than
/// following it), so that dangling links do not abort the copy.
fn copy_dir_recursive(src: &Path, dest: &Path) -> io::Result<()> {
// Create the destination directory
std::fs::create_dir_all(dest)?;
std::fs::create_dir_all(dest).map_err(|e| {
io::Error::new(
e.kind(),
format!("Failed to create directory '{}': {}", dest.display(), e),
)
})?;
// Iterate through the source directory
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let read = std::fs::read_dir(src).map_err(|e| {
io::Error::new(
e.kind(),
format!("Failed to read directory '{}': {}", src.display(), e),
)
})?;
for entry in read {
let entry = entry.map_err(|e| {
io::Error::new(
e.kind(),
format!("Failed to read entry in '{}': {}", src.display(), e),
)
})?;
let src_path = entry.path();
let dest_path = dest.join(entry.file_name());
if src_path.is_dir() {
// Handle symlinks explicitly: reproduce the link itself instead of
// following it. This avoids failing on dangling/absolute symlinks.
let metadata = match std::fs::symlink_metadata(&src_path) {
Ok(m) => m,
Err(e) => {
return Err(io::Error::new(
e.kind(),
format!(
"Failed to read metadata of '{}': {}",
src_path.display(),
e
),
))
}
};
if metadata.file_type().is_symlink() {
let target = std::fs::read_link(&src_path).map_err(|e| {
io::Error::new(
e.kind(),
format!("Failed to read symlink '{}': {}", src_path.display(), e),
)
})?;
// Remove an existing destination entry (e.g. from a previous attempt)
let _ = fs::remove_file(&dest_path);
symlink(&target, &dest_path).map_err(|e| {
io::Error::new(
e.kind(),
format!(
"Failed to create symlink '{}' -> '{}': {}",
dest_path.display(),
target.display(),
e
),
)
})?;
} else if src_path.is_dir() {
// Recursively copy subdirectories
copy_dir_recursive(&src_path, &dest_path)?;
} else {
// Copy files
std::fs::copy(&src_path, &dest_path)?;
// Copy regular files
std::fs::copy(&src_path, &dest_path).map_err(|e| {
io::Error::new(
e.kind(),
format!(
"Failed to copy '{}' to '{}': {}",
src_path.display(),
dest_path.display(),
e
),
)
})?;
}
}