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

This commit is contained in:
DuProcess
2026-07-11 17:59:41 -04:00
parent 4ea05fc14f
commit bfe75dfbd2
6 changed files with 264 additions and 68 deletions
+95 -15
View File
@@ -2266,8 +2266,11 @@ fn upload_file(
overwrite: opts.overwrite,
resume: opts.resume,
})?;
let offset = match client.recv()? {
FileResponse::Resume { offset } => offset.min(size),
let (offset, prefix_sha256) = match client.recv()? {
FileResponse::Resume {
offset,
prefix_sha256,
} => (offset.min(size), prefix_sha256),
FileResponse::Error { message } => return Err(anyhow!(message)),
other => bail!("unexpected upload start response: {other:?}"),
};
@@ -2275,6 +2278,7 @@ fn upload_file(
let mut hasher = Sha256::new();
if offset > 0 {
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))?;
let mut sent = offset;
@@ -2415,8 +2419,11 @@ fn copy_remote_file(
overwrite: opts.overwrite,
resume: opts.resume,
})?;
let offset = match dst_client.recv()? {
FileResponse::Resume { offset } => offset.min(meta.len),
let (offset, dst_prefix_sha256) = match dst_client.recv()? {
FileResponse::Resume {
offset,
prefix_sha256,
} => (offset.min(meta.len), prefix_sha256),
FileResponse::Error { message } => return Err(anyhow!(message)),
other => bail!("unexpected remote copy start response: {other:?}"),
};
@@ -2425,7 +2432,17 @@ fn copy_remote_file(
offset,
})?;
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)),
other => bail!("unexpected remote copy source start response: {other:?}"),
}
@@ -2498,20 +2515,22 @@ fn download_file(
{
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let offset = if opts.resume && local.exists() {
fs::metadata(local)?.len().min(meta.len)
let original_len = if local.exists() {
Some(fs::metadata(local)?.len())
} else {
None
};
let offset = if opts.resume {
original_len.unwrap_or(0).min(meta.len)
} else {
0
};
let mut file = if offset > 0 {
let file = fs::OpenOptions::new()
fs::OpenOptions::new()
.read(true)
.append(true)
.open(local)
.with_context(|| format!("open {}", local.display()))?;
file.set_len(offset)
.with_context(|| format!("truncate {}", local.display()))?;
file
.with_context(|| format!("open {}", local.display()))?
} else {
fs::OpenOptions::new()
.create(true)
@@ -2521,16 +2540,29 @@ fn download_file(
.with_context(|| format!("create {}", local.display()))?
};
let mut hasher = Sha256::new();
if offset > 0 {
let local_prefix_sha256 = if offset > 0 {
let mut prefix = fs::File::open(local)?;
hash_prefix(&mut prefix, offset, &mut hasher)?;
}
Some(digest_prefix(&hasher))
} else {
None
};
client.send(FileRequest::Get {
path: remote.to_string(),
offset,
})?;
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)),
other => bail!("unexpected download start response: {other:?}"),
}
@@ -2549,8 +2581,16 @@ fn download_file(
FileResponse::Done { sha256, bytes } => {
file.flush()?;
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!(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_mtime(local, meta.modified_secs, opts.preserve)?;
if opts.progress {
@@ -2585,6 +2625,46 @@ fn hash_prefix(file: &mut fs::File, bytes: u64, hasher: &mut Sha256) -> Result<(
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 {
let raw = path.as_os_str().to_string_lossy();
raw.ends_with('/') || raw.ends_with('\\')