an efficient binary archive format
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

add ability to overwrite keys, and vacuum function

zach 2c93f9d1 170d2a25

+286 -78
+29 -4
bindle.h
··· 12 12 #include <stdint.h> 13 13 #include <stdbool.h> 14 14 15 + typedef enum BindleCompress { 16 + BindleCompressNone, 17 + BindleCompressZstd, 18 + } BindleCompress; 19 + 15 20 typedef struct Bindle Bindle; 16 21 22 + /** 23 + * Open a bindle file from disk, the path paramter should be NUL terminated 24 + */ 17 25 struct Bindle *bindle_open(const char *path); 18 26 19 27 /** 20 - * Adds a new entry. Returns true on success. 28 + * Adds a new entry, the name should be NUL terminated, will the data can contain NUL characters since the length 29 + * is provided 21 30 */ 22 31 bool bindle_add(struct Bindle *ctx, 23 32 const char *name, 24 33 const uint8_t *data, 25 34 size_t data_len, 26 - bool compress); 35 + enum BindleCompress compress); 27 36 28 37 /** 29 - * Commits changes to disk. 38 + * Save any changed to disk 30 39 */ 31 40 bool bindle_save(struct Bindle *ctx); 32 41 33 42 /** 34 - * Frees BindleContext 43 + * Close an open bindle file 35 44 */ 36 45 void bindle_close(struct Bindle *ctx); 37 46 47 + /** 48 + * Read a value from a bindle file in memory, returns a pointer that should be freed with 49 + * `bindle_free_buffer` 50 + */ 38 51 uint8_t *bindle_read(struct Bindle *ctx_ptr, const char *name, size_t *out_len); 39 52 53 + /** 54 + * Used to free the results from `bindle_read` 55 + */ 40 56 void bindle_free_buffer(uint8_t *ptr); 41 57 58 + /** 59 + * Directly read an uncompressed entry from disk, returns NULL if the entry is compressed or doesn't exist 60 + */ 42 61 const uint8_t *bindle_read_uncompressed_direct(struct Bindle *ctx, 43 62 const char *name, 44 63 size_t *out_len); 45 64 65 + /** 66 + * Get the number of entries in a bindle file 67 + */ 46 68 size_t bindle_length(const struct Bindle *ctx); 47 69 48 70 /** ··· 51 73 */ 52 74 const char *bindle_entry_name(const struct Bindle *ctx, size_t index, size_t *len); 53 75 76 + /** 77 + * Compact and rewrite bindle file 78 + */ 54 79 bool bindle_vacuum(struct Bindle *ctx); 55 80 56 81 #endif /* BINDLE_H */
+5 -2
c/Makefile
··· 1 1 .PHONY: test 2 - test: 2 + test: check 3 + python3 test.py a.bindl 4 + 5 + 6 + check: 3 7 clang -I .. -o bindle_c bindle-cli.c bindle.c `pkg-config --cflags --libs libzstd` 4 - python3 test.py a.bindl 5 8
+74 -32
c/bindle.c
··· 46 46 47 47 Bindle *bindle_open(const char *path) { 48 48 FILE *fp = fopen(path, "r+b"); 49 - if (!fp) 49 + if (!fp) { 50 50 fp = fopen(path, "w+b"); 51 + } 51 52 if (!fp) 52 53 return NULL; 53 54 ··· 101 102 } 102 103 103 104 bool bindle_add(Bindle *b, const char *name, const uint8_t *data, size_t len, 104 - bool compress) { 105 + BindleCompress compress) { 105 106 if (!b || !name) 106 107 return false; 107 108 ··· 109 110 void *write_ptr = (void *)data; 110 111 void *comp_buf = NULL; 111 112 112 - if (compress) { 113 + if (compress == BindleCompressZstd) { 113 114 size_t bound = ZSTD_compressBound(len); 114 115 comp_buf = malloc(bound); 115 116 c_size = ZSTD_compress(comp_buf, bound, data, len, 3); ··· 150 151 b->entries = realloc(b->entries, sizeof(BindleEntry) * (b->count + 1)); 151 152 BindleEntry *e = &b->entries[b->count++]; 152 153 e->name = strdup(name); 153 - e->meta = (BindleEntryRaw){ 154 - offset, c_size, len, 0, (uint16_t)strlen(name), compress ? 1 : 0, 0}; 154 + e->meta = (BindleEntryRaw){offset, c_size, len, 0, (uint16_t)strlen(name), 155 + compress, 0}; 155 156 156 157 if (comp_buf) 157 158 free(comp_buf); ··· 166 167 fseek(b->fp, m->offset, SEEK_SET); 167 168 fread(c_buf, 1, m->compressed_size, b->fp); 168 169 169 - if (m->compression_type == 1) { 170 + if (m->compression_type == BindleCompressZstd) { 170 171 uint8_t *u_buf = malloc(m->uncompressed_size); 171 172 size_t actual = ZSTD_decompress(u_buf, m->uncompressed_size, c_buf, 172 173 m->compressed_size); ··· 186 187 for (uint64_t i = 0; i < b->count; i++) { 187 188 if (strcmp(b->entries[i].name, name) == 0) { 188 189 BindleEntryRaw *m = &b->entries[i].meta; 189 - if (m->compression_type != 0) 190 + if (m->compression_type != BindleCompressNone) 190 191 return NULL; 191 192 192 193 uint8_t *buf = malloc(m->uncompressed_size); ··· 222 223 return true; 223 224 } 224 225 226 + size_t bindle_length(const Bindle *b) { return b ? b->count : 0; } 227 + 228 + const char *bindle_entry_name(const Bindle *b, size_t index, size_t *namelen) { 229 + if (!b || index >= b->count) 230 + return NULL; 231 + *namelen = b->entries[index].meta.name_len; 232 + return b->entries[index].name; 233 + } 234 + 235 + void bindle_free_buffer(uint8_t *ptr) { free(ptr); } 236 + 237 + void bindle_close(Bindle *b) { 238 + if (!b) 239 + return; 240 + flock(fileno(b->fp), LOCK_UN); 241 + for (uint64_t i = 0; i < b->count; i++) 242 + free(b->entries[i].name); 243 + free(b->entries); 244 + fclose(b->fp); 245 + free(b->path); 246 + free(b); 247 + } 248 + 225 249 bool bindle_vacuum(Bindle *b) { 250 + if (!b) 251 + return false; 252 + 226 253 char tmp_path[1024]; 227 254 snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", b->path); 228 255 FILE *out = fopen(tmp_path, "wb"); 229 256 if (!out) 230 257 return false; 231 258 259 + // 1. Write Header 232 260 fwrite(BNDL_MAGIC, 8, 1, out); 233 261 uint64_t current_offset = 8; 234 262 263 + // 2. Copy Live Data to Temp File 235 264 for (uint64_t i = 0; i < b->count; i++) { 236 265 uint64_t size = b->entries[i].meta.compressed_size; 237 266 uint8_t *buf = malloc(size); 267 + 238 268 fseek(b->fp, b->entries[i].meta.offset, SEEK_SET); 239 269 fread(buf, 1, size, b->fp); 240 270 241 271 fseek(out, current_offset, SEEK_SET); 242 272 fwrite(buf, 1, size, out); 243 273 274 + // Update the in-memory metadata with the new offset 244 275 b->entries[i].meta.offset = current_offset; 245 276 246 277 size_t pad = ALIGN_UP(size, BNDL_ALIGN) - size; ··· 252 283 free(buf); 253 284 } 254 285 255 - fclose(b->fp); 256 - b->fp = out; 257 - b->data_end = current_offset; 258 - bindle_save(b); // Finalize index in new file 286 + // 3. Write Index and Footer to the Temp File (Matching Rust .save() logic) 287 + uint64_t index_start = current_offset; 288 + for (uint64_t i = 0; i < b->count; i++) { 289 + fwrite(&b->entries[i].meta, sizeof(BindleEntryRaw), 1, out); 290 + fwrite(b->entries[i].name, 1, b->entries[i].meta.name_len, out); 259 291 260 - rename(tmp_path, b->path); 261 - return true; 262 - } 292 + size_t consumed = sizeof(BindleEntryRaw) + b->entries[i].meta.name_len; 293 + size_t pad = ALIGN_UP(consumed, BNDL_ALIGN) - consumed; 294 + if (pad > 0) { 295 + uint8_t zero[8] = {0}; 296 + fwrite(zero, 1, pad, out); 297 + } 298 + } 299 + 300 + BindleFooterRaw footer = {index_start, b->count}; 301 + fwrite(&footer, sizeof(BindleFooterRaw), 1, out); 302 + 303 + // 4. CRITICAL: Close and Unlock handles before Rename 304 + fflush(out); 305 + fclose(out); // Close the temp file handle 306 + 307 + flock(fileno(b->fp), LOCK_UN); // Explicitly unlock the original file 308 + fclose(b->fp); // Close the original file handle 309 + b->fp = NULL; 263 310 264 - size_t bindle_length(const Bindle *b) { return b ? b->count : 0; } 311 + // 5. Atomic Rename 312 + if (rename(tmp_path, b->path) != 0) { 313 + // If rename fails, we are in a bad state; attempt to re-open original 314 + b->fp = fopen(b->path, "r+b"); 315 + return false; 316 + } 265 317 266 - const char *bindle_entry_name(const Bindle *b, size_t index, size_t *namelen) { 267 - if (!b || index >= b->count) 268 - return NULL; 269 - *namelen = b->entries[index].meta.name_len; 270 - return b->entries[index].name; 271 - } 318 + // 6. Re-open the new primary file 319 + b->fp = fopen(b->path, "r+b"); 320 + if (!b->fp) 321 + return false; 272 322 273 - void bindle_free_buffer(uint8_t *ptr) { free(ptr); } 323 + flock(fileno(b->fp), LOCK_SH); 324 + b->data_end = index_start; 274 325 275 - void bindle_close(Bindle *b) { 276 - if (!b) 277 - return; 278 - flock(fileno(b->fp), LOCK_UN); 279 - for (uint64_t i = 0; i < b->count; i++) 280 - free(b->entries[i].name); 281 - free(b->entries); 282 - fclose(b->fp); 283 - free(b->path); 284 - free(b); 326 + return true; 285 327 }
+8
cbindgen.toml
··· 4 4 sys_includes = ["stddef.h", "stdint.h", "stdbool.h"] 5 5 usize_is_size_t = true 6 6 7 + [enum] 8 + prefix_with_name = true 9 + rename_variants = "PascalCase" 10 + 11 + [export.rename] 12 + "Footer" = "BindleFooter" 13 + "Entry" = "BindleEntry" 14 + "Compress" = "BindleCompress"
+15 -6
src/ffi.rs
··· 4 4 use std::os::raw::c_char; 5 5 use std::slice; 6 6 7 - use crate::Bindle; 7 + use crate::{Bindle, Compress}; 8 8 9 + /// Open a bindle file from disk, the path paramter should be NUL terminated 9 10 #[unsafe(no_mangle)] 10 11 pub unsafe extern "C" fn bindle_open(path: *const c_char) -> *mut Bindle { 11 12 if path.is_null() { ··· 26 27 } 27 28 } 28 29 29 - /// Adds a new entry. Returns true on success. 30 + /// Adds a new entry, the name should be NUL terminated, will the data can contain NUL characters since the length 31 + /// is provided 30 32 #[unsafe(no_mangle)] 31 33 pub unsafe extern "C" fn bindle_add( 32 34 ctx: *mut Bindle, 33 35 name: *const c_char, 34 36 data: *const u8, 35 37 data_len: usize, 36 - compress: bool, 38 + compress: Compress, 37 39 ) -> bool { 38 40 if ctx.is_null() || name.is_null() || (data.is_null() && data_len > 0) { 39 41 return false; ··· 48 50 let data_slice = slice::from_raw_parts(data, data_len); 49 51 let b = &mut (*ctx); 50 52 51 - b.add(name_str, data_slice, compress).is_ok() 53 + b.add(name_str, data_slice, compress == Compress::Zstd) 54 + .is_ok() 52 55 } 53 56 } 54 57 55 - /// Commits changes to disk. 58 + /// Save any changed to disk 56 59 #[unsafe(no_mangle)] 57 60 pub unsafe extern "C" fn bindle_save(ctx: *mut Bindle) -> bool { 58 61 if ctx.is_null() { ··· 64 67 } 65 68 } 66 69 67 - /// Frees BindleContext 70 + /// Close an open bindle file 68 71 #[unsafe(no_mangle)] 69 72 pub unsafe extern "C" fn bindle_close(ctx: *mut Bindle) { 70 73 if ctx.is_null() { ··· 73 76 unsafe { drop(Box::from_raw(ctx)) } 74 77 } 75 78 79 + /// Read a value from a bindle file in memory, returns a pointer that should be freed with 80 + /// `bindle_free_buffer` 76 81 #[unsafe(no_mangle)] 77 82 pub unsafe extern "C" fn bindle_read( 78 83 ctx_ptr: *mut Bindle, ··· 132 137 } 133 138 } 134 139 140 + /// Used to free the results from `bindle_read` 135 141 #[unsafe(no_mangle)] 136 142 pub unsafe extern "C" fn bindle_free_buffer(ptr: *mut u8) { 137 143 unsafe { ··· 156 162 } 157 163 } 158 164 165 + /// Directly read an uncompressed entry from disk, returns NULL if the entry is compressed or doesn't exist 159 166 #[unsafe(no_mangle)] 160 167 pub unsafe extern "C" fn bindle_read_uncompressed_direct( 161 168 ctx: *mut Bindle, ··· 184 191 } 185 192 } 186 193 194 + /// Get the number of entries in a bindle file 187 195 #[unsafe(no_mangle)] 188 196 pub unsafe extern "C" fn bindle_length(ctx: *const Bindle) -> usize { 189 197 if ctx.is_null() { ··· 216 224 } 217 225 } 218 226 227 + /// Compact and rewrite bindle file 219 228 #[unsafe(no_mangle)] 220 229 pub unsafe extern "C" fn bindle_vacuum(ctx: *mut Bindle) -> bool { 221 230 if ctx.is_null() {
+155 -34
src/lib.rs
··· 7 7 use std::path::{Path, PathBuf}; 8 8 use zerocopy::{FromBytes, Immutable, IntoBytes, Unaligned}; 9 9 10 - mod ffi; 10 + pub(crate) mod ffi; 11 11 12 12 const BNDL_MAGIC: &[u8; 8] = b"BINDL001"; 13 13 const BNDL_ALIGN: usize = 8; 14 - const ENTRY_SIZE: usize = std::mem::size_of::<BindleEntry>(); 15 - const FOOTER_SIZE: usize = std::mem::size_of::<BindleFooter>(); 14 + const ENTRY_SIZE: usize = std::mem::size_of::<Entry>(); 15 + const FOOTER_SIZE: usize = std::mem::size_of::<Footer>(); 16 16 const HEADER_SIZE: u64 = 8; 17 17 18 + #[repr(C)] 19 + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] 20 + pub enum Compress { 21 + #[default] 22 + None, 23 + Zstd, 24 + } 25 + 18 26 #[repr(C, packed)] 19 27 #[derive(FromBytes, Unaligned, IntoBytes, Immutable, Clone, Copy, Debug, Default)] 20 - pub struct BindleEntry { 28 + pub struct Entry { 21 29 pub offset: [u8; 8], // Use [u8; 8] for disk stability 22 30 pub compressed_size: [u8; 8], 23 31 pub uncompressed_size: [u8; 8], ··· 28 36 } 29 37 30 38 // Add helpers to convert back to numbers for Rust logic 31 - impl BindleEntry { 39 + impl Entry { 32 40 pub fn offset(&self) -> u64 { 33 41 u64::from_le_bytes(self.offset) 34 42 } ··· 44 52 pub fn name_len(&self) -> usize { 45 53 u16::from_le_bytes(self.name_len) as usize 46 54 } 55 + 56 + pub fn compression_type(&self) -> Compress { 57 + match self.compression_type { 58 + 0 => Compress::None, 59 + 1 => Compress::Zstd, 60 + _ => Compress::default(), 61 + } 62 + } 47 63 } 48 64 49 65 #[repr(C, packed)] 50 66 #[derive(FromBytes, Unaligned, IntoBytes, Immutable, Debug)] 51 - struct BindleFooter { 67 + struct Footer { 52 68 pub index_offset: u64, 53 69 pub entry_count: u64, 54 70 } ··· 57 73 path: PathBuf, 58 74 file: File, 59 75 mmap: Option<Mmap>, 60 - index: BTreeMap<String, BindleEntry>, 76 + index: BTreeMap<String, Entry>, 61 77 data_end: u64, 62 78 } 63 79 ··· 91 107 92 108 let m = unsafe { Mmap::map(&file)? }; 93 109 let footer_pos = m.len() - FOOTER_SIZE; 94 - let footer = BindleFooter::read_from_bytes(&m[footer_pos..]).unwrap(); 110 + let footer = Footer::read_from_bytes(&m[footer_pos..]).unwrap(); 95 111 96 112 let data_end = footer.index_offset; 97 113 let count = footer.entry_count; ··· 99 115 100 116 let mut cursor = data_end as usize; 101 117 for _ in 0..count { 102 - let entry = BindleEntry::read_from_bytes(&m[cursor..cursor + ENTRY_SIZE]).unwrap(); 118 + let entry = Entry::read_from_bytes(&m[cursor..cursor + ENTRY_SIZE]).unwrap(); 103 119 let n_start = cursor + ENTRY_SIZE; 104 120 let name = 105 121 String::from_utf8_lossy(&m[n_start..n_start + entry.name_len()]).into_owned(); ··· 136 152 137 153 self.data_end = offset + c_size + pad; 138 154 139 - let entry = BindleEntry { 155 + let entry = Entry { 140 156 offset: offset.to_le_bytes(), 141 157 compressed_size: c_size.to_le_bytes(), 142 158 uncompressed_size: (data.len() as u64).to_le_bytes(), ··· 163 179 } 164 180 } 165 181 166 - let footer = BindleFooter { 182 + let footer = Footer { 167 183 index_offset: index_start, 168 184 entry_count: self.index.len() as u64, 169 185 }; ··· 176 192 177 193 pub fn vacuum(&mut self) -> io::Result<()> { 178 194 let tmp_path = self.path.with_extension("tmp"); 179 - let mut new_file = OpenOptions::new() 180 - .write(true) 181 - .create(true) 182 - .truncate(true) 183 - .open(&tmp_path)?; 184 - new_file.write_all(BNDL_MAGIC)?; 185 - let mut current_offset = HEADER_SIZE; 195 + 196 + // 1. Create and populate the temporary file 197 + { 198 + let mut new_file = OpenOptions::new() 199 + .write(true) 200 + .create(true) 201 + .truncate(true) 202 + .open(&tmp_path)?; 203 + 204 + new_file.write_all(BNDL_MAGIC)?; 205 + let mut current_offset = HEADER_SIZE; 186 206 187 - for entry in self.index.values_mut() { 188 - let mut buf = vec![0u8; entry.compressed_size() as usize]; 189 - self.file.seek(SeekFrom::Start(entry.offset()))?; 190 - self.file.read_exact(&mut buf)?; 207 + // Copy only live entries to the new file 208 + for entry in self.index.values_mut() { 209 + let mut buf = vec![0u8; entry.compressed_size() as usize]; 210 + self.file.seek(SeekFrom::Start(entry.offset()))?; 211 + self.file.read_exact(&mut buf)?; 191 212 192 - new_file.seek(SeekFrom::Start(current_offset))?; 193 - new_file.write_all(&buf)?; 213 + new_file.seek(SeekFrom::Start(current_offset))?; 214 + new_file.write_all(&buf)?; 215 + 216 + entry.offset = current_offset.to_le_bytes(); 217 + let pad = (8 - (entry.compressed_size() % 8)) % 8; 218 + if pad > 0 { 219 + new_file.write_all(&vec![0u8; pad as usize])?; 220 + } 221 + current_offset += entry.compressed_size() + pad; 222 + } 194 223 195 - entry.offset = current_offset.to_le_bytes(); 196 - let pad = (8 - (entry.compressed_size() % 8)) % 8; 197 - if pad > 0 { 198 - new_file.write_all(&vec![0u8; pad as usize])?; 224 + // Write the index and footer to the TEMP file before closing it 225 + let index_start = current_offset; 226 + for (name, entry) in &self.index { 227 + new_file.write_all(entry.as_bytes())?; 228 + new_file.write_all(name.as_bytes())?; 229 + let pad = (BNDL_ALIGN - ((ENTRY_SIZE + name.len()) % BNDL_ALIGN)) % BNDL_ALIGN; 230 + if pad > 0 { 231 + new_file.write_all(&vec![0u8; pad])?; 232 + } 199 233 } 200 - current_offset += entry.compressed_size() + pad; 234 + 235 + let footer = Footer { 236 + index_offset: index_start, 237 + entry_count: self.index.len() as u64, 238 + }; 239 + new_file.write_all(footer.as_bytes())?; 240 + new_file.sync_all()?; 241 + // new_file is closed here when it goes out of scope 201 242 } 202 243 203 - self.data_end = current_offset; 204 - self.file = new_file; 205 - self.save()?; 206 - std::fs::rename(tmp_path, &self.path)?; 244 + // 2. CRITICAL: Release ALL handles to the original file 245 + drop(self.mmap.take()); 246 + let _ = self.file.unlock(); 247 + 248 + // Re-open self.file in a way that allows us to drop it immediately 249 + let old_file = std::mem::replace(&mut self.file, File::open(&tmp_path)?); 250 + drop(old_file); 251 + 252 + // 3. Perform the atomic rename while no handles point to the original path 253 + std::fs::rename(&tmp_path, &self.path)?; 254 + 255 + // 4. Re-establish the state for the Bindle struct 256 + let file = OpenOptions::new().read(true).write(true).open(&self.path)?; 257 + file.lock_shared()?; 258 + let mmap = unsafe { Mmap::map(&file)? }; 259 + 260 + let footer_pos = mmap.len() - FOOTER_SIZE; 261 + let footer = Footer::read_from_bytes(&mmap[footer_pos..]).unwrap(); 262 + 263 + self.file = file; 264 + self.mmap = Some(mmap); 265 + self.data_end = footer.index_offset; 266 + 207 267 Ok(()) 208 268 } 209 269 ··· 230 290 self.index.is_empty() 231 291 } 232 292 233 - pub fn index(&self) -> &BTreeMap<String, BindleEntry> { 293 + pub fn index(&self) -> &BTreeMap<String, Entry> { 234 294 &self.index 235 295 } 236 296 } ··· 330 390 331 391 let res = Bindle::open(path); 332 392 assert!(res.is_err()); 393 + 394 + fs::remove_file(path).ok(); 395 + } 396 + 397 + #[test] 398 + fn test_key_shadowing() { 399 + let path = "test_shadow.bindl"; 400 + let _ = fs::remove_file(path); 401 + 402 + let mut b = Bindle::open(path).expect("Failed to open"); 403 + 404 + // 1. Add initial version 405 + b.add("config.txt", b"v1", false).unwrap(); 406 + b.save().unwrap(); 407 + 408 + // 2. Overwrite with v2 (shadowing) 409 + b.add("config.txt", b"version_2_is_longer", false).unwrap(); 410 + b.save().unwrap(); 411 + 412 + // 3. Verify latest version is retrieved 413 + let b2 = Bindle::open(path).expect("Failed to reopen"); 414 + let result = b2.read("config.txt").unwrap(); 415 + assert_eq!(result.as_ref(), b"version_2_is_longer"); 416 + 417 + // 4. Verify index count hasn't grown (still 1 entry) 418 + assert_eq!(b2.len(), 1); 419 + 420 + fs::remove_file(path).ok(); 421 + } 422 + 423 + #[test] 424 + fn test_vacuum_reclaims_space() { 425 + let path = "test_vacuum.bindl"; 426 + let _ = fs::remove_file(path); 427 + 428 + let mut b = Bindle::open(path).expect("Failed to open"); 429 + 430 + // 1. Add a large file 431 + let large_data = vec![0u8; 1024]; 432 + b.add("large.bin", &large_data, false).unwrap(); 433 + b.save().unwrap(); 434 + let size_v1 = fs::metadata(path).unwrap().len(); 435 + 436 + // 2. Shadow it with a tiny file 437 + b.add("large.bin", b"tiny", false).unwrap(); 438 + b.save().unwrap(); 439 + let size_v2 = fs::metadata(path).unwrap().len(); 440 + 441 + // Size should have increased because we appended 'tiny' 442 + assert!(size_v2 > size_v1); 443 + 444 + // 3. Run Vacuum 445 + b.vacuum().expect("Vacuum failed"); 446 + let size_v3 = fs::metadata(path).unwrap().len(); 447 + 448 + // 4. Verify size is now significantly smaller (reclaimed 1024 bytes) 449 + assert!(size_v3 < size_v2); 450 + 451 + // 5. Verify data integrity after vacuum 452 + let b2 = Bindle::open(path).unwrap(); 453 + assert_eq!(b2.read("large.bin").unwrap().as_ref(), b"tiny"); 333 454 334 455 fs::remove_file(path).ok(); 335 456 }