From 38ca13ca6200d126c0b70d510c01d8a2b5fca19a Mon Sep 17 00:00:00 2001 From: Eric Curtin Date: Fri, 2 Oct 2026 11:26:07 +0100 Subject: [PATCH] fix(cli): disable tar sparse mode in uploads Closes #3728 Signed-off-by: Eric Curtin --- crates/openshell-cli/src/ssh.rs | 37 ++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/openshell-cli/src/ssh.rs b/crates/openshell-cli/src/ssh.rs index a7dc992e07..bf1a30a7d1 100644 --- a/crates/openshell-cli/src/ssh.rs +++ b/crates/openshell-cli/src/ssh.rs @@ -1078,6 +1078,9 @@ enum UploadSource { fn write_upload_archive(writer: W, source: UploadSource) -> Result<()> { let mut archive = tar::Builder::new(writer); + // Sparse detection trusts `st_blocks`. Filesystems that report zero blocks + // (such as WSL2 drive mounts) would upload every file as NUL bytes. + archive.sparse(false); match source { UploadSource::SinglePath { local_path, @@ -3002,6 +3005,7 @@ mod tests { entry_type: tar::EntryType, #[cfg_attr(not(unix), allow(dead_code))] link_name: Option, + contents: Vec, } fn upload_archive_entries(source: UploadSource) -> Vec { @@ -3011,7 +3015,7 @@ mod tests { let entries = archive.entries().expect("read archive entries"); let mut entries = entries .map(|entry| { - let entry = entry.expect("read archive entry"); + let mut entry = entry.expect("read archive entry"); let path = entry .path() .expect("read archive path") @@ -3022,11 +3026,15 @@ mod tests { .link_name() .expect("read archive link") .map(|link| link.to_string_lossy().into_owned()); + let mut contents = Vec::new(); + std::io::Read::read_to_end(&mut entry, &mut contents) + .expect("read archive contents"); UploadArchiveEntry { path, entry_type, link_name, + contents, } }) .collect::>(); @@ -3043,6 +3051,33 @@ mod tests { paths } + #[test] + fn upload_archive_stores_files_with_holes_as_regular_entries() { + use std::io::{Seek, SeekFrom}; + + // tar writes sparse entries by default and treats a file that reports + // zero blocks (as WSL2 drive mounts do) as all holes, so the sandbox + // receives NUL bytes. Uploads must always carry the file contents. + let tmpdir = tempfile::tempdir().expect("create tmpdir"); + let path = tmpdir.path().join("holes.bin"); + let mut file = fs::File::create(&path).expect("create file"); + file.write_all(b"head").expect("write head"); + file.seek(SeekFrom::Start(1 << 20)).expect("seek past hole"); + file.write_all(b"tail").expect("write tail"); + drop(file); + + let entries = upload_archive_entries(UploadSource::SinglePath { + local_path: path, + tar_name: "holes.bin".into(), + }); + + assert_eq!(entries.len(), 1, "unexpected archive entries: {entries:?}"); + assert_eq!(entries[0].entry_type, tar::EntryType::Regular); + assert_eq!(entries[0].contents.len(), (1 << 20) + 4); + assert_eq!(&entries[0].contents[..4], b"head"); + assert_eq!(&entries[0].contents[1 << 20..], b"tail"); + } + #[test] fn file_list_archive_preserves_directory_prefix_when_requested() { let tmpdir = tempfile::tempdir().expect("create tmpdir");