diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7a134f9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: cargo test + run: cargo test --verbose + + - name: cargo clippy + run: cargo clippy -- -D warnings diff --git a/Cargo.lock b/Cargo.lock index 804564a..12d71a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "anstream" @@ -87,6 +87,7 @@ dependencies = [ "fiemap", "nix", "serde", + "tempfile", ] [[package]] @@ -141,6 +142,22 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "fiemap" version = "0.1.2" @@ -150,6 +167,17 @@ dependencies = [ "bitflags 2.6.0", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "heck" version = "0.5.0" @@ -164,9 +192,15 @@ checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" [[package]] name = "libc" -version = "0.2.168" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aaeb2981e0606ca11d79718f8bb01164f1d6ed75080182d3abf017e6d244b6d" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "memoffset" @@ -190,6 +224,12 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "pin-utils" version = "0.1.0" @@ -214,6 +254,25 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.6.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "serde" version = "1.0.217" @@ -251,6 +310,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "unicode-ident" version = "1.0.14" diff --git a/Cargo.toml b/Cargo.toml index f3ea150..a23e971 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,9 +3,20 @@ name = "blockdiff" version = "0.1.0" edition = "2021" +[lib] +name = "blockdiff" +path = "src/lib.rs" + +[[bin]] +name = "blockdiff" +path = "src/main.rs" + [dependencies] fiemap = "0.1.1" nix = "0.26.0" clap = { version = "4.4", features = ["derive"] } serde = { version = "1.0", features = ["derive"] } bincode = "1.3" + +[dev-dependencies] +tempfile = "3.14" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3ca7d06 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Cognition AI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 80c64bc..6a7a23f 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,23 @@ Fast block-level file diffs (e.g. for VM disk images) using CoW filesystem metadata +Check out the technical writeup [here](https://cognition.ai/blog/blockdiff). + +## Requirements + +- **Linux only** — uses `fiemap` and `copy_file_range` (ext4/xfs/btrfs, etc.) +- Rust 1.70+ + +## Build & test + +```bash +cargo build --release +cargo test +cargo clippy -- -D warnings +``` + +Integration tests exercise `create → apply` roundtrips on a Linux filesystem with fiemap support. + ## Usage ### File snapshots diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..874de0a --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,145 @@ +use serde::{Deserialize, Serialize}; +use std::io::{Error, Read, Write}; + +/// Magic string for bdiff files ("BDIFFv1\0") +pub const MAGIC: &[u8; 8] = b"BDIFFv1\0"; + +/// Standard block size used for alignment (4 KiB) +pub const BLOCK_SIZE: usize = 4096; + +/// Represents a range in the target file that's different from the base file. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DiffRange { + pub logical_offset: u64, + pub length: u64, +} + +/// Header for a `.bdiff` file. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BDiffHeader { + pub magic: [u8; 8], + pub target_size: u64, + pub base_size: u64, + pub ranges: Vec, +} + +impl BDiffHeader { + pub fn new(target_size: u64, base_size: u64, ranges: Vec) -> Self { + Self { + magic: *MAGIC, + target_size, + base_size, + ranges, + } + } + + pub fn write_to(&self, writer: impl Write) -> Result<(), Error> { + bincode::serialize_into(writer, self).map_err(|e| Error::new(std::io::ErrorKind::Other, e)) + } + + pub fn read_from(reader: impl Read) -> Result { + let header: Self = + bincode::deserialize_from(reader).map_err(|e| Error::new(std::io::ErrorKind::Other, e))?; + + if header.magic != *MAGIC { + return Err(Error::new( + std::io::ErrorKind::InvalidData, + "Invalid bdiff file format", + )); + } + + Ok(header) + } + + pub fn serialized_size(&self) -> Result { + bincode::serialized_size(self) + .map(|size| size as usize) + .map_err(|e| Error::new(std::io::ErrorKind::Other, e)) + } +} + +/// Zero-padding bytes needed to align a serialized header to `BLOCK_SIZE`. +pub fn block_padding_size(serialized_header_len: usize) -> usize { + (BLOCK_SIZE - (serialized_header_len % BLOCK_SIZE)) % BLOCK_SIZE +} + +pub fn format_size(bytes: u64) -> String { + const UNITS: [&str; 6] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; + + if bytes == 0 { + return "0 B".to_string(); + } + + let exp = (bytes as f64).ln() / 1024_f64.ln(); + let exp = exp.floor() as usize; + let exp = exp.min(UNITS.len() - 1); + + let bytes = bytes as f64 / (1024_u64.pow(exp as u32) as f64); + + if exp == 0 { + format!("{} {}", bytes.round(), UNITS[exp]) + } else { + format!("{:.1} {}", bytes, UNITS[exp]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn block_padding_size_aligns_to_block_boundary() { + assert_eq!(block_padding_size(0), 0); + assert_eq!(block_padding_size(1), BLOCK_SIZE - 1); + assert_eq!(block_padding_size(BLOCK_SIZE), 0); + assert_eq!(block_padding_size(BLOCK_SIZE + 1), BLOCK_SIZE - 1); + } + + #[test] + fn format_size_units() { + assert_eq!(format_size(0), "0 B"); + assert_eq!(format_size(512), "512 B"); + assert_eq!(format_size(1024), "1.0 KiB"); + assert_eq!(format_size(1536), "1.5 KiB"); + } + + #[test] + fn header_roundtrip_preserves_fields() { + let header = BDiffHeader::new( + 8192, + 4096, + vec![DiffRange { + logical_offset: 4096, + length: 4096, + }], + ); + + let mut buffer = Vec::new(); + header.write_to(&mut buffer).unwrap(); + + let restored = BDiffHeader::read_from(Cursor::new(buffer)).unwrap(); + assert_eq!(restored, header); + } + + #[test] + fn header_rejects_invalid_magic() { + let bad = BDiffHeader::new(1, 1, vec![]); + let mut bad = bad; + bad.magic = *b"NOTBDIFF"; + + let mut buffer = Vec::new(); + bad.write_to(&mut buffer).unwrap(); + + let err = BDiffHeader::read_from(Cursor::new(buffer)).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + } + + #[test] + fn padding_plus_header_is_block_aligned() { + let header = BDiffHeader::new(1_048_576, 1_048_576, vec![]); + let header_size = header.serialized_size().unwrap(); + let padding = block_padding_size(header_size); + assert_eq!((header_size + padding) % BLOCK_SIZE, 0); + } +} diff --git a/src/main.rs b/src/main.rs index 6a269b3..b9c11c5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ +use blockdiff::{block_padding_size, format_size, BDiffHeader, DiffRange}; use clap::{Parser, Subcommand}; use nix::fcntl::copy_file_range; -use serde::{Deserialize, Serialize}; use std::fs::File; use std::io::{Error, Read, Seek, Write}; use std::os::fd::AsRawFd; @@ -43,90 +43,6 @@ enum Commands { }, } -/// Represents a range in the target file that's different from the base file (as indicated by the CoW metadata) -#[derive(Debug, Serialize, Deserialize)] -struct DiffRange { - logical_offset: u64, - length: u64, -} - -/// Magic string for bdiff files ("BDIFFv1\0") -const MAGIC: &[u8; 8] = b"BDIFFv1\0"; - -/// Standard block size used for alignment (4 KiB) -const BLOCK_SIZE: usize = 4096; - -/// Represents the header of a bdiff file. The file format is: -/// - Header: -/// - 8 bytes: magic string ("BDIFFv1\0") -/// - 8 bytes: target file size (little-endian) -/// - 8 bytes: base file size (little-endian) -/// - 8 bytes: number of ranges (little-endian) -/// - Ranges array, each range containing: -/// - 8 bytes: logical offset (little-endian) -/// - 8 bytes: length (little-endian) -/// - Padding to next block boundary (4 KiB) -/// - Range data (contiguous blocks of data) -#[derive(Debug, Serialize, Deserialize)] -struct BDiffHeader { - magic: [u8; 8], - target_size: u64, - base_size: u64, - ranges: Vec, -} - -impl BDiffHeader { - fn new(target_size: u64, base_size: u64, ranges: Vec) -> Self { - Self { - magic: *MAGIC, - target_size, - base_size, - ranges, - } - } - - fn write_to(&self, writer: impl Write) -> Result<(), Error> { - bincode::serialize_into(writer, self).map_err(|e| Error::new(std::io::ErrorKind::Other, e)) - } - - fn read_from(reader: impl Read) -> Result { - let header: Self = bincode::deserialize_from(reader) - .map_err(|e| Error::new(std::io::ErrorKind::Other, e))?; - - if header.magic != *MAGIC { - return Err(Error::new( - std::io::ErrorKind::InvalidData, - "Invalid bdiff file format", - )); - } - - Ok(header) - } -} - -fn format_size(bytes: u64) -> String { - const UNITS: [&str; 6] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; - - if bytes == 0 { - return "0 B".to_string(); - } - - // Find the appropriate unit (how many times can we divide by 1024) - let exp = (bytes as f64).ln() / 1024_f64.ln(); - let exp = exp.floor() as usize; - let exp = exp.min(UNITS.len() - 1); // Don't exceed available units - - // Convert to the chosen unit - let bytes = bytes as f64 / (1024_u64.pow(exp as u32) as f64); - - // Format with 1 decimal place if >= 1024 bytes, otherwise no decimal - if exp == 0 { - format!("{} {}", bytes.round(), UNITS[exp]) - } else { - format!("{:.1} {}", bytes, UNITS[exp]) - } -} - fn get_different_ranges( target_file: &str, base_file: Option<&str>, @@ -142,7 +58,6 @@ fn get_different_ranges( use fiemap::FiemapExtentFlags as Flags; let unsafe_flags = [ - // Flags that indicate the file needs syncing ( Flags::DELALLOC, "File has pending delayed allocations. Please sync file and try again", @@ -155,7 +70,6 @@ fn get_different_ranges( Flags::NOT_ALIGNED, "File has unaligned extents. Please sync file and try again", ), - // Flags that indicate unsupported features ( Flags::UNKNOWN, "Data location is unknown which is not supported", @@ -199,25 +113,20 @@ fn get_different_ranges( fiemap::fiemap(base_file.unwrap())?.collect::, _>>()?; base_extents.sort_by_key(|e| e.fe_logical); - // Total size of target file let total_size: u64 = target_extents.iter().map(|e| e.fe_length).sum(); println!("Size of target file: {}", format_size(total_size)); - // Total size of base file let total_size: u64 = base_extents.iter().map(|e| e.fe_length).sum(); println!("Size of base file: {}", format_size(total_size)); - // A helper closure for getting the end of any extent quickly let extent_end = |e: &fiemap::FiemapExtent| e.fe_logical + e.fe_length; - // Index for base_extents let mut i = 0; 'target_loop: for target_extent in target_extents { let mut current_start = target_extent.fe_logical; let mut current_remaining = target_extent.fe_length; - // If this is a non-shared extent, it's entirely different. if !target_extent .fe_flags .contains(fiemap::FiemapExtentFlags::SHARED) @@ -229,27 +138,22 @@ fn get_different_ranges( continue; } - // Shared extent: we need to check partial overlaps with base_extents while current_remaining > 0 { - // Skip any base extents that end before our current offset while i < base_extents.len() && extent_end(&base_extents[i]) <= current_start { i += 1; } - // If we've consumed all base extents, everything left is different if i >= base_extents.len() { diff_ranges.push(DiffRange { logical_offset: current_start, length: current_remaining, }); - continue 'target_loop; // Move on to the next target extent + continue 'target_loop; } - // Now, base_extents[i] is the first base extent that could overlap our target_extent let base_extent = &base_extents[i]; let base_start = base_extent.fe_logical; let base_end = extent_end(base_extent); - // If base_start > current_start, there's a gap in base coverage. Mark the gap as different. if base_start > current_start { let gap_len = (base_start - current_start).min(current_remaining); diff_ranges.push(DiffRange { @@ -259,16 +163,13 @@ fn get_different_ranges( current_start += gap_len; current_remaining -= gap_len; if current_remaining == 0 { - // done with this target extent continue 'target_loop; } } - // Compute overlap boundaries let overlap_start = current_start.max(base_start); let overlap_end = (current_start + current_remaining).min(base_end); - // If there's no overlap, then the remainder of target_extent is all different if overlap_start >= overlap_end { diff_ranges.push(DiffRange { logical_offset: current_start, @@ -277,30 +178,24 @@ fn get_different_ranges( continue 'target_loop; } - // Physical offset for each file at overlap_start let current_physical_start = target_extent.fe_physical + (overlap_start - target_extent.fe_logical); let base_physical_start = base_extent.fe_physical + (overlap_start - base_extent.fe_logical); let overlap_len = overlap_end - overlap_start; - // If physical offsets match, we consider that region "the same" and skip it if current_physical_start == base_physical_start { - // "Consume" this overlap (not added to diff) current_start = overlap_end; current_remaining -= overlap_len; } else { - // This overlap is different diff_ranges.push(DiffRange { logical_offset: overlap_start, length: overlap_len, }); - // Move past the overlap in the target current_start = overlap_end; current_remaining -= overlap_len; } - // If we've consumed the entire base extent in that overlap, move on if overlap_end == base_end { i += 1; } @@ -310,8 +205,6 @@ fn get_different_ranges( Ok(diff_ranges) } -/// Copies all bytes from src_fd to dst_fd, handling partial copies and interrupts. -/// Returns the total number of bytes copied. fn copy_range( src_fd: std::os::unix::io::RawFd, mut src_offset: Option<&mut i64>, @@ -352,7 +245,6 @@ fn create_diff( target_file: &str, base_file: Option<&str>, ) -> Result<(), Error> { - // 1) Open the target file so we can copy bytes from it later let target = File::open(target_file).map_err(|e| { Error::new( e.kind(), @@ -360,7 +252,6 @@ fn create_diff( ) })?; - // Sync the target file to ensure all delayed allocations are resolved nix::unistd::fsync(target.as_raw_fd()).map_err(|e| Error::new(std::io::ErrorKind::Other, e))?; let base = if let Some(base) = base_file { @@ -381,12 +272,10 @@ fn create_diff( let target_size = target.metadata()?.len(); let base_size = base.metadata()?.len(); - // 2) Compute the diff ranges let diff_ranges = get_different_ranges(target_file, base_file)?; let total_size: u64 = diff_ranges.iter().map(|range| range.length).sum(); println!("Size of blockdiff: {}", format_size(total_size)); - // 3) Create the bdiff file let mut diff_out = File::create(bdiff_output).map_err(|e| { Error::new( e.kind(), @@ -394,18 +283,12 @@ fn create_diff( ) })?; - // 4) Create and write the header let header = BDiffHeader::new(target_size, base_size, diff_ranges); header.write_to(&mut diff_out)?; - // 5) Pad with zeros to align header to block boundary - let header_size = bincode::serialized_size(&header) - .map_err(|e| Error::new(std::io::ErrorKind::Other, e))? as usize; - let padding_size = (BLOCK_SIZE - (header_size % BLOCK_SIZE)) % BLOCK_SIZE; - let padding = vec![0u8; padding_size]; - diff_out.write_all(&padding)?; + let padding_size = block_padding_size(header.serialized_size()?); + diff_out.write_all(&vec![0u8; padding_size])?; - // 6) Write all data blocks contiguously after the header for range in &header.ranges { let mut off_in = range.logical_offset as i64; @@ -420,8 +303,10 @@ fn create_diff( if copied != range.length as usize { return Err(Error::new( std::io::ErrorKind::UnexpectedEof, - format!("Failed to copy all requested bytes for range {:?}: copied {} bytes, expected {}", - range, copied, range.length) + format!( + "Failed to copy all requested bytes for range {:?}: copied {} bytes, expected {}", + range, copied, range.length + ), )); } } @@ -432,7 +317,6 @@ fn create_diff( } fn apply_diff(bdiff_input: &str, target_file: &str, base_file: Option<&str>) -> Result<(), Error> { - // Open the diff file and read header let mut diff_in = File::open(bdiff_input).map_err(|e| { Error::new( e.kind(), @@ -441,7 +325,6 @@ fn apply_diff(bdiff_input: &str, target_file: &str, base_file: Option<&str>) -> })?; let header = BDiffHeader::read_from(&mut diff_in)?; - // Create target file (either as reflink copy of base or empty sparse file) let target = File::options() .write(true) .create(true) @@ -454,7 +337,6 @@ fn apply_diff(bdiff_input: &str, target_file: &str, base_file: Option<&str>) -> })?; if let Some(base) = base_file { - // Create as reflink copy of base let src = File::open(base).map_err(|e| { Error::new( e.kind(), @@ -467,8 +349,10 @@ fn apply_diff(bdiff_input: &str, target_file: &str, base_file: Option<&str>) -> if copied != total_len { return Err(Error::new( std::io::ErrorKind::UnexpectedEof, - format!("Failed to create target file {} as copy of base file {}: copied {} bytes, expected {}", - target_file, base, copied, total_len) + format!( + "Failed to create target file {} as copy of base file {}: copied {} bytes, expected {}", + target_file, base, copied, total_len + ), )); } @@ -477,7 +361,6 @@ fn apply_diff(bdiff_input: &str, target_file: &str, base_file: Option<&str>) -> target_file ); - // Check if target size differs from base size and resize if needed if header.target_size != header.base_size { println!( "Note: target file size differs from base file size: {} -> {}", @@ -487,7 +370,6 @@ fn apply_diff(bdiff_input: &str, target_file: &str, base_file: Option<&str>) -> target.set_len(header.target_size)?; } } else { - // Create empty sparse file of target size target.set_len(header.target_size)?; println!( "Initialized target file as empty sparse file of size {} at: {}", @@ -496,13 +378,9 @@ fn apply_diff(bdiff_input: &str, target_file: &str, base_file: Option<&str>) -> ); } - // Skip padding to align with block boundary - let header_size = bincode::serialized_size(&header) - .map_err(|e| Error::new(std::io::ErrorKind::Other, e))? as usize; - let padding_size = (BLOCK_SIZE - (header_size % BLOCK_SIZE)) % BLOCK_SIZE; + let padding_size = block_padding_size(header.serialized_size()?); diff_in.seek(std::io::SeekFrom::Current(padding_size as i64))?; - // Apply each range for range in header.ranges { let mut off_out = range.logical_offset as i64; let copied = copy_range( @@ -516,8 +394,10 @@ fn apply_diff(bdiff_input: &str, target_file: &str, base_file: Option<&str>) -> if copied != range.length as usize { return Err(Error::new( std::io::ErrorKind::UnexpectedEof, - format!("Failed to copy all requested bytes for range {:?}: copied {} bytes, expected {}", - range, copied, range.length) + format!( + "Failed to copy all requested bytes for range {:?}: copied {} bytes, expected {}", + range, copied, range.length + ), )); } } @@ -528,9 +408,7 @@ fn apply_diff(bdiff_input: &str, target_file: &str, base_file: Option<&str>) -> } fn debug_viewer(input_file: &str, offset_str: Option<&str>) -> Result<(), Error> { - // Parse the hex offset if provided let filter_offset = if let Some(off_str) = offset_str { - // Remove "0x" prefix if present and parse let cleaned = off_str.trim_start_matches("0x"); Some(u64::from_str_radix(cleaned, 16).map_err(|e| { Error::new( @@ -562,14 +440,12 @@ fn debug_viewer(input_file: &str, offset_str: Option<&str>) -> Result<(), Error> println!("\nRanges:"); if let Some(offset) = filter_offset { - // Find the range containing the offset let containing_idx = header .ranges .iter() .position(|r| r.logical_offset <= offset && offset < r.logical_offset + r.length); if let Some(idx) = containing_idx { - // Show 3 ranges before and after let start_idx = idx.saturating_sub(3); let end_idx = (idx + 4).min(header.ranges.len()); @@ -588,7 +464,6 @@ fn debug_viewer(input_file: &str, offset_str: Option<&str>) -> Result<(), Error> println!(" No range contains offset {:#x}", offset); } } else { - // Show all ranges when no filter for (i, range) in header.ranges.iter().enumerate() { println!( " {}: offset={:#x} length={:#x} ({})", @@ -611,13 +486,11 @@ fn debug_viewer(input_file: &str, offset_str: Option<&str>) -> Result<(), Error> println!("\nExtents:"); if let Some(offset) = filter_offset { - // Find the extent containing the offset let containing_idx = extents .iter() .position(|e| e.fe_logical <= offset && offset < e.fe_logical + e.fe_length); if let Some(idx) = containing_idx { - // Show 3 extents before and after let start_idx = idx.saturating_sub(3); let end_idx = (idx + 4).min(extents.len()); @@ -638,7 +511,6 @@ fn debug_viewer(input_file: &str, offset_str: Option<&str>) -> Result<(), Error> println!(" No extent contains offset {:#x}", offset); } } else { - // Show all extents when no filter for (i, extent) in extents.iter().enumerate() { println!( " {}: logical={:#x} physical={:#x} length={:#x} ({}) flags={:?}", diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs new file mode 100644 index 0000000..ace0d8b --- /dev/null +++ b/tests/roundtrip.rs @@ -0,0 +1,106 @@ +//! Linux integration tests for create → apply roundtrips. +//! Requires a filesystem that supports fiemap (ext4/xfs/btrfs). + +#![cfg(target_os = "linux")] + +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::process::Command; + +fn blockdiff_bin() -> String { + env!("CARGO_BIN_EXE_blockdiff").to_string() +} + +fn write_bytes(path: &std::path::Path, data: &[u8]) { + let mut file = File::create(path).unwrap(); + file.write_all(data).unwrap(); + file.sync_all().unwrap(); +} + +#[test] +fn compactify_sparse_file_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("target.img"); + let compact = dir.path().join("compact.bdiff"); + let restored = dir.path().join("restored.img"); + + // Sparse-ish file: 64 KiB logical size, 4 KiB of payload at offset 0. + write_bytes(&target, b"blockdiff-roundtrip-test-data!!!"); + + let bin = blockdiff_bin(); + let status = Command::new(&bin) + .args([ + "create", + compact.to_str().unwrap(), + target.to_str().unwrap(), + ]) + .status() + .unwrap(); + assert!(status.success(), "blockdiff create failed"); + + let status = Command::new(&bin) + .args([ + "apply", + compact.to_str().unwrap(), + restored.to_str().unwrap(), + ]) + .status() + .unwrap(); + assert!(status.success(), "blockdiff apply failed"); + + let mut original = Vec::new(); + File::open(&target).unwrap().read_to_end(&mut original).unwrap(); + let mut roundtripped = Vec::new(); + File::open(&restored) + .unwrap() + .read_to_end(&mut roundtripped) + .unwrap(); + + assert_eq!(original, roundtripped); +} + +#[test] +fn create_apply_with_base_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let base = dir.path().join("base.img"); + let target = dir.path().join("target.img"); + let diff = dir.path().join("delta.bdiff"); + let restored = dir.path().join("restored.img"); + + write_bytes(&base, b"base-content-0123456789abcdef"); + write_bytes(&target, b"base-content-0123456789TARGET!!"); + + let bin = blockdiff_bin(); + let status = Command::new(&bin) + .args([ + "create", + diff.to_str().unwrap(), + target.to_str().unwrap(), + "--base", + base.to_str().unwrap(), + ]) + .status() + .unwrap(); + assert!(status.success(), "blockdiff create with base failed"); + + fs::copy(&base, &restored).unwrap(); + + let status = Command::new(&bin) + .args([ + "apply", + diff.to_str().unwrap(), + restored.to_str().unwrap(), + "--base", + base.to_str().unwrap(), + ]) + .status() + .unwrap(); + assert!(status.success(), "blockdiff apply with base failed"); + + let mut expected = Vec::new(); + File::open(&target).unwrap().read_to_end(&mut expected).unwrap(); + let mut actual = Vec::new(); + File::open(&restored).unwrap().read_to_end(&mut actual).unwrap(); + + assert_eq!(expected, actual); +}