From 2c5b9ab30daf3f25881a1d3b40f1523a3bbe2522 Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:07:13 -0400 Subject: [PATCH] Preserve symlinks in file copy --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/bin/dosh-client.rs | 88 +++++++++++++++++++++++++++++++++++++- src/bin/dosh-server.rs | 51 ++++++++++++++++++++++ src/file_transfer.rs | 11 +++++ tests/integration_smoke.rs | 10 +++++ 6 files changed, 160 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2a2e825..c499174 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -436,7 +436,7 @@ dependencies = [ [[package]] name = "dosh" -version = "1.0.0-rc26" +version = "1.0.0-rc27" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index f01a788..4d4207e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dosh" -version = "1.0.0-rc26" +version = "1.0.0-rc27" edition = "2024" license = "MIT" diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs index 539dd8b..259d646 100644 --- a/src/bin/dosh-client.rs +++ b/src/bin/dosh-client.rs @@ -1866,6 +1866,30 @@ impl FileServiceClient { other => bail!("unexpected mkdir response: {other:?}"), } } + + fn readlink(&mut self, path: &str) -> Result { + self.send(FileRequest::Readlink { + path: path.to_string(), + })?; + match self.recv()? { + FileResponse::LinkTarget { target } => Ok(target), + FileResponse::Error { message } => Err(anyhow!(message)), + other => bail!("unexpected readlink response: {other:?}"), + } + } + + fn symlink(&mut self, path: &str, target: &str, overwrite: bool) -> Result<()> { + self.send(FileRequest::Symlink { + path: path.to_string(), + target: target.to_string(), + overwrite, + })?; + match self.recv()? { + FileResponse::Ok => Ok(()), + FileResponse::Error { message } => Err(anyhow!(message)), + other => bail!("unexpected symlink response: {other:?}"), + } + } } fn run_cp_command(_config: &dosh::config::ClientConfig, args: &Args) -> Result<()> { @@ -2197,6 +2221,18 @@ fn upload_path( let metadata = fs::symlink_metadata(local).with_context(|| format!("stat {}", local.display()))?; let remote = upload_destination(client, local, remote, metadata.is_dir())?; + if metadata.file_type().is_symlink() { + let target = + fs::read_link(local).with_context(|| format!("readlink {}", local.display()))?; + let target = target + .to_str() + .ok_or_else(|| anyhow!("symlink target is not valid UTF-8: {}", local.display()))?; + client.symlink(&remote, target, opts.overwrite)?; + if opts.progress { + eprintln!("linked {} -> {}", remote, target); + } + return Ok(()); + } if metadata.is_dir() { anyhow::ensure!( opts.recursive, @@ -2343,8 +2379,9 @@ fn download_path( Ok(()) } FileKind::File => download_file(client, remote, &destination, &meta, opts), + FileKind::Symlink => download_symlink(client, remote, &destination, opts), _ => Err(anyhow!( - "remote path is not a regular file or directory: {remote}" + "remote path is not a regular file, directory, or symlink: {remote}" )), } } @@ -2377,8 +2414,16 @@ fn copy_remote_path( Ok(()) } FileKind::File => copy_remote_file(src_client, dst_client, src, &dst, &meta, opts), + FileKind::Symlink => { + let target = src_client.readlink(src)?; + dst_client.symlink(&dst, &target, opts.overwrite)?; + if opts.progress { + eprintln!("linked {dst} -> {target}"); + } + Ok(()) + } _ => Err(anyhow!( - "remote path is not a regular file or directory: {src}" + "remote path is not a regular file, directory, or symlink: {src}" )), } } @@ -2609,6 +2654,45 @@ fn download_file( } } +fn download_symlink( + client: &mut FileServiceClient, + remote: &str, + local: &Path, + opts: &CpOptions, +) -> Result<()> { + if let Some(parent) = local.parent() + && !parent.as_os_str().is_empty() + { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + let target = client.readlink(remote)?; + create_local_symlink(local, &target, opts.overwrite)?; + if opts.progress { + eprintln!("linked {} -> {}", local.display(), target); + } + Ok(()) +} + +#[cfg(unix)] +fn create_local_symlink(path: &Path, target: &str, overwrite: bool) -> Result<()> { + if let Ok(metadata) = fs::symlink_metadata(path) { + anyhow::ensure!(overwrite, "destination exists: {}", path.display()); + anyhow::ensure!( + !metadata.is_dir(), + "destination is a directory: {}", + path.display() + ); + fs::remove_file(path).with_context(|| format!("remove {}", path.display()))?; + } + std::os::unix::fs::symlink(target, path) + .with_context(|| format!("symlink {} -> {}", path.display(), target)) +} + +#[cfg(not(unix))] +fn create_local_symlink(_path: &Path, _target: &str, _overwrite: bool) -> Result<()> { + bail!("symlink creation is not supported on this client platform") +} + fn hash_prefix(file: &mut fs::File, bytes: u64, hasher: &mut Sha256) -> Result<()> { file.seek(std::io::SeekFrom::Start(0))?; let mut remaining = bytes; diff --git a/src/bin/dosh-server.rs b/src/bin/dosh-server.rs index cadd561..8ff159c 100644 --- a/src/bin/dosh-server.rs +++ b/src/bin/dosh-server.rs @@ -2681,6 +2681,46 @@ async fn handle_file_request( send_file_response_to_client(state, socket, client_id, stream_id, FileResponse::Ok) .await } + FileRequest::Readlink { path } => { + let path = clean_remote_path(&path, home)?; + let target = + fs::read_link(&path).with_context(|| format!("readlink {}", path.display()))?; + let target = target + .to_str() + .ok_or_else(|| anyhow!("symlink target is not valid UTF-8"))? + .to_string(); + send_file_response_to_client( + state, + socket, + client_id, + stream_id, + FileResponse::LinkTarget { target }, + ) + .await + } + FileRequest::Symlink { + path, + target, + overwrite, + } => { + let path = clean_remote_path(&path, home)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("create {}", parent.display()))?; + } + if let Ok(metadata) = fs::symlink_metadata(&path) { + anyhow::ensure!(overwrite, "destination exists: {}", path.display()); + anyhow::ensure!( + !metadata.is_dir(), + "destination is a directory: {}", + path.display() + ); + fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?; + } + create_symlink(&target, &path)?; + send_file_response_to_client(state, socket, client_id, stream_id, FileResponse::Ok) + .await + } FileRequest::Remove { path, recursive } => { let path = clean_remote_path(&path, home)?; let metadata = @@ -3065,6 +3105,17 @@ fn set_mode(path: &Path, mode: u32) -> Result<()> { fs::set_permissions(path, permissions).with_context(|| format!("chmod {}", path.display())) } +#[cfg(unix)] +fn create_symlink(target: &str, path: &Path) -> Result<()> { + std::os::unix::fs::symlink(target, path) + .with_context(|| format!("symlink {} -> {}", path.display(), target)) +} + +#[cfg(not(unix))] +fn create_symlink(_target: &str, _path: &Path) -> Result<()> { + bail!("symlink creation is not supported on this server platform") +} + fn upload_temp_path(final_path: &Path, stream_id: u64) -> PathBuf { let name = final_path .file_name() diff --git a/src/file_transfer.rs b/src/file_transfer.rs index a2433b0..53b8020 100644 --- a/src/file_transfer.rs +++ b/src/file_transfer.rs @@ -18,6 +18,14 @@ pub enum FileRequest { path: String, mode: Option, }, + Readlink { + path: String, + }, + Symlink { + path: String, + target: String, + overwrite: bool, + }, Remove { path: String, recursive: bool, @@ -56,6 +64,9 @@ pub enum FileResponse { List { entries: Vec, }, + LinkTarget { + target: String, + }, Start { meta: FileMeta, offset: u64, diff --git a/tests/integration_smoke.rs b/tests/integration_smoke.rs index 5a667ae..9eb34d3 100644 --- a/tests/integration_smoke.rs +++ b/tests/integration_smoke.rs @@ -1003,6 +1003,8 @@ fn native_file_copy_recursive_round_trip() { fs::create_dir_all(src.join("nested")).unwrap(); fs::write(src.join("root.txt"), b"root file\n").unwrap(); fs::write(src.join("nested/child.txt"), b"child file\n").unwrap(); + std::os::unix::fs::symlink("root.txt", src.join("root-link")).unwrap(); + std::os::unix::fs::symlink("nested/child.txt", src.join("child-link")).unwrap(); let client_bin = env!("CARGO_BIN_EXE_dosh-client"); let upload = Command::new(client_bin) @@ -1108,6 +1110,14 @@ fn native_file_copy_recursive_round_trip() { fs::read_to_string(downloaded.join("nested/child.txt")).unwrap(), "child file\n" ); + assert_eq!( + fs::read_link(downloaded.join("root-link")).unwrap(), + PathBuf::from("root.txt") + ); + assert_eq!( + fs::read_link(downloaded.join("child-link")).unwrap(), + PathBuf::from("nested/child.txt") + ); let remove = Command::new(client_bin) .arg("--dosh-host")