Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package backhand for openSUSE:Factory checked in at 2026-09-29 17:48:45 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/backhand (Old) and /work/SRC/openSUSE:Factory/.backhand.new.383539 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "backhand" Tue Sep 29 17:48:45 2026 rev:10 rq:1381153 version:0.25.5 Changes: -------- --- /work/SRC/openSUSE:Factory/backhand/backhand.changes 2026-09-24 23:01:44.141957034 +0200 +++ /work/SRC/openSUSE:Factory/.backhand.new.383539/backhand.changes 2026-09-29 17:49:35.033714682 +0200 @@ -1,0 +2,9 @@ +Sat Sep 26 16:44:27 UTC 2026 - Martin Hauke <[email protected]> + +- Update to version 0.25.5: + * Compare the whole file to find duplicate files. + * Do not copy the fragment of an empty file. + * Write sparse files with holes, as mksquashfs does. + * Fix zero-length files written with dangling fragment reference. + +------------------------------------------------------------------- Old: ---- backhand-0.25.4.obscpio New: ---- backhand-0.25.5.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ backhand.spec ++++++ --- /var/tmp/diff_new_pack.pPkdzs/_old 2026-09-29 17:49:44.892125010 +0200 +++ /var/tmp/diff_new_pack.pPkdzs/_new 2026-09-29 17:49:44.893125051 +0200 @@ -18,7 +18,7 @@ Name: backhand -Version: 0.25.4 +Version: 0.25.5 Release: 0 Summary: Tools for the reading, creating, and modification of SquashFS file systems License: Apache-2.0 OR MIT ++++++ _service ++++++ --- /var/tmp/diff_new_pack.pPkdzs/_old 2026-09-29 17:49:44.940127017 +0200 +++ /var/tmp/diff_new_pack.pPkdzs/_new 2026-09-29 17:49:44.946127268 +0200 @@ -3,7 +3,7 @@ <param name="url">https://github.com/wcampbell0x2a/backhand</param> <param name="versionformat">@PARENT_TAG@</param> <param name="scm">git</param> - <param name="revision">v0.25.4</param> + <param name="revision">v0.25.5</param> <param name="versionrewrite-pattern">v(\d+\.\d+\.\d+)</param> <param name="changesgenerate">enable</param> </service> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.pPkdzs/_old 2026-09-29 17:49:44.973128397 +0200 +++ /var/tmp/diff_new_pack.pPkdzs/_new 2026-09-29 17:49:44.981128731 +0200 @@ -1,6 +1,6 @@ <servicedata> <service name="tar_scm"> <param name="url">https://github.com/wcampbell0x2a/backhand</param> - <param name="changesrevision">c9be3e619b57c1c9fda83d081f3dbbf4bf436059</param></service></servicedata> + <param name="changesrevision">bf678c7f923799a57d972cf695daf69d8c6ddafb</param></service></servicedata> (No newline at EOF) ++++++ backhand-0.25.4.obscpio -> backhand-0.25.5.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/backhand-0.25.4/CHANGELOG.md new/backhand-0.25.5/CHANGELOG.md --- old/backhand-0.25.4/CHANGELOG.md 2026-09-23 05:03:16.000000000 +0200 +++ new/backhand-0.25.5/CHANGELOG.md 2026-09-26 18:04:25.000000000 +0200 @@ -7,6 +7,15 @@ ## [Unreleased] +## [0.25.5](https://github.com/wcampbell0x2a/backhand/compare/v0.25.4...v0.25.5) - 2026-09-26 + +### Other + +- Compare the whole file to find duplicate files +- Do not copy the fragment of an empty file +- Write sparse files with holes, as mksquashfs does +- Fix zero-length files written with dangling fragment reference + ## [0.25.4](https://github.com/wcampbell0x2a/backhand/compare/v0.25.3...v0.25.4) - 2026-09-23 ### Other diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/backhand-0.25.4/Cargo.lock new/backhand-0.25.5/Cargo.lock --- old/backhand-0.25.4/Cargo.lock 2026-09-23 05:03:16.000000000 +0200 +++ new/backhand-0.25.5/Cargo.lock 2026-09-26 18:04:25.000000000 +0200 @@ -106,7 +106,7 @@ [[package]] name = "backhand" -version = "0.25.4" +version = "0.25.5" dependencies = [ "assert_cmd", "criterion", @@ -138,7 +138,7 @@ [[package]] name = "backhand-cli" -version = "0.25.4" +version = "0.25.5" dependencies = [ "backhand", "clap", diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/backhand-0.25.4/Cargo.toml new/backhand-0.25.5/Cargo.toml --- old/backhand-0.25.4/Cargo.toml 2026-09-23 05:03:16.000000000 +0200 +++ new/backhand-0.25.5/Cargo.toml 2026-09-26 18:04:25.000000000 +0200 @@ -9,7 +9,7 @@ resolver = "2" [workspace.package] -version = "0.25.4" +version = "0.25.5" authors = ["wcampbell <[email protected]>"] license = "MIT OR Apache-2.0" edition = "2024" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/backhand-0.25.4/backhand/src/v4/data.rs new/backhand-0.25.5/backhand/src/v4/data.rs --- old/backhand-0.25.4/backhand/src/v4/data.rs 2026-09-23 05:03:16.000000000 +0200 +++ new/backhand-0.25.5/backhand/src/v4/data.rs 2026-09-26 18:04:25.000000000 +0200 @@ -1,11 +1,12 @@ //! File Data -use no_std_io2::io::{Read, Seek, Write}; +use no_std_io2::io::{Read, Seek, SeekFrom, Write}; use std::collections::HashMap; +use std::collections::hash_map::Entry; use deku::prelude::*; use solana_nohash_hasher::IntMap; -use xxhash_rust::xxh64::xxh64; +use xxhash_rust::xxh64::Xxh64; use crate::error::BackhandError; use crate::v4::filesystem::writer::FilesystemCompressor; @@ -43,6 +44,12 @@ Self::new(size, true) } + /// A block of all zeros, with no bytes stored in the image + #[inline] + pub const fn hole() -> Self { + Self(0) + } + #[inline] pub fn uncompressed(&self) -> bool { self.0 & DATA_STORED_UNCOMPRESSED != 0 @@ -66,8 +73,8 @@ #[derive(Debug, Clone, PartialEq, Eq)] pub enum Added { - // Only Data was added - Data { blocks_start: u64, block_sizes: Vec<DataSize> }, + // Only Data was added. `sparse` is the number of bytes stored as holes. + Data { blocks_start: u64, block_sizes: Vec<DataSize>, sparse: u64 }, // Only Fragment was added Fragment { frag_index: u32, block_offset: u32 }, } @@ -111,7 +118,7 @@ ), block_size: u32, fs_compressor: FilesystemCompressor, - /// If some, cache of HashMap<file_len, HashMap<hash, (file_len, Added)>> + /// If some, cache of HashMap<file_len, HashMap<hash of whole file, (file_len, Added)>> #[allow(clippy::type_complexity)] dup_cache: Option<IntMap<u64, IntMap<u64, (usize, Added)>>>, /// Un-written fragment_bytes @@ -152,17 +159,23 @@ mut writer: W, ) -> Result<(usize, Added), BackhandError> { //just clone it, because block sizes where never modified, just copy it - let mut block_sizes = reader.file.block_sizes().to_vec(); + let source = reader.file; + let mut block_sizes = source.block_sizes().to_vec(); + let sparse = source.sparse(); let mut read_buf = vec![]; let mut decompress_buf = vec![]; // if the first block is not full (fragment), store only a fragment // otherwise processed to store blocks let blocks_start = writer.stream_position()?; + // Older backhand versions gave an empty file a fragment, do not copy that + if source.file_len() == 0 { + return Ok((0, Added::Data { blocks_start, block_sizes: vec![], sparse: 0 })); + } let first_block = match reader.next_block(&mut read_buf) { Some(Ok(first_block)) => first_block, Some(Err(x)) => return Err(x), - None => return Ok((0, Added::Data { blocks_start, block_sizes })), + None => return Ok((0, Added::Data { blocks_start, block_sizes, sparse })), }; // write and early return if fragment @@ -181,8 +194,16 @@ return Ok((decompress_buf.len(), Added::Fragment { frag_index, block_offset })); } - //if is a block, just copy it - writer.write_all(&read_buf)?; + // The reader gives a hole as a block of zeros, but a hole has no bytes in the image + let mut is_hole = source.block_sizes().iter().map(|size| size.size() == 0); + let mut copy_block = |writer: &mut W, raw: &[u8]| -> Result<(), BackhandError> { + if is_hole.next() != Some(true) { + writer.write_all(raw)?; + } + Ok(()) + }; + + copy_block(&mut writer, &read_buf)?; while let Some(block) = reader.next_block(&mut read_buf) { let block = block?; if block.fragment { @@ -204,12 +225,11 @@ writer.write_all(&cb)?; } } else { - //if is a block, just copy it - writer.write_all(&read_buf)?; + copy_block(&mut writer, &read_buf)?; } } - let file_size = reader.file.file_len(); - Ok((file_size, Added::Data { blocks_start, block_sizes })) + let file_size = source.file_len(); + Ok((file_size, Added::Data { blocks_start, block_sizes, sparse })) } /// Add to data writer, either a Data or Fragment @@ -231,6 +251,15 @@ // read entire chunk (file) let mut chunk = chunk_reader.read_chunk()?; + // an empty file must carry no fragment reference: a zero-byte + // fragment entry makes the kernel squashfs driver reject the inode + // with EINVAL on stat/open (Added::Data with no blocks encodes + // frag_index 0xffffffff, matching mksquashfs) + if chunk.is_empty() { + let blocks_start = writer.stream_position()?; + return Ok((0, Added::Data { blocks_start, block_sizes: vec![], sparse: 0 })); + } + // chunk size not exactly the size of the block if chunk.len() != self.block_size as usize { // if this doesn't fit in the current fragment bytes @@ -251,51 +280,47 @@ let blocks_start = writer.stream_position()?; let mut block_sizes = vec![]; - // If duplicate file checking is enabled, use the old data position as this file if it hashes the same - if let Some(dup_cache) = &self.dup_cache - && let Some(c) = dup_cache.get(&(chunk.len() as u64)) - { - let hash = xxh64(chunk, 0); - if let Some(res) = c.get(&hash) { - trace!("duplicate file data found"); - return Ok(res.clone()); - } - } - - // Save information needed to add to duplicate_cache later - let chunk_len = chunk.len(); - let hash = xxh64(chunk, 0); - + let mut hasher = Xxh64::new(0); + let mut sparse = 0; while !chunk.is_empty() { - let cb = self.compressor.compress(chunk, self.fs_compressor, self.block_size)?; - - // compression didn't reduce size - if cb.len() > chunk.len() { - // store uncompressed - block_sizes.push(DataSize::new_uncompressed(chunk.len() as u32)); - writer.write_all(chunk)?; + if chunk.iter().all(|byte| *byte == 0) { + // like mksquashfs, store a block of zeros as a hole + block_sizes.push(DataSize::hole()); + sparse += chunk.len() as u64; } else { - // store compressed - block_sizes.push(DataSize::new_compressed(cb.len() as u32)); - writer.write_all(&cb)?; + let cb = self.compressor.compress(chunk, self.fs_compressor, self.block_size)?; + + // compression didn't reduce size + if cb.len() > chunk.len() { + // store uncompressed + block_sizes.push(DataSize::new_uncompressed(chunk.len() as u32)); + writer.write_all(chunk)?; + } else { + // store compressed + block_sizes.push(DataSize::new_compressed(cb.len() as u32)); + writer.write_all(&cb)?; + } } + hasher.update(chunk); chunk = chunk_reader.read_chunk()?; } - // Add to duplicate information cache - let added = (chunk_reader.file_len, Added::Data { blocks_start, block_sizes }); + let added = (chunk_reader.file_len, Added::Data { blocks_start, block_sizes, sparse }); + let Some(dup_cache) = &mut self.dup_cache else { + return Ok(added); + }; - // If duplicate files checking is enbaled, then add this to it's memory - if let Some(dup_cache) = &mut self.dup_cache { - if let Some(entry) = dup_cache.get_mut(&(chunk_len as u64)) { - entry.insert(hash, added.clone()); - } else { - let mut hashmap = IntMap::default(); - hashmap.insert(hash, added.clone()); - dup_cache.insert(chunk_len as u64, hashmap); + // The hash is known only after the whole file is read. Thus, like mksquashfs, write the + // data first, then seek back so that the next data writes over a duplicate. + let files_with_len = dup_cache.entry(chunk_reader.file_len as u64).or_default(); + match files_with_len.entry(hasher.digest()) { + Entry::Occupied(original) => { + trace!("duplicate file data found"); + writer.seek(SeekFrom::Start(blocks_start))?; + Ok(original.get().clone()) } + Entry::Vacant(entry) => Ok(entry.insert(added).clone()), } - Ok(added) } /// Compress the fragments that were under length, write to data, add to fragment table, clear diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/backhand-0.25.4/backhand/src/v4/entry.rs new/backhand-0.25.5/backhand/src/v4/entry.rs --- old/backhand-0.25.4/backhand/src/v4/entry.rs 2026-09-23 05:03:16.000000000 +0200 +++ new/backhand-0.25.5/backhand/src/v4/entry.rs 2026-09-26 18:04:25.000000000 +0200 @@ -145,55 +145,47 @@ }; match added { - Added::Data { blocks_start, block_sizes } => { - match ( - <usize as TryInto<u32>>::try_into(file_size), - <u64 as TryInto<u32>>::try_into(*blocks_start), - ) { - (Ok(file_size), Ok(blocks_start)) => { - let file_inode = Inode::new( - InodeId::BasicFile, - header, - InodeInner::BasicFile(BasicFile { - blocks_start, - frag_index: 0xffffffff, // <- no fragment - block_offset: 0x0, // <- no fragment - file_size, - block_sizes: block_sizes.to_vec(), - }), - ); - - Ok(file_inode.to_bytes( - node_path.as_bytes(), - inode_writer, - superblock, - kind, - )) - } - (_, _) => { - let file_inode = Inode::new( + Added::Data { blocks_start, block_sizes, sparse } => { + // A basic inode has no `sparse` field, thus a file with holes needs an extended + // inode. This is the same as mksquashfs. + let basic = match *sparse { + 0 => u32::try_from(file_size).ok().zip(u32::try_from(*blocks_start).ok()), + _ => None, + }; + let inode = match basic { + Some((file_size, blocks_start)) => Inode::new( + InodeId::BasicFile, + header, + InodeInner::BasicFile(BasicFile { + blocks_start, + frag_index: 0xffffffff, // <- no fragment + block_offset: 0x0, // <- no fragment + file_size, + block_sizes: block_sizes.to_vec(), + }), + ), + None => { + let file_size = file_size as u64; + Inode::new( InodeId::ExtendedFile, header, InodeInner::ExtendedFile(ExtendedFile { blocks_start: *blocks_start, frag_index: 0xffffffff, // <- no fragment block_offset: 0x0, // <- no fragment - file_size: file_size as u64, - sparse: 0, + file_size, + // The kernel sets st_blocks from `file_size - sparse`. Keep at + // least 1 byte, the same as mksquashfs. + sparse: (*sparse).min(file_size.saturating_sub(1)), block_sizes: block_sizes.to_vec(), - link_count: 0, + link_count: 1, xattr_index: 0xffffffff, // <- no xattr }), - ); - - Ok(file_inode.to_bytes( - node_path.as_bytes(), - inode_writer, - superblock, - kind, - )) + ) } - } + }; + + Ok(inode.to_bytes(node_path.as_bytes(), inode_writer, superblock, kind)) } Added::Fragment { frag_index, block_offset } => { let file_inode = Inode::new( diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/backhand-0.25.4/backhand/src/v4/filesystem/node.rs new/backhand-0.25.5/backhand/src/v4/filesystem/node.rs --- old/backhand-0.25.4/backhand/src/v4/filesystem/node.rs 2026-09-23 05:03:16.000000000 +0200 +++ new/backhand-0.25.5/backhand/src/v4/filesystem/node.rs 2026-09-26 18:04:25.000000000 +0200 @@ -121,6 +121,14 @@ } } + /// Bytes of the file stored as holes. A basic inode has no such field, thus 0. + pub fn sparse(&self) -> u64 { + match self { + SquashfsFileReader::Basic(_) => 0, + SquashfsFileReader::Extended(extended) => extended.sparse, + } + } + pub fn blocks_start(&self) -> u64 { match self { SquashfsFileReader::Basic(basic) => basic.blocks_start as u64, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/backhand-0.25.4/backhand-cli/Cargo.toml new/backhand-0.25.5/backhand-cli/Cargo.toml --- old/backhand-0.25.4/backhand-cli/Cargo.toml 2026-09-23 05:03:16.000000000 +0200 +++ new/backhand-0.25.5/backhand-cli/Cargo.toml 2026-09-26 18:04:25.000000000 +0200 @@ -19,7 +19,7 @@ indicatif = "0.18.6" console = "0.16.4" rayon = "1.12.0" -backhand = { path = "../backhand", default-features = false, version = "0.25.4" } +backhand = { path = "../backhand", default-features = false, version = "0.25.5" } tracing = "0.1.40" color-print = "0.3.6" clap-cargo = "0.19.0" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/backhand-0.25.4/backhand-test/Cargo.toml new/backhand-0.25.5/backhand-test/Cargo.toml --- old/backhand-0.25.4/backhand-test/Cargo.toml 2026-09-23 05:03:16.000000000 +0200 +++ new/backhand-0.25.5/backhand-test/Cargo.toml 2026-09-26 18:04:25.000000000 +0200 @@ -44,6 +44,9 @@ name = "add" [[test]] +name = "empty_file" + +[[test]] name = "issues" [[test]] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/backhand-0.25.4/backhand-test/tests/duplicate.rs new/backhand-0.25.5/backhand-test/tests/duplicate.rs --- old/backhand-0.25.4/backhand-test/tests/duplicate.rs 1970-01-01 01:00:00.000000000 +0100 +++ new/backhand-0.25.5/backhand-test/tests/duplicate.rs 2026-09-26 18:04:25.000000000 +0200 @@ -0,0 +1,158 @@ +//! Duplicate file detection in the v4 writer +//! +//! A file with the same content as an earlier file uses the data of that file. The writer must +//! compare the whole file, not only the first block. + +#![cfg(feature = "xz")] + +use std::io::{Cursor, Read}; +use std::process::Command; + +use backhand::{ + DEFAULT_BLOCK_SIZE, FilesystemReader, FilesystemWriter, InnerNode, NodeHeader, + SquashfsFileReader, +}; + +const BLOCK: usize = DEFAULT_BLOCK_SIZE as usize; + +/// Bytes that do not compress well, from `seed` +fn noise(len: usize, seed: u64) -> Vec<u8> { + let mut rng = fastrand::Rng::with_seed(seed); + (0..len).map(|_| rng.u8(..)).collect() +} + +/// Write `files` as (name, content) into an image +fn write_image(files: &[(&str, &[u8])], duplicate_check: bool) -> Vec<u8> { + let mut fs = FilesystemWriter::default(); + fs.set_no_duplicate_files(duplicate_check); + // squashfs-tools/unsquashfs must be able to read the files + fs.set_root_mode(0o755); + for (name, content) in files { + let header = NodeHeader::new(0o644, 0, 0, 0); + fs.push_file(Cursor::new(content.to_vec()), name, header).unwrap(); + } + let mut out = Cursor::new(vec![]); + fs.write(&mut out).unwrap(); + out.into_inner() +} + +/// Return the inode and the content of each file, in the order of `names` +fn read_files(image: &[u8], names: &[&str]) -> Vec<(SquashfsFileReader, Vec<u8>)> { + let reader = FilesystemReader::from_reader(Cursor::new(image)).unwrap(); + names + .iter() + .map(|name| { + let node = reader + .files() + .find(|node| node.fullpath.file_name().is_some_and(|n| n == *name)) + .unwrap(); + let InnerNode::File(file) = &node.inner else { panic!("{name} is not a file") }; + let mut content = vec![]; + reader.file(file).reader().read_to_end(&mut content).unwrap(); + (file.clone(), content) + }) + .collect() +} + +/// Check that each file reads back with its own content, and return the inodes +fn assert_contents(image: &[u8], files: &[(&str, &[u8])]) -> Vec<SquashfsFileReader> { + let names: Vec<&str> = files.iter().map(|(name, _)| *name).collect(); + read_files(image, &names) + .into_iter() + .zip(files) + .map(|((inode, read), (name, content))| { + assert!(read == *content, "{name}: content changed, len {}", content.len()); + inode + }) + .collect() +} + +#[test] +fn test_same_first_block_is_not_duplicate() { + let first = noise(3 * BLOCK, 1); + let mut second = first.clone(); + second[2 * BLOCK + 5] ^= 0xff; + let files: [(&str, &[u8]); 2] = [("first", &first), ("second", &second)]; + + let inodes = assert_contents(&write_image(&files, true), &files); + assert_ne!(inodes[0].blocks_start(), inodes[1].blocks_start()); +} + +#[test] +fn test_same_first_block_and_different_len_is_not_duplicate() { + let first = noise(2 * BLOCK, 2); + let second = [&first[..], &noise(10, 3)].concat(); + let files: [(&str, &[u8]); 2] = [("first", &first), ("second", &second)]; + assert_contents(&write_image(&files, true), &files); +} + +#[test] +fn test_duplicate_uses_same_data() { + let content = noise(2 * BLOCK + 100, 4); + let files: [(&str, &[u8]); 2] = [("first", &content), ("second", &content)]; + + let with_check = write_image(&files, true); + let inodes = assert_contents(&with_check, &files); + assert_eq!(inodes[0].blocks_start(), inodes[1].blocks_start()); + assert_eq!(inodes[0].block_sizes(), inodes[1].block_sizes()); + + let without_check = write_image(&files, false); + let inodes = assert_contents(&without_check, &files); + assert_ne!(inodes[0].blocks_start(), inodes[1].blocks_start()); + assert!(with_check.len() < without_check.len()); +} + +/// The data after a duplicate goes where the duplicate data was written first +#[test] +fn test_data_after_duplicate() { + let content = noise(2 * BLOCK, 5); + let other = noise(3 * BLOCK + 7, 6); + let small = noise(20, 7); + let files: [(&str, &[u8]); 4] = + [("a", &content), ("b", &content), ("c", &other), ("d", &small)]; + let inodes = assert_contents(&write_image(&files, true), &files); + assert_eq!(inodes[0].blocks_start(), inodes[1].blocks_start()); + assert_eq!(inodes[2].blocks_start(), inodes[0].blocks_start() + 2 * BLOCK as u64); +} + +/// squashfs-tools must read an image where the last file is a duplicate +#[test] +#[cfg(feature = "__test_unsquashfs")] +fn test_squashfs_tools_reads_duplicate_at_end() { + let content = noise(4 * BLOCK, 8); + let files: [(&str, &[u8]); 2] = [("first", &content), ("last", &content)]; + let tmp = tempfile::tempdir().unwrap(); + let image = tmp.path().join("image.squashfs"); + std::fs::write(&image, write_image(&files, true)).unwrap(); + + let dest = tmp.path().join("out"); + let output = + Command::new("unsquashfs").arg("-no-xattrs").arg("-d").arg(&dest).arg(&image).output(); + let output = output.unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + for (name, content) in files { + assert!(std::fs::read(dest.join(name)).unwrap() == content, "{name}: content changed"); + } +} + +/// Random files made from a small set of blocks, thus with many shared first blocks +#[test] +fn test_random_shared_blocks() { + let pieces: Vec<Vec<u8>> = (0..3).map(|seed| noise(BLOCK, 100 + seed)).collect(); + let mut rng = fastrand::Rng::with_seed(0xd0b); + for _ in 0..8 { + let contents: Vec<Vec<u8>> = (0..6) + .map(|_| { + let blocks = rng.usize(1..4); + let mut content: Vec<u8> = + (0..blocks).flat_map(|_| pieces[rng.usize(..pieces.len())].clone()).collect(); + content.truncate(content.len() - rng.usize(0..2) * rng.usize(..BLOCK)); + content + }) + .collect(); + let names: Vec<String> = (0..contents.len()).map(|n| format!("file{n}")).collect(); + let files: Vec<(&str, &[u8])> = + names.iter().map(String::as_str).zip(contents.iter().map(Vec::as_slice)).collect(); + assert_contents(&write_image(&files, true), &files); + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/backhand-0.25.4/backhand-test/tests/empty_file.rs new/backhand-0.25.5/backhand-test/tests/empty_file.rs --- old/backhand-0.25.4/backhand-test/tests/empty_file.rs 1970-01-01 01:00:00.000000000 +0100 +++ new/backhand-0.25.5/backhand-test/tests/empty_file.rs 2026-09-26 18:04:25.000000000 +0200 @@ -0,0 +1,30 @@ +/// Regression test: zero-length files must be encoded with NO fragment +/// reference (frag_index 0xffffffff). A dangling zero-byte fragment entry +/// makes the Linux kernel squashfs driver reject the inode with EINVAL on +/// stat/open, even though userspace readers tolerate it. +use std::io::Cursor; + +use backhand::SquashfsFileReader; +use backhand::v4::filesystem::node::InnerNode; +use backhand::{FilesystemReader, FilesystemWriter, NodeHeader}; +use test_log::test; + +#[test] +#[cfg(feature = "xz")] +fn test_empty_file_has_no_fragment_ref() { + let mut fs = FilesystemWriter::default(); + fs.push_file(Cursor::new(vec![]), "empty", NodeHeader::default()).unwrap(); + fs.push_file(Cursor::new(b"data".to_vec()), "full", NodeHeader::default()).unwrap(); + + let mut image = Cursor::new(vec![]); + fs.write(&mut image).unwrap(); + image.set_position(0); + + let fs = FilesystemReader::from_reader(image).unwrap(); + let node = fs.files().find(|n| n.fullpath.to_string_lossy() == "/empty").unwrap(); + let InnerNode::File(SquashfsFileReader::Basic(basic)) = &node.inner else { + panic!("expected basic file inode"); + }; + assert_eq!(basic.file_size, 0); + assert_eq!(basic.frag_index, 0xffffffff, "empty file must not reference a fragment"); +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/backhand-0.25.4/backhand-test/tests/issues.rs new/backhand-0.25.5/backhand-test/tests/issues.rs --- old/backhand-0.25.4/backhand-test/tests/issues.rs 2026-09-23 05:03:16.000000000 +0200 +++ new/backhand-0.25.5/backhand-test/tests/issues.rs 2026-09-26 18:04:25.000000000 +0200 @@ -37,3 +37,38 @@ Ok(_) => panic!("Invalid result"), }; } + +/// https://github.com/wcampbell0x2a/backhand/issues/803 +/// +/// An empty file must have no fragment, or the kernel returns EINVAL on stat and open +#[test] +#[cfg(feature = "xz")] +fn issue_803() { + use backhand::{FilesystemReader, FilesystemWriter, InnerNode, SquashfsFileReader}; + use std::io::Cursor; + + fn assert_no_fragment(image: &[u8]) { + let reader = FilesystemReader::from_reader(Cursor::new(image)).unwrap(); + let node = reader.files().find(|node| node.fullpath.ends_with("empty")).unwrap(); + let InnerNode::File(SquashfsFileReader::Basic(file)) = &node.inner else { + panic!("expected a basic file: {:?}", node.inner); + }; + assert_eq!(file.file_size, 0); + assert_eq!(file.frag_index, 0xffffffff); + assert!(file.block_sizes.is_empty()); + } + + let mut fs = FilesystemWriter::default(); + fs.push_file(Cursor::new(vec![]), "empty", backhand::NodeHeader::default()).unwrap(); + // A small file before the empty file, so that the fragment table is not empty + fs.push_file(Cursor::new(vec![1; 10]), "small", backhand::NodeHeader::default()).unwrap(); + let mut image = Cursor::new(vec![]); + fs.write(&mut image).unwrap(); + let image = image.into_inner(); + assert_no_fragment(&image); + + let reader = FilesystemReader::from_reader(Cursor::new(&image)).unwrap(); + let mut copy = Cursor::new(vec![]); + FilesystemWriter::from_fs_reader(&reader).unwrap().write(&mut copy).unwrap(); + assert_no_fragment(©.into_inner()); +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/backhand-0.25.4/backhand-test/tests/sparse.rs new/backhand-0.25.5/backhand-test/tests/sparse.rs --- old/backhand-0.25.4/backhand-test/tests/sparse.rs 1970-01-01 01:00:00.000000000 +0100 +++ new/backhand-0.25.5/backhand-test/tests/sparse.rs 2026-09-26 18:04:25.000000000 +0200 @@ -0,0 +1,247 @@ +//! Sparse file (hole) support in the v4 writer +//! +//! A data block of all zeros is stored as a hole: a block size of 0 with no bytes in the image. +//! The `sparse` field of the inode holds the number of hole bytes. The kernel uses it for +//! `st_blocks`, and squashfs-tools/unsquashfs uses it to write holes on extract. + +#![cfg(feature = "xz")] + +use std::fs::File; +use std::io::{Cursor, Read, Seek, SeekFrom, Write}; +use std::os::unix::fs::MetadataExt; +use std::path::Path; +use std::process::Command; + +use backhand::{ + DEFAULT_BLOCK_SIZE, FilesystemReader, FilesystemWriter, InnerNode, NodeHeader, + SquashfsFileReader, +}; + +const BLOCK: usize = DEFAULT_BLOCK_SIZE as usize; +const FILE_NAME: &str = "file"; + +/// Content of one data block in a test file +#[derive(Clone, Copy, Debug)] +enum Block { + Zero, + Data, +} + +/// Make file content from `blocks`, each [`BLOCK`] bytes long, then `tail` bytes of `tail_kind` +fn make_content(blocks: &[Block], tail: usize, tail_kind: Block) -> Vec<u8> { + let fill = |kind: Block, len: usize, seed: usize| -> Vec<u8> { + match kind { + Block::Zero => vec![0; len], + // Never all zeros, and not the same for each block + Block::Data => (0..len).map(|i| ((i + seed) % 251) as u8 + 1).collect(), + } + }; + let mut content: Vec<u8> = + blocks.iter().enumerate().flat_map(|(n, kind)| fill(*kind, BLOCK, n)).collect(); + content.extend(fill(tail_kind, tail, blocks.len())); + content +} + +/// The hole bytes that mksquashfs records for `content`, capped at `len - 1` +/// +/// A file smaller than one block goes into a fragment and has no holes. +fn expected_sparse(content: &[u8]) -> u64 { + if content.len() < BLOCK { + return 0; + } + let holes: u64 = content + .chunks(BLOCK) + .filter(|chunk| chunk.iter().all(|b| *b == 0)) + .map(|chunk| chunk.len() as u64) + .sum(); + holes.min(content.len() as u64 - 1) +} + +fn write_image(content: &[u8]) -> Vec<u8> { + let mut fs = FilesystemWriter::default(); + // squashfs-tools/unsquashfs must be able to read the file + fs.set_root_mode(0o755); + let header = NodeHeader::new(0o644, 0, 0, 0); + fs.push_file(Cursor::new(content.to_vec()), FILE_NAME, header).unwrap(); + let mut out = Cursor::new(vec![]); + fs.write(&mut out).unwrap(); + out.into_inner() +} + +/// Read the image again through [`FilesystemWriter::from_fs_reader`] +fn copy_image(image: &[u8]) -> Vec<u8> { + let reader = FilesystemReader::from_reader(Cursor::new(image)).unwrap(); + let mut fs = FilesystemWriter::from_fs_reader(&reader).unwrap(); + let mut out = Cursor::new(vec![]); + fs.write(&mut out).unwrap(); + out.into_inner() +} + +/// Return the inode and the content of the file named `name` +fn read_file(image: &[u8], name: &str) -> (SquashfsFileReader, Vec<u8>) { + let reader = FilesystemReader::from_reader(Cursor::new(image)).unwrap(); + let node = + reader.files().find(|node| node.fullpath.file_name().is_some_and(|n| n == name)).unwrap(); + let InnerNode::File(file) = &node.inner else { panic!("{name} is not a file") }; + let mut content = vec![]; + reader.file(file).reader().read_to_end(&mut content).unwrap(); + (file.clone(), content) +} + +fn hole_count(file: &SquashfsFileReader) -> usize { + file.block_sizes().iter().filter(|size| size.size() == 0).count() +} + +/// Check the image written from `content`, and a copy of that image +fn assert_round_trip(content: &[u8]) { + let image = write_image(content); + let (file, read) = read_file(&image, FILE_NAME); + assert!(read == content, "content changed, len {}", content.len()); + assert_eq!(file.sparse(), expected_sparse(content)); + match (&file, expected_sparse(content)) { + (SquashfsFileReader::Basic(_), 0) => {} + (SquashfsFileReader::Extended(extended), 1..) => assert_eq!(extended.link_count, 1), + (file, sparse) => panic!("wrong inode type for sparse {sparse}: {file:?}"), + } + + let copy = copy_image(&image); + let (copied_file, copied_read) = read_file(©, FILE_NAME); + assert!(copied_read == content, "copied content changed, len {}", content.len()); + assert_eq!(copied_file.sparse(), file.sparse()); + assert_eq!(hole_count(&copied_file), hole_count(&file)); +} + +#[test] +fn test_zero_blocks_become_holes() { + let content = + make_content(&[Block::Data, Block::Zero, Block::Data, Block::Zero], 0, Block::Data); + let (file, _) = read_file(&write_image(&content), FILE_NAME); + assert_eq!(hole_count(&file), 2); + assert_eq!(file.sparse(), 2 * BLOCK as u64); + assert_round_trip(&content); +} + +#[test] +fn test_zero_tail_block_becomes_hole() { + let content = make_content(&[Block::Data], 100, Block::Zero); + let (file, _) = read_file(&write_image(&content), FILE_NAME); + assert_eq!(hole_count(&file), 1); + assert_eq!(file.sparse(), 100); + assert_round_trip(&content); +} + +#[test] +fn test_all_zero_file_caps_sparse() { + let content = make_content(&[Block::Zero, Block::Zero], 0, Block::Data); + let (file, _) = read_file(&write_image(&content), FILE_NAME); + assert_eq!(hole_count(&file), 2); + assert_eq!(file.sparse(), content.len() as u64 - 1); + assert_round_trip(&content); +} + +#[test] +fn test_no_zero_blocks_stays_basic() { + let content = make_content(&[Block::Data, Block::Data], 7, Block::Data); + let (file, _) = read_file(&write_image(&content), FILE_NAME); + assert!(matches!(file, SquashfsFileReader::Basic(_))); + assert_round_trip(&content); +} + +#[test] +fn test_zero_file_smaller_than_block_is_fragment() { + let content = vec![0; BLOCK - 1]; + let (file, _) = read_file(&write_image(&content), FILE_NAME); + assert!(matches!(file, SquashfsFileReader::Basic(_))); + assert_eq!(hole_count(&file), 0); + assert_round_trip(&content); +} + +#[test] +fn test_empty_file() { + assert_round_trip(&[]); +} + +/// Random mixes of data blocks, zero blocks, and tails +#[test] +fn test_random_block_mix() { + let mut rng = fastrand::Rng::with_seed(0x5ba5e); + for _ in 0..64 { + let kind = |rng: &mut fastrand::Rng| if rng.bool() { Block::Zero } else { Block::Data }; + let blocks: Vec<Block> = (0..rng.usize(0..6)).map(|_| kind(&mut rng)).collect(); + let tail = if rng.bool() { 0 } else { rng.usize(1..BLOCK) }; + let tail_kind = kind(&mut rng); + assert_round_trip(&make_content(&blocks, tail, tail_kind)); + } +} + +/// Write a file with real holes on disk: data, hole, data, hole +fn write_sparse_file(path: &Path) -> Vec<u8> { + let content = + make_content(&[Block::Data, Block::Zero, Block::Data, Block::Zero], 0, Block::Data); + let mut file = File::create(path).unwrap(); + file.write_all(&content[..BLOCK]).unwrap(); + file.seek(SeekFrom::Start(2 * BLOCK as u64)).unwrap(); + file.write_all(&content[2 * BLOCK..3 * BLOCK]).unwrap(); + file.set_len(content.len() as u64).unwrap(); + content +} + +fn run(command: &mut Command) { + let output = command.output().unwrap(); + assert!(output.status.success(), "{command:?}: {}", String::from_utf8_lossy(&output.stderr)); +} + +fn unsquashfs(image: &Path, dest: &Path) { + run(Command::new("unsquashfs").arg("-no-xattrs").arg("-d").arg(dest).arg(image)); +} + +/// squashfs-tools must read our holes, and write them as holes on extract +#[test] +#[cfg(feature = "__test_unsquashfs")] +fn test_squashfs_tools_extracts_holes() { + let tmp = tempfile::tempdir().unwrap(); + let content = make_content(&[Block::Data, Block::Zero, Block::Zero], 0, Block::Data); + let image = tmp.path().join("image.squashfs"); + std::fs::write(&image, write_image(&content)).unwrap(); + + let dest = tmp.path().join("out"); + unsquashfs(&image, &dest); + let extracted = dest.join(FILE_NAME); + assert!(std::fs::read(&extracted).unwrap() == content); + let allocated = std::fs::metadata(&extracted).unwrap().blocks() * 512; + assert!(allocated < content.len() as u64, "file has no holes: {allocated} bytes allocated"); +} + +/// A copy of a mksquashfs image keeps the holes and the `sparse` field +#[test] +#[cfg(feature = "__test_unsquashfs")] +fn test_copy_of_mksquashfs_image() { + let tmp = tempfile::tempdir().unwrap(); + let src = tmp.path().join("src"); + std::fs::create_dir(&src).unwrap(); + let content = write_sparse_file(&src.join(FILE_NAME)); + + let image = tmp.path().join("image.squashfs"); + run(Command::new("mksquashfs").arg(&src).arg(&image).args([ + "-comp", + "xz", + "-noappend", + "-no-xattrs", + "-quiet", + ])); + let image = std::fs::read(&image).unwrap(); + let (file, _) = read_file(&image, FILE_NAME); + assert_eq!(hole_count(&file), 2); + + let copy = copy_image(&image); + let (copied_file, copied_read) = read_file(©, FILE_NAME); + assert!(copied_read == content); + assert_eq!(copied_file.sparse(), file.sparse()); + assert_eq!(hole_count(&copied_file), 2); + + let copy_path = tmp.path().join("copy.squashfs"); + std::fs::write(©_path, ©).unwrap(); + let dest = tmp.path().join("out"); + unsquashfs(©_path, &dest); + assert!(std::fs::read(dest.join(FILE_NAME)).unwrap() == content); +} ++++++ backhand.obsinfo ++++++ --- /var/tmp/diff_new_pack.pPkdzs/_old 2026-09-29 17:49:45.201137930 +0200 +++ /var/tmp/diff_new_pack.pPkdzs/_new 2026-09-29 17:49:45.205138097 +0200 @@ -1,5 +1,5 @@ name: backhand -version: 0.25.4 -mtime: 1790132596 -commit: c9be3e619b57c1c9fda83d081f3dbbf4bf436059 +version: 0.25.5 +mtime: 1790438665 +commit: bf678c7f923799a57d972cf695daf69d8c6ddafb ++++++ vendor.tar.zst ++++++ /work/SRC/openSUSE:Factory/backhand/vendor.tar.zst /work/SRC/openSUSE:Factory/.backhand.new.383539/vendor.tar.zst differ: char 7, line 1
