Compare commits

..

4 Commits

Author SHA1 Message Date
DuProcess 1dcb33550e Expand SSH identity path tokens
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-07-11 18:18:43 -04:00
DuProcess 2c5b9ab30d Preserve symlinks in file copy
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-07-11 18:07:13 -04:00
DuProcess bfe75dfbd2 Harden file resume prefix validation
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-07-11 17:59:41 -04:00
DuProcess 4ea05fc14f Clarify proxied SSH UDP requirements
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-07-11 17:44:43 -04:00
7 changed files with 689 additions and 117 deletions
Generated
+1 -1
View File
@@ -436,7 +436,7 @@ dependencies = [
[[package]] [[package]]
name = "dosh" name = "dosh"
version = "1.0.0-rc24" version = "1.0.0-rc28"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "dosh" name = "dosh"
version = "1.0.0-rc24" version = "1.0.0-rc28"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
+4
View File
@@ -108,6 +108,10 @@ dosh_host = "prod.example.com"
port = 50000 port = 50000
``` ```
`ProxyJump` and `ProxyCommand` can be used for SSH setup, but the Dosh UDP
endpoint still has to be reachable directly from the client. Set `dosh_host` to
the reachable UDP hostname or IP when it differs from the SSH alias target.
## Rust Library ## Rust Library
Dosh exposes a Rust transport for encrypted, reconnecting application streams. Dosh exposes a Rust transport for encrypted, reconnecting application streams.
+444 -64
View File
@@ -960,6 +960,14 @@ async fn run_doctor_for_host(
} }
}; };
println!("[info] native endpoint: {udp_host}:{dosh_port}"); println!("[info] native endpoint: {udp_host}:{dosh_port}");
if let Some(warning) = native_proxy_udp_warning(
&parsed_ssh_config,
requested_udp_host.as_deref(),
&udp_host,
dosh_port,
) {
println!("[warn] native endpoint: {warning}");
}
match resolve_addr(&udp_host, dosh_port) { match resolve_addr(&udp_host, dosh_port) {
Ok(addr) => println!("[ok] udp resolve: {addr}"), Ok(addr) => println!("[ok] udp resolve: {addr}"),
Err(err) => println!("[fail] udp resolve: {err:#}"), Err(err) => println!("[fail] udp resolve: {err:#}"),
@@ -1858,6 +1866,30 @@ impl FileServiceClient {
other => bail!("unexpected mkdir response: {other:?}"), other => bail!("unexpected mkdir response: {other:?}"),
} }
} }
fn readlink(&mut self, path: &str) -> Result<String> {
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<()> { fn run_cp_command(_config: &dosh::config::ClientConfig, args: &Args) -> Result<()> {
@@ -2189,6 +2221,18 @@ fn upload_path(
let metadata = let metadata =
fs::symlink_metadata(local).with_context(|| format!("stat {}", local.display()))?; fs::symlink_metadata(local).with_context(|| format!("stat {}", local.display()))?;
let remote = upload_destination(client, local, remote, metadata.is_dir())?; 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() { if metadata.is_dir() {
anyhow::ensure!( anyhow::ensure!(
opts.recursive, opts.recursive,
@@ -2258,8 +2302,11 @@ fn upload_file(
overwrite: opts.overwrite, overwrite: opts.overwrite,
resume: opts.resume, resume: opts.resume,
})?; })?;
let offset = match client.recv()? { let (offset, prefix_sha256) = match client.recv()? {
FileResponse::Resume { offset } => offset.min(size), FileResponse::Resume {
offset,
prefix_sha256,
} => (offset.min(size), prefix_sha256),
FileResponse::Error { message } => return Err(anyhow!(message)), FileResponse::Error { message } => return Err(anyhow!(message)),
other => bail!("unexpected upload start response: {other:?}"), other => bail!("unexpected upload start response: {other:?}"),
}; };
@@ -2267,6 +2314,7 @@ fn upload_file(
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
if offset > 0 { if offset > 0 {
hash_prefix(&mut file, offset, &mut hasher)?; hash_prefix(&mut file, offset, &mut hasher)?;
ensure_resume_prefix_matches(prefix_sha256, Some(digest_prefix(&hasher)), offset, remote)?;
} }
file.seek(std::io::SeekFrom::Start(offset))?; file.seek(std::io::SeekFrom::Start(offset))?;
let mut sent = offset; let mut sent = offset;
@@ -2331,8 +2379,9 @@ fn download_path(
Ok(()) Ok(())
} }
FileKind::File => download_file(client, remote, &destination, &meta, opts), FileKind::File => download_file(client, remote, &destination, &meta, opts),
FileKind::Symlink => download_symlink(client, remote, &destination, opts),
_ => Err(anyhow!( _ => Err(anyhow!(
"remote path is not a regular file or directory: {remote}" "remote path is not a regular file, directory, or symlink: {remote}"
)), )),
} }
} }
@@ -2365,8 +2414,16 @@ fn copy_remote_path(
Ok(()) Ok(())
} }
FileKind::File => copy_remote_file(src_client, dst_client, src, &dst, &meta, opts), 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!( _ => Err(anyhow!(
"remote path is not a regular file or directory: {src}" "remote path is not a regular file, directory, or symlink: {src}"
)), )),
} }
} }
@@ -2407,8 +2464,11 @@ fn copy_remote_file(
overwrite: opts.overwrite, overwrite: opts.overwrite,
resume: opts.resume, resume: opts.resume,
})?; })?;
let offset = match dst_client.recv()? { let (offset, dst_prefix_sha256) = match dst_client.recv()? {
FileResponse::Resume { offset } => offset.min(meta.len), FileResponse::Resume {
offset,
prefix_sha256,
} => (offset.min(meta.len), prefix_sha256),
FileResponse::Error { message } => return Err(anyhow!(message)), FileResponse::Error { message } => return Err(anyhow!(message)),
other => bail!("unexpected remote copy start response: {other:?}"), other => bail!("unexpected remote copy start response: {other:?}"),
}; };
@@ -2417,7 +2477,17 @@ fn copy_remote_file(
offset, offset,
})?; })?;
match src_client.recv()? { match src_client.recv()? {
FileResponse::Start { .. } => {} FileResponse::Start {
offset: start_offset,
prefix_sha256,
..
} => {
anyhow::ensure!(
start_offset == offset,
"remote copy source offset mismatch: got {start_offset}, expected {offset}"
);
ensure_matching_resume_prefixes(dst_prefix_sha256, prefix_sha256, offset, dst)?;
}
FileResponse::Error { message } => return Err(anyhow!(message)), FileResponse::Error { message } => return Err(anyhow!(message)),
other => bail!("unexpected remote copy source start response: {other:?}"), other => bail!("unexpected remote copy source start response: {other:?}"),
} }
@@ -2490,20 +2560,22 @@ fn download_file(
{ {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
} }
let offset = if opts.resume && local.exists() { let original_len = if local.exists() {
fs::metadata(local)?.len().min(meta.len) Some(fs::metadata(local)?.len())
} else {
None
};
let offset = if opts.resume {
original_len.unwrap_or(0).min(meta.len)
} else { } else {
0 0
}; };
let mut file = if offset > 0 { let mut file = if offset > 0 {
let file = fs::OpenOptions::new() fs::OpenOptions::new()
.read(true) .read(true)
.append(true) .append(true)
.open(local) .open(local)
.with_context(|| format!("open {}", local.display()))?; .with_context(|| format!("open {}", local.display()))?
file.set_len(offset)
.with_context(|| format!("truncate {}", local.display()))?;
file
} else { } else {
fs::OpenOptions::new() fs::OpenOptions::new()
.create(true) .create(true)
@@ -2513,16 +2585,29 @@ fn download_file(
.with_context(|| format!("create {}", local.display()))? .with_context(|| format!("create {}", local.display()))?
}; };
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
if offset > 0 { let local_prefix_sha256 = if offset > 0 {
let mut prefix = fs::File::open(local)?; let mut prefix = fs::File::open(local)?;
hash_prefix(&mut prefix, offset, &mut hasher)?; hash_prefix(&mut prefix, offset, &mut hasher)?;
} Some(digest_prefix(&hasher))
} else {
None
};
client.send(FileRequest::Get { client.send(FileRequest::Get {
path: remote.to_string(), path: remote.to_string(),
offset, offset,
})?; })?;
match client.recv()? { match client.recv()? {
FileResponse::Start { .. } => {} FileResponse::Start {
offset: start_offset,
prefix_sha256,
..
} => {
anyhow::ensure!(
start_offset == offset,
"download offset mismatch: got {start_offset}, expected {offset}"
);
ensure_resume_prefix_matches(prefix_sha256, local_prefix_sha256, offset, remote)?;
}
FileResponse::Error { message } => return Err(anyhow!(message)), FileResponse::Error { message } => return Err(anyhow!(message)),
other => bail!("unexpected download start response: {other:?}"), other => bail!("unexpected download start response: {other:?}"),
} }
@@ -2541,8 +2626,16 @@ fn download_file(
FileResponse::Done { sha256, bytes } => { FileResponse::Done { sha256, bytes } => {
file.flush()?; file.flush()?;
let digest: [u8; 32] = hasher.finalize().into(); let digest: [u8; 32] = hasher.finalize().into();
if (sha256 != digest || bytes != meta.len)
&& opts.resume
&& let Some(original_len) = original_len
{
let _ = file.set_len(original_len);
}
anyhow::ensure!(sha256 == digest, "download checksum mismatch for {remote}"); anyhow::ensure!(sha256 == digest, "download checksum mismatch for {remote}");
anyhow::ensure!(bytes == meta.len, "download size mismatch for {remote}"); anyhow::ensure!(bytes == meta.len, "download size mismatch for {remote}");
file.set_len(meta.len)
.with_context(|| format!("truncate {}", local.display()))?;
set_local_mode(local, meta.mode, opts.preserve)?; set_local_mode(local, meta.mode, opts.preserve)?;
set_local_mtime(local, meta.modified_secs, opts.preserve)?; set_local_mtime(local, meta.modified_secs, opts.preserve)?;
if opts.progress { if opts.progress {
@@ -2561,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<()> { fn hash_prefix(file: &mut fs::File, bytes: u64, hasher: &mut Sha256) -> Result<()> {
file.seek(std::io::SeekFrom::Start(0))?; file.seek(std::io::SeekFrom::Start(0))?;
let mut remaining = bytes; let mut remaining = bytes;
@@ -2577,6 +2709,46 @@ fn hash_prefix(file: &mut fs::File, bytes: u64, hasher: &mut Sha256) -> Result<(
Ok(()) Ok(())
} }
fn digest_prefix(hasher: &Sha256) -> [u8; 32] {
hasher.clone().finalize().into()
}
fn ensure_resume_prefix_matches(
remote_prefix_sha256: Option<[u8; 32]>,
local_prefix_sha256: Option<[u8; 32]>,
offset: u64,
path: &str,
) -> Result<()> {
if offset == 0 {
return Ok(());
}
let Some(remote_prefix_sha256) = remote_prefix_sha256 else {
bail!("resume prefix digest missing for {path}");
};
let Some(local_prefix_sha256) = local_prefix_sha256 else {
bail!("local resume prefix digest missing for {path}");
};
anyhow::ensure!(
remote_prefix_sha256 == local_prefix_sha256,
"resume prefix mismatch for {path}; retry without --resume or remove the partial destination"
);
Ok(())
}
fn ensure_matching_resume_prefixes(
destination_prefix_sha256: Option<[u8; 32]>,
source_prefix_sha256: Option<[u8; 32]>,
offset: u64,
path: &str,
) -> Result<()> {
ensure_resume_prefix_matches(
source_prefix_sha256,
destination_prefix_sha256,
offset,
path,
)
}
fn path_ends_separator(path: &Path) -> bool { fn path_ends_separator(path: &Path) -> bool {
let raw = path.as_os_str().to_string_lossy(); let raw = path.as_os_str().to_string_lossy();
raw.ends_with('/') || raw.ends_with('\\') raw.ends_with('/') || raw.ends_with('\\')
@@ -3532,6 +3704,25 @@ fn selected_udp_host(
} }
} }
fn ssh_config_uses_proxy(ssh_config: &SshConfig) -> bool {
ssh_config.proxy_jump.is_some() || ssh_config.proxy_command.is_some()
}
fn native_proxy_udp_warning(
ssh_config: &SshConfig,
requested_udp_host: Option<&str>,
udp_host: &str,
dosh_port: u16,
) -> Option<String> {
if ssh_config_uses_proxy(ssh_config) && requested_udp_host.is_none() {
Some(format!(
"SSH uses ProxyJump/ProxyCommand, but Dosh UDP still needs a directly reachable host; set dosh_host if {udp_host}:{dosh_port} is not reachable from this client"
))
} else {
None
}
}
fn ssh_username(server: &str) -> Option<String> { fn ssh_username(server: &str) -> Option<String> {
let (user, _) = server.rsplit_once('@')?; let (user, _) = server.rsplit_once('@')?;
if user.is_empty() { if user.is_empty() {
@@ -3841,7 +4032,7 @@ async fn try_native_auth(
let auth = sign_native_user_auth( let auth = sign_native_user_auth(
config, config,
cli_identity, cli_identity,
ssh_config, &NativeIdentityContext::new(server, ssh_port, ssh_config),
&hello, &hello,
&server_hello.hello, &server_hello.hello,
requested_forwardings, requested_forwardings,
@@ -3992,7 +4183,7 @@ async fn try_native_auth_check(
let auth = sign_native_user_auth( let auth = sign_native_user_auth(
config, config,
cli_identity, cli_identity,
ssh_config, &NativeIdentityContext::new(server, ssh_port, ssh_config),
&hello, &hello,
&server_hello.hello, &server_hello.hello,
Vec::new(), Vec::new(),
@@ -4027,13 +4218,14 @@ async fn try_native_auth_check(
fn sign_native_user_auth( fn sign_native_user_auth(
config: &dosh::config::ClientConfig, config: &dosh::config::ClientConfig,
cli_identity: Option<&Path>, cli_identity: Option<&Path>,
ssh_config: &SshConfig, identity_context: &NativeIdentityContext<'_>,
hello: &dosh::native::NativeClientHello, hello: &dosh::native::NativeClientHello,
server_hello: &dosh::native::NativeServerHello, server_hello: &dosh::native::NativeServerHello,
requested_forwardings: Vec<ForwardingRequest>, requested_forwardings: Vec<ForwardingRequest>,
allow_passphrase_prompt: bool, allow_passphrase_prompt: bool,
) -> Result<dosh::native::NativeUserAuth> { ) -> Result<dosh::native::NativeUserAuth> {
let mut errors = Vec::new(); let mut errors = Vec::new();
let ssh_config = identity_context.ssh_config;
if config.use_ssh_agent && !ssh_config.identities_only { if config.use_ssh_agent && !ssh_config.identities_only {
match ssh_agent::sign_user_auth_with_agent( match ssh_agent::sign_user_auth_with_agent(
hello, hello,
@@ -4048,9 +4240,9 @@ fn sign_native_user_auth(
} }
let identity = if allow_passphrase_prompt { let identity = if allow_passphrase_prompt {
load_first_native_identity(config, cli_identity, ssh_config) load_first_native_identity(config, cli_identity, identity_context)
} else { } else {
load_first_native_identity_noninteractive(config, cli_identity, ssh_config) load_first_native_identity_noninteractive(config, cli_identity, identity_context)
}; };
match identity { match identity {
Ok(identity) => { Ok(identity) => {
@@ -4069,12 +4261,12 @@ fn sign_native_user_auth(
fn load_first_native_identity( fn load_first_native_identity(
config: &dosh::config::ClientConfig, config: &dosh::config::ClientConfig,
cli_identity: Option<&Path>, cli_identity: Option<&Path>,
ssh_config: &SshConfig, identity_context: &NativeIdentityContext<'_>,
) -> Result<ssh_key::PrivateKey> { ) -> Result<ssh_key::PrivateKey> {
load_first_native_identity_with_prompt( load_first_native_identity_with_prompt(
config, config,
cli_identity, cli_identity,
ssh_config, identity_context,
prompt_identity_passphrase, prompt_identity_passphrase,
) )
} }
@@ -4082,9 +4274,9 @@ fn load_first_native_identity(
fn load_first_native_identity_noninteractive( fn load_first_native_identity_noninteractive(
config: &dosh::config::ClientConfig, config: &dosh::config::ClientConfig,
cli_identity: Option<&Path>, cli_identity: Option<&Path>,
ssh_config: &SshConfig, identity_context: &NativeIdentityContext<'_>,
) -> Result<ssh_key::PrivateKey> { ) -> Result<ssh_key::PrivateKey> {
load_first_native_identity_with_prompt(config, cli_identity, ssh_config, |path| { load_first_native_identity_with_prompt(config, cli_identity, identity_context, |path| {
Err(anyhow!( Err(anyhow!(
"{} is encrypted; unlock it in ssh-agent or use an unencrypted key for proxy-stdio", "{} is encrypted; unlock it in ssh-agent or use an unencrypted key for proxy-stdio",
path.display() path.display()
@@ -4095,22 +4287,30 @@ fn load_first_native_identity_noninteractive(
fn load_first_native_identity_with_prompt<F>( fn load_first_native_identity_with_prompt<F>(
config: &dosh::config::ClientConfig, config: &dosh::config::ClientConfig,
cli_identity: Option<&Path>, cli_identity: Option<&Path>,
ssh_config: &SshConfig, identity_context: &NativeIdentityContext<'_>,
mut prompt: F, mut prompt: F,
) -> Result<ssh_key::PrivateKey> ) -> Result<ssh_key::PrivateKey>
where where
F: FnMut(&Path) -> Result<String>, F: FnMut(&Path) -> Result<String>,
{ {
let ssh_config = identity_context.ssh_config;
let token_context = &identity_context.path_tokens;
let mut paths = Vec::new(); let mut paths = Vec::new();
if let Some(path) = cli_identity { if let Some(path) = cli_identity {
push_identity_path(&mut paths, path.to_path_buf()); push_identity_path(&mut paths, path.to_path_buf());
} }
for path in &ssh_config.identity_files { for path in &ssh_config.identity_files {
push_identity_path(&mut paths, expand_tilde(path)); push_identity_path(
&mut paths,
expand_tilde(&expand_ssh_path_tokens(path, token_context)),
);
} }
if !ssh_config.identities_only { if !ssh_config.identities_only {
for path in &config.identity_files { for path in &config.identity_files {
push_identity_path(&mut paths, expand_tilde(path)); push_identity_path(
&mut paths,
expand_tilde(&expand_ssh_path_tokens(path, token_context)),
);
} }
} }
@@ -4148,6 +4348,98 @@ fn push_identity_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
} }
} }
#[derive(Debug, Clone, PartialEq, Eq)]
struct SshPathTokenContext {
original_host: String,
hostname: String,
port: u16,
remote_user: String,
local_user: String,
home_dir: Option<String>,
}
struct NativeIdentityContext<'a> {
ssh_config: &'a SshConfig,
path_tokens: SshPathTokenContext,
}
impl<'a> NativeIdentityContext<'a> {
fn new(server: &str, ssh_port: Option<u16>, ssh_config: &'a SshConfig) -> Self {
Self {
ssh_config,
path_tokens: ssh_path_token_context(server, ssh_port, ssh_config),
}
}
}
fn ssh_path_token_context(
server: &str,
ssh_port: Option<u16>,
ssh_config: &SshConfig,
) -> SshPathTokenContext {
let original_host = ssh_destination_host(server);
let hostname = ssh_config
.hostname
.clone()
.unwrap_or_else(|| original_host.clone());
let port = ssh_config.port.or(ssh_port).unwrap_or(22);
let remote_user = ssh_username(server)
.or(ssh_config.user.clone())
.unwrap_or_else(local_username);
let local_user = local_username();
let home_dir = dirs::home_dir().map(|path| path.to_string_lossy().to_string());
SshPathTokenContext {
original_host,
hostname,
port,
remote_user,
local_user,
home_dir,
}
}
fn local_username() -> String {
std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "unknown".to_string())
}
fn expand_ssh_path_tokens(path: &str, context: &SshPathTokenContext) -> String {
let mut out = String::with_capacity(path.len());
let mut chars = path.chars();
while let Some(ch) = chars.next() {
if ch != '%' {
out.push(ch);
continue;
}
let Some(token) = chars.next() else {
out.push('%');
break;
};
match token {
'%' => out.push('%'),
'd' => {
if let Some(home_dir) = &context.home_dir {
out.push_str(home_dir);
} else {
out.push('%');
out.push(token);
}
}
'h' => out.push_str(&context.hostname),
'n' => out.push_str(&context.original_host),
'p' => out.push_str(&context.port.to_string()),
'r' => out.push_str(&context.remote_user),
'u' => out.push_str(&context.local_user),
other => {
out.push('%');
out.push(other);
}
}
}
out
}
fn prompt_identity_passphrase(path: &Path) -> Result<String> { fn prompt_identity_passphrase(path: &Path) -> Result<String> {
rpassword::prompt_password(format!("Enter passphrase for {}: ", path.display())) rpassword::prompt_password(format!("Enter passphrase for {}: ", path.display()))
.with_context(|| format!("read passphrase for {}", path.display())) .with_context(|| format!("read passphrase for {}", path.display()))
@@ -7750,22 +8042,23 @@ mod tests {
use super::{ use super::{
ALT_SCREEN_IDLE_REPAINT_AFTER, CachedCredential, DisconnectStatus, DynamicForward, ALT_SCREEN_IDLE_REPAINT_AFTER, CachedCredential, DisconnectStatus, DynamicForward,
FRAME_GAP_RESYNC_AFTER_MS, FrameBuffer, LocalForward, MAX_PENDING_USER_INPUT_BYTES, FRAME_GAP_RESYNC_AFTER_MS, FrameBuffer, LocalForward, MAX_PENDING_USER_INPUT_BYTES,
POST_SUBMIT_ALL_INPUT_HOLD, PendingStreamOpen, PredictMode, Predictor, NativeIdentityContext, POST_SUBMIT_ALL_INPUT_HOLD, PendingStreamOpen, PredictMode,
RESTART_STATUS_SCRIPT, RemoteForward, STARTUP_INPUT_HOLD, SshConfig, StartupGateMode, Predictor, RESTART_STATUS_SCRIPT, RemoteForward, STARTUP_INPUT_HOLD, SshConfig,
StatusAction, UpdateOptions, UpdateRole, auth_allows, cache_key, cache_server_prefix, SshPathTokenContext, StartupGateMode, StatusAction, UpdateOptions, UpdateRole, auth_allows,
cleanup_stream_state, clear_cached_credentials, ensure_tui_safe_status_overlay, cache_key, cache_server_prefix, cleanup_stream_state, clear_cached_credentials,
input_contains_focus_in, input_matches_escape, is_local_status_target, ensure_tui_safe_status_overlay, expand_ssh_path_tokens, input_contains_focus_in,
is_resume_response_for_client, latest_release_download_url, input_matches_escape, is_local_status_target, is_resume_response_for_client,
load_first_native_identity_with_prompt, parse_dynamic_forward, parse_escape_key, latest_release_download_url, load_first_native_identity_with_prompt,
parse_local_forward, parse_remote_forward, parse_ssh_config, parse_update_options, native_proxy_udp_warning, parse_dynamic_forward, parse_escape_key, parse_local_forward,
post_submit_hold_duration, queue_pending_user_input, raw_contains_host_table, parse_remote_forward, parse_ssh_config, parse_update_options, post_submit_hold_duration,
recv_response_until, refresh_live_addr, release_tag_download_url, queue_pending_user_input, raw_contains_host_table, recv_response_until, refresh_live_addr,
release_tag_from_effective_url, release_version_from_tag, render_status_clear, release_tag_download_url, release_tag_from_effective_url, release_version_from_tag,
render_status_overlay, requested_env, resolved_startup_command, retire_stream_state, render_status_clear, render_status_overlay, requested_env, resolved_startup_command,
retransmit_stream_opens, rewrite_forward_command, selected_predict_mode, selected_udp_host, retire_stream_state, retransmit_stream_opens, rewrite_forward_command,
server_version_mismatch, should_flush_terminal_input_after_contact, selected_predict_mode, selected_udp_host, server_version_mismatch,
should_hold_during_startup_gate, should_hold_post_submit_input, should_flush_terminal_input_after_contact, should_hold_during_startup_gate,
should_repaint_idle_alternate_screen, split_after_command_submit, ssh_command_target, should_hold_post_submit_input, should_repaint_idle_alternate_screen,
split_after_command_submit, ssh_command_target, ssh_config_uses_proxy,
ssh_destination_host, ssh_username, ssh_with_user, startup_command, status_ssh_target, ssh_destination_host, ssh_username, ssh_with_user, startup_command, status_ssh_target,
strip_stale_mouse_reports, strip_terminal_focus_reports, terminal_private_mode_transition, strip_stale_mouse_reports, strip_terminal_focus_reports, terminal_private_mode_transition,
toml_bare_key_or_quoted, unix_update_script, update_installer_url, update_version_status, toml_bare_key_or_quoted, unix_update_script, update_installer_url, update_version_status,
@@ -8141,6 +8434,58 @@ mod tests {
assert!(selected_udp_host(Some("any"), "alias", &ssh).is_err()); assert!(selected_udp_host(Some("any"), "alias", &ssh).is_err());
} }
#[test]
fn ssh_config_proxy_detection_covers_jump_and_command() {
assert!(!ssh_config_uses_proxy(&SshConfig::default()));
assert!(ssh_config_uses_proxy(&SshConfig {
proxy_jump: Some("bastion".to_string()),
..SshConfig::default()
}));
assert!(ssh_config_uses_proxy(&SshConfig {
proxy_command: Some("ssh bastion -W %h:%p".to_string()),
..SshConfig::default()
}));
}
#[test]
fn ssh_identity_paths_expand_common_openssh_tokens() {
let context = SshPathTokenContext {
original_host: "prod".to_string(),
hostname: "10.0.0.5".to_string(),
port: 2222,
remote_user: "deploy".to_string(),
local_user: "palav".to_string(),
home_dir: Some("/home/palav".to_string()),
};
assert_eq!(
expand_ssh_path_tokens("~/.ssh/%r@%h:%p-%n-%u-%%-%x", &context),
"~/.ssh/deploy@10.0.0.5:2222-prod-palav-%-%x"
);
assert_eq!(
expand_ssh_path_tokens("%d/.ssh/%r_%h", &context),
"/home/palav/.ssh/deploy_10.0.0.5"
);
}
#[test]
fn native_proxy_udp_warning_requires_missing_explicit_dosh_host() {
let proxied = SshConfig {
proxy_jump: Some("bastion".to_string()),
..SshConfig::default()
};
let warning = native_proxy_udp_warning(&proxied, None, "internal.lan", 50000)
.expect("proxied SSH without dosh_host should warn");
assert!(warning.contains("ProxyJump/ProxyCommand"));
assert!(warning.contains("internal.lan:50000"));
assert!(
native_proxy_udp_warning(&proxied, Some("public.example.com"), "internal.lan", 50000)
.is_none()
);
assert!(
native_proxy_udp_warning(&SshConfig::default(), None, "internal.lan", 50000).is_none()
);
}
#[test] #[test]
fn parses_user_from_ssh_destination() { fn parses_user_from_ssh_destination() {
assert_eq!(ssh_username("alice@example.com"), Some("alice".to_string())); assert_eq!(ssh_username("alice@example.com"), Some("alice".to_string()));
@@ -8179,13 +8524,14 @@ mod tests {
write_encrypted_identity(&path, &signing, "let-me-in"); write_encrypted_identity(&path, &signing, "let-me-in");
let mut config = ClientConfig::default(); let mut config = ClientConfig::default();
config.identity_files.clear(); config.identity_files.clear();
let loaded = load_first_native_identity_with_prompt( let ssh_config = SshConfig::default();
&config, let identity_context =
Some(&path), NativeIdentityContext::new("alice@example.com", Some(2222), &ssh_config);
&SshConfig::default(), let loaded =
|_| Ok("let-me-in".to_string()), load_first_native_identity_with_prompt(&config, Some(&path), &identity_context, |_| {
) Ok("let-me-in".to_string())
.unwrap(); })
.unwrap();
assert_eq!( assert_eq!(
loaded.public_key().key_data().ed25519().unwrap().as_ref(), loaded.public_key().key_data().ed25519().unwrap().as_ref(),
@@ -8201,13 +8547,14 @@ mod tests {
write_encrypted_identity(&path, &signing, "correct"); write_encrypted_identity(&path, &signing, "correct");
let mut config = ClientConfig::default(); let mut config = ClientConfig::default();
config.identity_files.clear(); config.identity_files.clear();
let err = load_first_native_identity_with_prompt( let ssh_config = SshConfig::default();
&config, let identity_context =
Some(&path), NativeIdentityContext::new("alice@example.com", Some(2222), &ssh_config);
&SshConfig::default(), let err =
|_| Ok("wrong".to_string()), load_first_native_identity_with_prompt(&config, Some(&path), &identity_context, |_| {
) Ok("wrong".to_string())
.unwrap_err(); })
.unwrap_err();
assert!(err.to_string().contains("no usable native identity")); assert!(err.to_string().contains("no usable native identity"));
} }
@@ -8226,7 +8573,9 @@ mod tests {
identities_only: true, identities_only: true,
..SshConfig::default() ..SshConfig::default()
}; };
let err = load_first_native_identity_with_prompt(&config, None, &ssh_config, |_| { let identity_context =
NativeIdentityContext::new("alice@example.com", Some(2222), &ssh_config);
let err = load_first_native_identity_with_prompt(&config, None, &identity_context, |_| {
unreachable!("unencrypted key should not prompt") unreachable!("unencrypted key should not prompt")
}) })
.unwrap_err(); .unwrap_err();
@@ -8247,10 +8596,41 @@ mod tests {
identities_only: true, identities_only: true,
..SshConfig::default() ..SshConfig::default()
}; };
let loaded = load_first_native_identity_with_prompt(&config, None, &ssh_config, |_| { let identity_context =
unreachable!("unencrypted key should not prompt") NativeIdentityContext::new("alice@example.com", Some(2222), &ssh_config);
}) let loaded =
.unwrap(); load_first_native_identity_with_prompt(&config, None, &identity_context, |_| {
unreachable!("unencrypted key should not prompt")
})
.unwrap();
assert_eq!(
loaded.public_key().key_data().ed25519().unwrap().as_ref(),
VerifyingKey::from(&signing).as_bytes()
);
}
#[test]
fn ssh_config_identity_file_tokens_are_expanded_for_native_auth() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("deploy_10.0.0.5_2222");
let signing = SigningKey::from_bytes(&[65u8; 32]);
write_identity(&path, &signing);
let mut config = ClientConfig::default();
config.identity_files.clear();
let ssh_config = SshConfig {
hostname: Some("10.0.0.5".to_string()),
identity_files: vec![format!("{}/%r_%h_%p", dir.path().display())],
identities_only: true,
port: Some(2222),
..SshConfig::default()
};
let identity_context = NativeIdentityContext::new("deploy@prod", None, &ssh_config);
let loaded =
load_first_native_identity_with_prompt(&config, None, &identity_context, |_| {
unreachable!("unencrypted key should not prompt")
})
.unwrap();
assert_eq!( assert_eq!(
loaded.public_key().key_data().ed25519().unwrap().as_ref(), loaded.public_key().key_data().ed25519().unwrap().as_ref(),
+117 -44
View File
@@ -2566,8 +2566,10 @@ struct UploadState {
hasher: Sha256, hasher: Sha256,
written: u64, written: u64,
expected_size: u64, expected_size: u64,
mode: Option<u32>,
modified_secs: Option<u64>, modified_secs: Option<u64>,
atomic: bool, atomic: bool,
original_len: Option<u64>,
} }
async fn run_file_stream_service( async fn run_file_stream_service(
@@ -2679,6 +2681,46 @@ async fn handle_file_request(
send_file_response_to_client(state, socket, client_id, stream_id, FileResponse::Ok) send_file_response_to_client(state, socket, client_id, stream_id, FileResponse::Ok)
.await .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 } => { FileRequest::Remove { path, recursive } => {
let path = clean_remote_path(&path, home)?; let path = clean_remote_path(&path, home)?;
let metadata = let metadata =
@@ -2716,31 +2758,41 @@ async fn handle_file_request(
bail!("destination exists: {}", final_path.display()); bail!("destination exists: {}", final_path.display());
} }
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
let (file, temp_path, written, atomic) = if resume && final_path.exists() { let (file, temp_path, written, atomic, original_len, prefix_sha256) =
let mut file = fs::OpenOptions::new() if resume && final_path.exists() {
.read(true) let original_len = fs::metadata(&final_path)
.append(true) .with_context(|| format!("stat {}", final_path.display()))?
.open(&final_path) .len();
.with_context(|| format!("open {}", final_path.display()))?; let written = original_len.min(size);
let written = file.metadata()?.len().min(size); if written > 0 {
file.set_len(written) let mut prefix = fs::File::open(&final_path)?;
.with_context(|| format!("truncate {}", final_path.display()))?; hash_exact_prefix(&mut prefix, written, &mut hasher)?;
if written > 0 { }
let mut prefix = fs::File::open(&final_path)?; let prefix_sha256 = (written > 0).then(|| hasher.clone().finalize().into());
hash_exact_prefix(&mut prefix, written, &mut hasher)?; let mut file = fs::OpenOptions::new()
} .read(true)
file.seek(SeekFrom::Start(written))?; .append(true)
(file, None, written, false) .open(&final_path)
} else { .with_context(|| format!("open {}", final_path.display()))?;
let temp_path = upload_temp_path(&final_path, stream_id); file.seek(SeekFrom::Start(written))?;
let file = fs::OpenOptions::new() (
.create(true) file,
.truncate(true) None,
.write(true) written,
.open(&temp_path) false,
.with_context(|| format!("create {}", temp_path.display()))?; Some(original_len),
(file, Some(temp_path), 0, true) prefix_sha256,
}; )
} else {
let temp_path = upload_temp_path(&final_path, stream_id);
let file = fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&temp_path)
.with_context(|| format!("create {}", temp_path.display()))?;
(file, Some(temp_path), 0, true, None, None)
};
*upload = Some(UploadState { *upload = Some(UploadState {
final_path, final_path,
temp_path, temp_path,
@@ -2748,26 +2800,20 @@ async fn handle_file_request(
hasher, hasher,
written, written,
expected_size: size, expected_size: size,
mode,
modified_secs, modified_secs,
atomic, atomic,
original_len,
}); });
if let Some(mode) = mode {
let Some(active_upload) = upload.as_ref() else {
return Err(anyhow!("upload state missing after start"));
};
let target = active_upload
.temp_path
.as_ref()
.unwrap_or(&active_upload.final_path)
.clone();
set_mode(&target, mode)?;
}
send_file_response_to_client( send_file_response_to_client(
state, state,
socket, socket,
client_id, client_id,
stream_id, stream_id,
FileResponse::Resume { offset: written }, FileResponse::Resume {
offset: written,
prefix_sha256,
},
) )
.await .await
} }
@@ -2789,8 +2835,6 @@ async fn handle_file_request(
let Some(mut active) = upload.take() else { let Some(mut active) = upload.take() else {
bail!("no upload in progress"); bail!("no upload in progress");
}; };
active.file.flush()?;
active.file.sync_all()?;
anyhow::ensure!( anyhow::ensure!(
active.written == active.expected_size, active.written == active.expected_size,
"upload size mismatch: got {}, expected {}", "upload size mismatch: got {}, expected {}",
@@ -2801,9 +2845,17 @@ async fn handle_file_request(
if digest != sha256 { if digest != sha256 {
if let Some(temp_path) = &active.temp_path { if let Some(temp_path) = &active.temp_path {
let _ = fs::remove_file(temp_path); let _ = fs::remove_file(temp_path);
} else if let Some(original_len) = active.original_len {
let _ = active.file.set_len(original_len);
} }
bail!("upload checksum mismatch"); bail!("upload checksum mismatch");
} }
active
.file
.set_len(active.expected_size)
.with_context(|| format!("truncate {}", active.final_path.display()))?;
active.file.flush()?;
active.file.sync_all()?;
drop(active.file); drop(active.file);
if active.atomic if active.atomic
&& let Some(temp_path) = active.temp_path.take() && let Some(temp_path) = active.temp_path.take()
@@ -2816,6 +2868,9 @@ async fn handle_file_request(
) )
})?; })?;
} }
if let Some(mode) = active.mode {
set_mode(&active.final_path, mode)?;
}
if let Some(modified_secs) = active.modified_secs { if let Some(modified_secs) = active.modified_secs {
filetime::set_file_mtime( filetime::set_file_mtime(
&active.final_path, &active.final_path,
@@ -2843,9 +2898,20 @@ async fn handle_file_request(
"remote path is not a file: {}", "remote path is not a file: {}",
meta.path meta.path
); );
anyhow::ensure!(
offset <= meta.len,
"download offset {offset} is beyond file size {}",
meta.len
);
let mut file = let mut file =
fs::File::open(&path).with_context(|| format!("open {}", path.display()))?; fs::File::open(&path).with_context(|| format!("open {}", path.display()))?;
file.seek(SeekFrom::Start(offset))?; file.seek(SeekFrom::Start(offset))?;
let mut hasher = Sha256::new();
if offset > 0 {
let mut prefix = fs::File::open(&path)?;
hash_exact_prefix(&mut prefix, offset, &mut hasher)?;
}
let prefix_sha256 = (offset > 0).then(|| hasher.clone().finalize().into());
send_file_response_to_client( send_file_response_to_client(
state, state,
socket, socket,
@@ -2854,14 +2920,10 @@ async fn handle_file_request(
FileResponse::Start { FileResponse::Start {
meta: meta.clone(), meta: meta.clone(),
offset, offset,
prefix_sha256,
}, },
) )
.await?; .await?;
let mut hasher = Sha256::new();
if offset > 0 {
let mut prefix = fs::File::open(&path)?;
hash_exact_prefix(&mut prefix, offset, &mut hasher)?;
}
let mut sent = offset; let mut sent = offset;
let mut buf = vec![0u8; CHUNK_SIZE]; let mut buf = vec![0u8; CHUNK_SIZE];
loop { loop {
@@ -3043,6 +3105,17 @@ fn set_mode(path: &Path, mode: u32) -> Result<()> {
fs::set_permissions(path, permissions).with_context(|| format!("chmod {}", path.display())) 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 { fn upload_temp_path(final_path: &Path, stream_id: u64) -> PathBuf {
let name = final_path let name = final_path
.file_name() .file_name()
+37 -7
View File
@@ -18,6 +18,14 @@ pub enum FileRequest {
path: String, path: String,
mode: Option<u32>, mode: Option<u32>,
}, },
Readlink {
path: String,
},
Symlink {
path: String,
target: String,
overwrite: bool,
},
Remove { Remove {
path: String, path: String,
recursive: bool, recursive: bool,
@@ -46,13 +54,35 @@ pub enum FileRequest {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum FileResponse { pub enum FileResponse {
Ok, Ok,
Resume { offset: u64 }, Resume {
Stat { meta: FileMeta }, offset: u64,
List { entries: Vec<FileEntry> }, prefix_sha256: Option<[u8; 32]>,
Start { meta: FileMeta, offset: u64 }, },
Chunk { offset: u64, bytes: Vec<u8> }, Stat {
Done { sha256: [u8; 32], bytes: u64 }, meta: FileMeta,
Error { message: String }, },
List {
entries: Vec<FileEntry>,
},
LinkTarget {
target: String,
},
Start {
meta: FileMeta,
offset: u64,
prefix_sha256: Option<[u8; 32]>,
},
Chunk {
offset: u64,
bytes: Vec<u8>,
},
Done {
sha256: [u8; 32],
bytes: u64,
},
Error {
message: String,
},
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+85
View File
@@ -3,6 +3,7 @@
use std::fs; use std::fs;
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream, UdpSocket}; use std::net::{TcpListener, TcpStream, UdpSocket};
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::{UnixListener, UnixStream}; use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, Stdio};
@@ -1002,6 +1003,8 @@ fn native_file_copy_recursive_round_trip() {
fs::create_dir_all(src.join("nested")).unwrap(); fs::create_dir_all(src.join("nested")).unwrap();
fs::write(src.join("root.txt"), b"root file\n").unwrap(); fs::write(src.join("root.txt"), b"root file\n").unwrap();
fs::write(src.join("nested/child.txt"), b"child 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 client_bin = env!("CARGO_BIN_EXE_dosh-client");
let upload = Command::new(client_bin) let upload = Command::new(client_bin)
@@ -1107,6 +1110,14 @@ fn native_file_copy_recursive_round_trip() {
fs::read_to_string(downloaded.join("nested/child.txt")).unwrap(), fs::read_to_string(downloaded.join("nested/child.txt")).unwrap(),
"child file\n" "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) let remove = Command::new(client_bin)
.arg("--dosh-host") .arg("--dosh-host")
@@ -1241,6 +1252,80 @@ fn native_file_copy_resume_truncates_oversized_destinations() {
let _ = server.wait(); let _ = server.wait();
} }
#[test]
fn native_file_copy_resume_prefix_mismatch_preserves_destinations() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
write_native_client_auth(&dir, &config);
let mut server = start_server(&dir, &config);
let client_bin = env!("CARGO_BIN_EXE_dosh-client");
let local_upload = dir.path().join("upload-prefix.txt");
let remote_upload = dir.path().join("remote-upload.txt");
fs::write(&local_upload, b"local-prefix\n").unwrap();
fs::write(&remote_upload, b"remote-prefix-with-tail\n").unwrap();
fs::set_permissions(&local_upload, fs::Permissions::from_mode(0o755)).unwrap();
fs::set_permissions(&remote_upload, fs::Permissions::from_mode(0o600)).unwrap();
let bad_upload_resume = Command::new(client_bin)
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("cp")
.arg("--resume")
.arg(&local_upload)
.arg("local:remote-upload.txt")
.env("HOME", dir.path())
.output()
.unwrap();
assert!(
!bad_upload_resume.status.success(),
"bad upload resume unexpectedly succeeded: stdout={} stderr={}",
String::from_utf8_lossy(&bad_upload_resume.stdout),
String::from_utf8_lossy(&bad_upload_resume.stderr)
);
assert_eq!(
fs::read_to_string(&remote_upload).unwrap(),
"remote-prefix-with-tail\n"
);
assert_eq!(
fs::metadata(&remote_upload).unwrap().permissions().mode() & 0o777,
0o600
);
fs::write(dir.path().join("remote-download.txt"), b"remote-download\n").unwrap();
let local_download = dir.path().join("download-prefix.txt");
fs::write(&local_download, b"local-download-with-tail\n").unwrap();
let bad_download_resume = Command::new(client_bin)
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("cp")
.arg("--resume")
.arg("local:remote-download.txt")
.arg(&local_download)
.env("HOME", dir.path())
.output()
.unwrap();
assert!(
!bad_download_resume.status.success(),
"bad download resume unexpectedly succeeded: stdout={} stderr={}",
String::from_utf8_lossy(&bad_download_resume.stdout),
String::from_utf8_lossy(&bad_download_resume.stderr)
);
assert_eq!(
fs::read_to_string(&local_download).unwrap(),
"local-download-with-tail\n"
);
let _ = server.kill();
let _ = server.wait();
}
#[test] #[test]
fn native_exec_command_smoke() { fn native_exec_command_smoke() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();