From 53d31a88e1f069817c9dcba90fdec5f6f54f27da Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:57:57 -0400 Subject: [PATCH] Support Windows symlink downloads --- src/bin/dosh-client.rs | 96 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 79 insertions(+), 17 deletions(-) diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs index 26b5041..3f4d0f4 100644 --- a/src/bin/dosh-client.rs +++ b/src/bin/dosh-client.rs @@ -3654,24 +3654,67 @@ fn download_symlink( #[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()))?; - } + prepare_local_symlink_destination(path, overwrite)?; std::os::unix::fs::symlink(target, path) .with_context(|| format!("symlink {} -> {}", path.display(), target)) } -#[cfg(not(unix))] +#[cfg(windows)] +fn create_local_symlink(path: &Path, target: &str, overwrite: bool) -> Result<()> { + prepare_local_symlink_destination(path, overwrite)?; + if local_symlink_target_is_dir(path, target) { + std::os::windows::fs::symlink_dir(target, path) + .with_context(|| format!("directory symlink {} -> {}", path.display(), target)) + } else { + std::os::windows::fs::symlink_file(target, path) + .with_context(|| format!("file symlink {} -> {}", path.display(), target)) + } +} + +#[cfg(not(any(unix, windows)))] fn create_local_symlink(_path: &Path, _target: &str, _overwrite: bool) -> Result<()> { bail!("symlink creation is not supported on this client platform") } +fn prepare_local_symlink_destination(path: &Path, overwrite: bool) -> Result<()> { + let Ok(metadata) = fs::symlink_metadata(path) else { + return Ok(()); + }; + anyhow::ensure!(overwrite, "destination exists: {}", path.display()); + anyhow::ensure!( + !metadata.is_dir(), + "destination is a directory: {}", + path.display() + ); + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(file_err) if metadata.file_type().is_symlink() => { + fs::remove_dir(path).map_err(|_| file_err)?; + Ok(()) + } + Err(err) => Err(err), + } + .with_context(|| format!("remove {}", path.display())) +} + +#[cfg_attr(not(any(test, windows)), allow(dead_code))] +fn local_symlink_target_is_dir(link_path: &Path, target: &str) -> bool { + if target.ends_with('/') || target.ends_with('\\') { + return true; + } + let target_path = Path::new(target); + let resolved = if target_path.is_absolute() { + target_path.to_path_buf() + } else { + link_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")) + .join(target_path) + }; + fs::metadata(resolved).is_ok_and(|metadata| metadata.is_dir()) +} + fn hash_prefix(file: &mut fs::File, bytes: u64, hasher: &mut Sha256) -> Result<()> { file.seek(std::io::SeekFrom::Start(0))?; let mut remaining = bytes; @@ -10034,13 +10077,13 @@ mod tests { clear_cached_credentials, effective_update_artifact_tag, ensure_tui_safe_status_overlay, expand_ssh_path_tokens, input_contains_focus_in, input_matches_escape, is_local_status_target, is_resume_response_for_client, latest_release_download_url, - load_first_native_identity_with_prompt, native_proxy_udp_warning, - newest_client_trace_path_from, parse_dynamic_forward, parse_escape_key, - parse_local_forward, parse_remote_forward, parse_single_remote_path, parse_ssh_config, - parse_trace_line, parse_trace_options, parse_trace_report_options, parse_trace_summary, - parse_update_options, post_submit_hold_duration, queue_or_send_stream_data, - queue_pending_user_input, queue_stale_pending_user_input, raw_contains_host_table, - recv_response_until, refresh_live_addr, release_tag_download_url, + load_first_native_identity_with_prompt, local_symlink_target_is_dir, + native_proxy_udp_warning, newest_client_trace_path_from, parse_dynamic_forward, + parse_escape_key, parse_local_forward, parse_remote_forward, parse_single_remote_path, + parse_ssh_config, parse_trace_line, parse_trace_options, parse_trace_report_options, + parse_trace_summary, parse_update_options, post_submit_hold_duration, + queue_or_send_stream_data, queue_pending_user_input, queue_stale_pending_user_input, + raw_contains_host_table, recv_response_until, refresh_live_addr, release_tag_download_url, release_tag_from_effective_url, release_version_from_tag, render_frame_bytes, render_status_clear, render_status_overlay, requested_env, resolve_forward_agent_endpoint, resolved_startup_command, retire_stream_state, retransmit_stream_closes, @@ -10124,6 +10167,25 @@ mod tests { ); } + #[test] + fn symlink_target_dir_inference_uses_existing_relative_target() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join("target-dir")).unwrap(); + let link = dir.path().join("link"); + + assert!(local_symlink_target_is_dir(&link, "target-dir")); + assert!(!local_symlink_target_is_dir(&link, "missing-file")); + } + + #[test] + fn symlink_target_dir_inference_honors_trailing_separator() { + let dir = tempfile::tempdir().unwrap(); + let link = dir.path().join("link"); + + assert!(local_symlink_target_is_dir(&link, "missing-dir/")); + assert!(local_symlink_target_is_dir(&link, "missing-dir\\")); + } + #[test] fn cleanup_stream_state_removes_all_per_stream_state_only_for_target() { let stream_id = 42;