Skip to main content

max / audiofiles

24.9 KB · 709 lines History Blame Raw
1 //! Flat-to-sharded blob migration, exercised headlessly at scale.
2 //!
3 //! The sweep in `audiofiles_core::store::layout` auto-starts at vault open on any
4 //! vault created before 2026-07-29, so the first real exercise of it lands on a
5 //! library someone cares about unless it is done deliberately first. Unit tests
6 //! cover the branches on two or three blobs each; they do not answer whether the
7 //! thing survives fifty thousand renames, whether an interrupted pass really
8 //! resumes, or whether the mirror's symlinks are still pointing at anything
9 //! afterwards.
10 //!
11 //! This mode fabricates flat vaults and falsifies those claims one at a time. It
12 //! is a checker first and a benchmark second: every scenario prints PASS or FAIL
13 //! and the process exits non-zero if any of them failed, so a run that scrolls
14 //! past unread still fails loudly in a pipeline.
15 //!
16 //! What it cannot cover, and what still needs a human at the app: the progress
17 //! strip's rendering above the footer, and browsing/preview/search staying usable
18 //! mid-sweep. The store-layer half of that second claim is covered here (every
19 //! blob resolves through a partial migration), but the GUI half is an eyeball
20 //! pass.
21 //!
22 //! Vaults are fabricated rather than imported: the sweep reads the filesystem, not
23 //! the DB, so blob provenance is irrelevant to it and a real import pass would
24 //! spend its time in hashing and per-file fsync instead. Blobs are chmod'd
25 //! read-only exactly as `import` leaves them, because two branches of the sweep
26 //! unlink a flat blob and that is the permission shape they will meet in the
27 //! field.
28 //!
29 //! Usage:
30 //! `cargo run --release -p audiofiles-bench -- layout`
31 //!
32 //! Env: `AF_BENCH_VAULT` (scratch vault root, default `<tmp>/af-bench-layout`),
33 //! `AF_BENCH_LAYOUT_N` (blobs in the timed sweep, default 50,000),
34 //! `AF_BENCH_JSON` (machine-readable output path).
35 //!
36 //! <!-- wiki: af-benchmarks -->
37
38 use std::path::{Path, PathBuf};
39 use std::sync::atomic::{AtomicBool, Ordering};
40 use std::time::Instant;
41
42 use audiofiles_core::config_key::ConfigKey;
43 use audiofiles_core::db::Database;
44 use audiofiles_core::id_types::SampleHash;
45 use audiofiles_core::store::layout::{
46 BlobLayout, LayoutMigration, count_flat_blobs, migrate_to_sharded, recorded_layout,
47 };
48 use audiofiles_core::store::{
49 SampleStore, existing_blob_path, hash_file, legacy_flat_blob_path, store_blob_path,
50 };
51 use audiofiles_core::vfs;
52 use audiofiles_core::vfs_mirror::{MirrorConfig, sync_mirror};
53
54 use crate::report::Report;
55 use crate::storage;
56
57 /// Blobs in each correctness scenario.
58 ///
59 /// Small on purpose: these check branches, not throughput, and every scenario
60 /// rebuilds its vault from scratch. Large enough that a cancellation lands in the
61 /// middle of a pass rather than racing the first blob.
62 const SCENARIO_BLOBS: usize = 2_000;
63
64 /// Blobs in the timed sweep unless `AF_BENCH_LAYOUT_N` says otherwise.
65 ///
66 /// Matches the af-bench-nsynth corpus on the T9 (53,286 blobs) closely enough to
67 /// stand in for it, so the throughput number here is comparable to what that vault
68 /// will do.
69 pub(crate) const DEFAULT_TIMED_BLOBS: usize = 50_000;
70
71 /// A fabricated blob: its content hash and extension.
72 type Blob = (String, String);
73
74 /// Deterministic blob content for index `i`.
75 ///
76 /// Length varies with the index so that a planted size mismatch is a real
77 /// difference in bytes rather than a difference the sweep could only see by
78 /// hashing, and so the fabricated vault is not one file repeated.
79 fn payload(i: usize) -> Vec<u8> {
80 let mut bytes = format!("af-bench-layout blob {i}\n").into_bytes();
81 bytes.resize(64 + (i % 97) * 8, b'\0');
82 bytes
83 }
84
85 /// Make a blob read-only, as `SampleStore::import` leaves every canonical blob.
86 ///
87 /// Best-effort for the same reason the store's own version is: a filesystem that
88 /// rejects the chmod must not fail the run. Unlinking still works either way,
89 /// since that needs write on the directory rather than on the file.
90 fn set_readonly(path: &Path) {
91 if let Ok(meta) = std::fs::metadata(path) {
92 let mut perms = meta.permissions();
93 #[cfg(unix)]
94 {
95 use std::os::unix::fs::PermissionsExt;
96 perms.set_mode(0o444);
97 }
98 #[cfg(not(unix))]
99 perms.set_readonly(true);
100 let _ = std::fs::set_permissions(path, perms);
101 }
102 }
103
104 /// A fabricated vault in the legacy flat layout.
105 struct FlatVault {
106 db: Database,
107 store: SampleStore,
108 blobs: Vec<Blob>,
109 root: PathBuf,
110 }
111
112 /// Build a vault of `n` flat blobs at `vault`, clearing anything already there.
113 ///
114 /// Rows go into `samples` so the mirror and the resolver have something to read;
115 /// VFS links are created only when asked, because they cost a statement per blob
116 /// and only the mirror scenario needs them.
117 fn fabricate(vault: &Path, n: usize, with_vfs: bool) -> Option<FlatVault> {
118 if vault.exists()
119 && let Err(e) = std::fs::remove_dir_all(vault)
120 {
121 eprintln!("could not clear scratch vault: {e}");
122 return None;
123 }
124 let root = vault.join("samples");
125 let stage = vault.join("stage");
126 for dir in [&root, &stage] {
127 if let Err(e) = std::fs::create_dir_all(dir) {
128 eprintln!("could not create {}: {e}", dir.display());
129 return None;
130 }
131 }
132
133 let db = match Database::open(vault.join("audiofiles.db")) {
134 Ok(db) => db,
135 Err(e) => {
136 eprintln!("Database::open failed (WAL unsupported on this fs?): {e}");
137 return None;
138 }
139 };
140 let store = match SampleStore::new(&root) {
141 Ok(s) => s,
142 Err(e) => {
143 eprintln!("SampleStore::new failed: {e}");
144 return None;
145 }
146 };
147
148 let vfs_id = if with_vfs {
149 match vfs::create_vfs(&db, "bench") {
150 Ok(id) => Some(id),
151 Err(e) => {
152 eprintln!("could not create bench vfs: {e}");
153 return None;
154 }
155 }
156 } else {
157 None
158 };
159
160 let mut blobs = Vec::with_capacity(n);
161 // `.wav` because `hash_file` refuses anything `is_audio_file` does not
162 // recognise. It hashes bytes rather than decoding them, so the extension is
163 // the only part of "is this audio" that has to be true here.
164 let staged = stage.join("blob.wav");
165 // One transaction for the whole fabrication. Per-row commits would make
166 // building a 50k vault slower than the sweep it exists to measure.
167 if db.conn().execute_batch("BEGIN").is_err() {
168 eprintln!("could not open the fabrication transaction");
169 return None;
170 }
171 for i in 0..n {
172 let content = payload(i);
173 if std::fs::write(&staged, &content).is_err() {
174 eprintln!("could not stage blob {i}");
175 return None;
176 }
177 let (hash, size) = match hash_file(&staged) {
178 Ok(pair) => pair,
179 Err(e) => {
180 eprintln!("could not hash blob {i}: {e}");
181 return None;
182 }
183 };
184 let dest = legacy_flat_blob_path(&root, &hash, "wav");
185 if std::fs::rename(&staged, &dest).is_err() {
186 eprintln!("could not place flat blob {i}");
187 return None;
188 }
189 set_readonly(&dest);
190 let now = i as i64;
191 if db
192 .conn()
193 .execute(
194 "INSERT OR IGNORE INTO samples
195 (hash, original_name, file_extension, file_size, import_date, last_modified)
196 VALUES (?1, ?2, 'wav', ?3, ?4, ?4)",
197 rusqlite::params![hash, format!("blob-{i:06}.wav"), size, now],
198 )
199 .is_err()
200 {
201 eprintln!("could not insert sample row {i}");
202 return None;
203 }
204 if let Some(vfs_id) = vfs_id
205 && vfs::create_sample_link(
206 &db,
207 vfs_id,
208 None,
209 &format!("blob-{i:06}.wav"),
210 &SampleHash::from_trusted(hash.clone()),
211 )
212 .is_err()
213 {
214 eprintln!("could not link blob {i} into the vfs");
215 return None;
216 }
217 blobs.push((hash, "wav".to_string()));
218 }
219 if db.conn().execute_batch("COMMIT").is_err() {
220 eprintln!("could not commit the fabrication transaction");
221 return None;
222 }
223 let _ = std::fs::remove_dir_all(&stage);
224
225 Some(FlatVault {
226 db,
227 store,
228 blobs,
229 root,
230 })
231 }
232
233 /// Run a sweep with no cancellation and no progress interest.
234 fn sweep(v: &FlatVault) -> Option<LayoutMigration> {
235 let cancel = AtomicBool::new(false);
236 migrate_to_sharded(&v.store, &v.db, &cancel, &mut |_, _| {})
237 .inspect_err(|e| eprintln!("sweep failed: {e}"))
238 .ok()
239 }
240
241 /// Every blob resolves to a file that exists, in whichever layout holds it.
242 ///
243 /// This is the store-layer half of "the library stays usable mid-migration": the
244 /// resolver is what browsing, preview and export all go through, so a hash that
245 /// stops resolving is a sample that has disappeared from the app.
246 fn all_resolve(v: &FlatVault) -> bool {
247 v.blobs
248 .iter()
249 .all(|(hash, ext)| existing_blob_path(&v.root, hash, ext).is_some_and(|p| p.is_file()))
250 }
251
252 /// Every blob sits at its canonical sharded path and nowhere else.
253 fn all_sharded(v: &FlatVault) -> bool {
254 v.blobs.iter().all(|(hash, ext)| {
255 store_blob_path(&v.root, hash, ext).is_file()
256 && !legacy_flat_blob_path(&v.root, hash, ext).exists()
257 })
258 }
259
260 /// Names directly in the store root that are not shard directories.
261 fn root_strays(root: &Path) -> Vec<String> {
262 let Ok(entries) = std::fs::read_dir(root) else {
263 return Vec::new();
264 };
265 entries
266 .flatten()
267 .filter(|e| !e.file_type().is_ok_and(|t| t.is_dir()))
268 .map(|e| e.file_name().to_string_lossy().into_owned())
269 .collect()
270 }
271
272 /// Collected scenario results, printed as one table and folded into the exit code.
273 struct Checks {
274 rows: Vec<(String, bool, String)>,
275 }
276
277 impl Checks {
278 fn new() -> Self {
279 Self { rows: Vec::new() }
280 }
281
282 fn add(&mut self, name: &str, ok: bool, detail: impl Into<String>) {
283 let detail = detail.into();
284 println!(
285 " {:<44} {} {detail}",
286 name,
287 if ok { "PASS" } else { "FAIL" }
288 );
289 self.rows.push((name.to_string(), ok, detail));
290 }
291
292 fn failed(&self) -> usize {
293 self.rows.iter().filter(|(_, ok, _)| !ok).count()
294 }
295 }
296
297 /// A pass over a complete vault relocates everything and records the layout.
298 fn scenario_full(vault: &Path, checks: &mut Checks) {
299 let Some(v) = fabricate(vault, SCENARIO_BLOBS, false) else {
300 checks.add("full sweep", false, "could not fabricate the vault");
301 return;
302 };
303 checks.add(
304 "pending before the sweep",
305 !matches!(recorded_layout(&v.db), Ok(BlobLayout::Sharded))
306 && count_flat_blobs(&v.root).unwrap_or(0) == SCENARIO_BLOBS,
307 format!("{SCENARIO_BLOBS} flat blobs, layout unrecorded"),
308 );
309
310 // Progress is what the strip draws, so its shape is worth checking even
311 // though the strip itself is not on screen here: a callback that skips the
312 // final call or walks backwards would show a bar that never fills.
313 let mut seen: Vec<(usize, usize)> = Vec::new();
314 let cancel = AtomicBool::new(false);
315 let Ok(report) = migrate_to_sharded(&v.store, &v.db, &cancel, &mut |done, total| {
316 seen.push((done, total));
317 }) else {
318 checks.add("full sweep", false, "sweep returned an error");
319 return;
320 };
321
322 checks.add(
323 "full sweep relocates every blob",
324 report.moved == SCENARIO_BLOBS
325 && report.deduped == 0
326 && report.errors == 0
327 && report.completed
328 && !report.cancelled,
329 format!(
330 "moved {} deduped {} errors {}",
331 report.moved, report.deduped, report.errors
332 ),
333 );
334 checks.add(
335 "progress is monotonic and ends full",
336 seen.windows(2).all(|w| w[1].0 == w[0].0 + 1)
337 && seen.last() == Some(&(SCENARIO_BLOBS, SCENARIO_BLOBS)),
338 format!("{} callbacks", seen.len()),
339 );
340 checks.add(
341 "blobs land under their hash prefix",
342 all_sharded(&v),
343 "{root}/{ab}/{hash}.wav".to_string(),
344 );
345 let strays = root_strays(&v.root);
346 checks.add(
347 "root holds only shard directories",
348 strays.is_empty(),
349 if strays.is_empty() {
350 "clean".to_string()
351 } else {
352 format!("{} left: {}", strays.len(), strays.join(", "))
353 },
354 );
355 checks.add(
356 "layout recorded as sharded",
357 matches!(recorded_layout(&v.db), Ok(BlobLayout::Sharded)),
358 "blob_layout=sharded".to_string(),
359 );
360
361 // The second open the task asks about: a completed vault must not re-sweep.
362 let Some(again) = sweep(&v) else {
363 checks.add(
364 "re-open does not re-sweep",
365 false,
366 "sweep returned an error",
367 );
368 return;
369 };
370 checks.add(
371 "re-open does not re-sweep",
372 again.moved == 0 && again.deduped == 0 && again.errors == 0 && again.completed,
373 format!("moved {} on the second pass", again.moved),
374 );
375 }
376
377 /// Cancelling mid-pass leaves a resolvable vault that the next pass finishes.
378 fn scenario_resume(vault: &Path, checks: &mut Checks) {
379 let Some(v) = fabricate(vault, SCENARIO_BLOBS, false) else {
380 checks.add("resume", false, "could not fabricate the vault");
381 return;
382 };
383
384 // Cancel from inside the progress callback, which is where the GUI's cancel
385 // button effectively lands: the flag is set while the sweep is running.
386 let cancel = AtomicBool::new(false);
387 let stop_at = SCENARIO_BLOBS / 3;
388 let Ok(first) = migrate_to_sharded(&v.store, &v.db, &cancel, &mut |done, _| {
389 if done >= stop_at {
390 cancel.store(true, Ordering::Release);
391 }
392 }) else {
393 checks.add("resume", false, "first pass returned an error");
394 return;
395 };
396
397 checks.add(
398 "cancel stops the pass early",
399 first.cancelled
400 && !first.completed
401 && first.moved >= stop_at
402 && first.moved < SCENARIO_BLOBS,
403 format!("moved {} of {SCENARIO_BLOBS} then stopped", first.moved),
404 );
405 checks.add(
406 "cancelled vault stays recorded flat",
407 matches!(recorded_layout(&v.db), Ok(BlobLayout::Flat)),
408 "so the next open resumes".to_string(),
409 );
410 checks.add(
411 "every blob resolves mid-migration",
412 all_resolve(&v),
413 "reads span both layouts".to_string(),
414 );
415
416 let remaining = count_flat_blobs(&v.root).unwrap_or(0);
417 let Some(second) = sweep(&v) else {
418 checks.add("resume", false, "second pass returned an error");
419 return;
420 };
421 checks.add(
422 "the next pass resumes rather than restarts",
423 second.moved == remaining && first.moved + second.moved == SCENARIO_BLOBS,
424 format!("{} + {} = {SCENARIO_BLOBS}", first.moved, second.moved),
425 );
426 checks.add(
427 "resumed vault ends fully sharded",
428 second.completed && all_sharded(&v) && root_strays(&v.root).is_empty(),
429 "blob_layout=sharded".to_string(),
430 );
431 }
432
433 /// A size mismatch is left for a human; a matching duplicate is discarded.
434 fn scenario_mismatch(vault: &Path, checks: &mut Checks) {
435 let Some(v) = fabricate(vault, 32, false) else {
436 checks.add("size mismatch", false, "could not fabricate the vault");
437 return;
438 };
439
440 // Plant a shard-side copy of blob 0 with different bytes (a truncated blob
441 // from a pre-atomic-rename crash) and an identical-size copy of blob 1 (the
442 // same content already migrated by a repair write).
443 let (mismatch, ext) = v.blobs[0].clone();
444 let (dupe, _) = v.blobs[1].clone();
445 for (hash, content) in [(&mismatch, b"short".to_vec()), (&dupe, payload(1))] {
446 let dest = store_blob_path(&v.root, hash, &ext);
447 let Some(shard) = dest.parent() else { continue };
448 if std::fs::create_dir_all(shard).is_err() || std::fs::write(&dest, &content).is_err() {
449 checks.add("size mismatch", false, "could not plant the shard copy");
450 return;
451 }
452 }
453 // Leftovers the sweep must ignore rather than rename: an import that died
454 // between create and rename, and a file a user dropped in by hand.
455 let tmp = v.root.join(format!("{mismatch}.wav.12345.tmp"));
456 let note = v.root.join("notes.txt");
457 let _ = std::fs::write(&tmp, b"partial");
458 let _ = std::fs::write(&note, b"mine");
459
460 let Some(report) = sweep(&v) else {
461 checks.add("size mismatch", false, "sweep returned an error");
462 return;
463 };
464
465 checks.add(
466 "size mismatch is counted, not resolved",
467 report.errors == 1 && !report.completed,
468 format!("errors {} completed {}", report.errors, report.completed),
469 );
470 checks.add(
471 "both copies of a mismatch survive",
472 legacy_flat_blob_path(&v.root, &mismatch, &ext).is_file()
473 && store_blob_path(&v.root, &mismatch, &ext).is_file(),
474 "left for inspection".to_string(),
475 );
476 checks.add(
477 "redundant flat copy is discarded",
478 report.deduped == 1 && !legacy_flat_blob_path(&v.root, &dupe, &ext).exists(),
479 format!("deduped {}", report.deduped),
480 );
481 checks.add(
482 "an errored pass stays recorded flat",
483 matches!(recorded_layout(&v.db), Ok(BlobLayout::Flat)),
484 "so the mismatch is swept again after repair".to_string(),
485 );
486 checks.add(
487 "temp leftovers and stray files are untouched",
488 tmp.is_file() && note.is_file(),
489 "not blobs, never renamed".to_string(),
490 );
491 }
492
493 /// The mirror's symlinks are stale after a sweep and whole again after a rebuild.
494 fn scenario_mirror(vault: &Path, checks: &mut Checks) {
495 let Some(v) = fabricate(vault, 256, true) else {
496 checks.add("mirror", false, "could not fabricate the vault");
497 return;
498 };
499 let mirror_root = vault.join("mirror");
500 let config = MirrorConfig {
501 mirror_root: mirror_root.clone(),
502 store_root: v.root.clone(),
503 };
504 if v.db.set_config(ConfigKey::MirrorEnabled, "true").is_err()
505 || v.db
506 .set_config(ConfigKey::MirrorPath, &mirror_root.to_string_lossy())
507 .is_err()
508 {
509 checks.add("mirror", false, "could not record the mirror config");
510 return;
511 }
512
513 // Built while the vault is still flat, so every link points at a flat path:
514 // the state a real pre-2026-07-29 vault with a mirror is in at open.
515 let Ok(before) = sync_mirror(&v.db, &config) else {
516 checks.add("mirror", false, "the first sync failed");
517 return;
518 };
519 checks.add(
520 "mirror links a flat vault",
521 before.links_created == 256 && dangling(&mirror_root) == 0,
522 format!("{} links, none dangling", before.links_created),
523 );
524
525 let Some(report) = sweep(&v) else {
526 checks.add("mirror", false, "sweep returned an error");
527 return;
528 };
529 let stale = dangling(&mirror_root);
530 checks.add(
531 "sweep leaves the old links dangling",
532 report.completed && stale == 256,
533 format!("{stale} of 256 dangle, which is why a rebuild fires"),
534 );
535
536 let Ok(after) = sync_mirror(&v.db, &config) else {
537 checks.add("mirror", false, "the rebuild failed");
538 return;
539 };
540 checks.add(
541 "rebuild repoints every link",
542 dangling(&mirror_root) == 0,
543 format!("{} relinked", after.links_created),
544 );
545 }
546
547 /// Symlinks under `root` whose target does not exist.
548 fn dangling(root: &Path) -> usize {
549 let Ok(entries) = std::fs::read_dir(root) else {
550 return 0;
551 };
552 entries
553 .flatten()
554 .map(|e| {
555 let path = e.path();
556 if e.file_type().is_ok_and(|t| t.is_dir()) {
557 dangling(&path)
558 } else {
559 // `symlink_metadata` sees the link, `metadata` follows it: present
560 // as a link but absent as a file is exactly a dangling symlink.
561 usize::from(path.symlink_metadata().is_ok() && !path.exists())
562 }
563 })
564 .sum()
565 }
566
567 /// Time one uninterrupted sweep over `n` blobs.
568 fn timed_sweep(vault: &Path, n: usize, report: &mut Report, checks: &mut Checks) {
569 println!();
570 println!("━━━ TIMED SWEEP ({n} blobs) ━━━");
571 println!();
572
573 let build = Instant::now();
574 let Some(v) = fabricate(vault, n, false) else {
575 checks.add("timed sweep", false, "could not fabricate the vault");
576 return;
577 };
578 println!(" fabricated in {:.1}s", build.elapsed().as_secs_f64());
579
580 let mut callbacks = 0usize;
581 let cancel = AtomicBool::new(false);
582 let start = Instant::now();
583 let Ok(pass) = migrate_to_sharded(&v.store, &v.db, &cancel, &mut |_, _| callbacks += 1) else {
584 checks.add("timed sweep", false, "sweep returned an error");
585 return;
586 };
587 let elapsed = start.elapsed().as_secs_f64();
588 let per_sec = if elapsed > 0.0 {
589 pass.moved as f64 / elapsed
590 } else {
591 0.0
592 };
593
594 println!();
595 println!(" moved {} blobs in {elapsed:.2}s", pass.moved);
596 println!(" {per_sec:.0} blobs/s {:.3} ms/blob", 1000.0 / per_sec);
597 println!();
598 report.set("layout_blobs", n);
599 report.set("layout_sweep_s", (elapsed * 100.0).round() / 100.0);
600 report.set("layout_blobs_per_sec", per_sec.round());
601
602 checks.add(
603 "timed sweep completes cleanly",
604 pass.moved == n && pass.errors == 0 && pass.completed && callbacks == n,
605 format!("{n} moved, {callbacks} progress callbacks"),
606 );
607 checks.add(
608 "timed sweep leaves a clean root",
609 root_strays(&v.root).is_empty() && count_flat_blobs(&v.root).unwrap_or(1) == 0,
610 "only shard directories".to_string(),
611 );
612 }
613
614 /// Run the layout exercise against a scratch vault at `vault`.
615 pub(crate) fn run(vault: &Path, timed_blobs: usize) {
616 println!("━━━ BLOB LAYOUT MIGRATION ━━━");
617 println!();
618 println!(" vault: {}", vault.display());
619 println!();
620
621 let mut report = Report::new("layout");
622 let vault_storage = storage::describe(
623 vault
624 .parent()
625 .filter(|p| p.exists())
626 .unwrap_or_else(|| Path::new(".")),
627 );
628 report.set_storage("vault", &vault_storage);
629 storage::print_conditions(&[("vault", &vault_storage)], None);
630
631 let mut checks = Checks::new();
632 println!("━━━ SCENARIOS ({SCENARIO_BLOBS} blobs unless stated) ━━━");
633 println!();
634 scenario_full(vault, &mut checks);
635 println!();
636 scenario_resume(vault, &mut checks);
637 println!();
638 scenario_mismatch(vault, &mut checks);
639 println!();
640 scenario_mirror(vault, &mut checks);
641
642 timed_sweep(vault, timed_blobs, &mut report, &mut checks);
643
644 let failed = checks.failed();
645 report.set("layout_checks", checks.rows.len());
646 report.set("layout_checks_failed", failed);
647 report.write();
648
649 println!();
650 if failed == 0 {
651 println!(" {} checks, all passed", checks.rows.len());
652 } else {
653 println!(" {failed} of {} checks FAILED", checks.rows.len());
654 }
655 // The scratch vault is left behind on failure: the whole value of a failed
656 // check is the state that produced it.
657 if failed == 0 {
658 let _ = std::fs::remove_dir_all(vault);
659 } else {
660 println!(" vault left at {} for inspection", vault.display());
661 std::process::exit(1);
662 }
663 }
664
665 /// The layout module owns its own naming rules for what is and is not a blob, and
666 /// this harness fabricates names against those rules rather than through the store.
667 /// These check the fabrication itself, so a harness bug cannot pass as a clean run.
668 #[cfg(test)]
669 mod tests {
670 use super::*;
671
672 #[test]
673 fn payloads_differ_in_length_across_the_cycle() {
674 assert_ne!(payload(0).len(), payload(1).len());
675 assert_eq!(payload(0).len(), payload(97).len());
676 }
677
678 #[test]
679 fn fabricated_vault_is_flat_and_countable() {
680 let dir = tempfile::TempDir::new().unwrap();
681 let vault = dir.path().join("vault");
682 let v = fabricate(&vault, 8, true).expect("fabrication failed");
683 assert_eq!(v.blobs.len(), 8);
684 assert_eq!(count_flat_blobs(&v.root).unwrap(), 8);
685 assert!(v.blobs.iter().all(|(h, e)| {
686 legacy_flat_blob_path(&v.root, h, e).is_file()
687 && !store_blob_path(&v.root, h, e).exists()
688 }));
689 assert!(all_resolve(&v));
690 // Distinct content per index, so a sweep of 8 is a sweep of 8 blobs.
691 let unique: std::collections::HashSet<_> = v.blobs.iter().map(|(h, _)| h).collect();
692 assert_eq!(unique.len(), 8);
693 }
694
695 #[test]
696 fn dangling_counts_only_broken_links() {
697 let dir = tempfile::TempDir::new().unwrap();
698 let root = dir.path();
699 let real = root.join("real");
700 std::fs::write(&real, b"x").unwrap();
701 #[cfg(unix)]
702 {
703 std::os::unix::fs::symlink(&real, root.join("good")).unwrap();
704 std::os::unix::fs::symlink(root.join("gone"), root.join("bad")).unwrap();
705 assert_eq!(dangling(root), 1);
706 }
707 }
708 }
709