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(&copy.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(&copy, 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(&copy, 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(&copy_path, &copy).unwrap();
+    let dest = tmp.path().join("out");
+    unsquashfs(&copy_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

Reply via email to