bestool 2.2.2

BES Deployment tooling
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
//! Held captures: a run's capture retained on the device after the run.
//!
//! A backup method prepares its capture for the *run* — at a mount path keyed by
//! backup type, and (btrfs, thin-LVM) under a name whose infix exists so the next
//! run's reaper can glob orphans. Retaining a capture is therefore not a skipped
//! teardown: the capture has to be promoted out of that run-owned namespace first,
//! or the next run of the same type unmounts it or deletes it outright. Each
//! backend's promotion lives in its own module; this one owns the record that
//! outlives the process and the release that undoes the promotion.
//!
//! The record is written to disk because a hold outlives the daemon and the
//! machine: it carries enough to find the capture, describe it, and release it
//! without the run that made it. Its shape is deliberately independent of the
//! backends' internal teardown structs — a hold taken before a bestool upgrade
//! still has to be droppable after one.

use std::path::{Path, PathBuf};

use jiff::Timestamp;
use miette::{Context as _, IntoDiagnostic as _, Result, bail};
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};

/// A capture retained on the device, as stored on disk.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HoldRecord {
	/// The hold's id, and the stem of its record file.
	pub id: String,
	/// The backup type the capture came from.
	pub backup_type: String,
	/// The instant the data froze, where the capture has one. A streamed base
	/// backup represents an interval rather than a point and records none.
	#[serde(default)]
	pub taken_at: Option<Timestamp>,
	/// When the capture was retained.
	pub held_at: Timestamp,
	/// Where the capture is readable — the path a restore reads from.
	pub source: PathBuf,
	/// Whether the run that took this capture also uploaded it.
	pub uploaded: bool,
	/// What to release when the hold is dropped.
	pub capture: HeldCapture,
	/// What the backend noted at the freeze, so a later in-place restore can ask
	/// the filesystem what has diverged since instead of scanning for it.
	///
	/// Absent where the backend has nothing to offer, and on records written
	/// before it was kept. Never required: a restore without one compares the
	/// two trees itself.
	#[serde(default)]
	pub diverged_since: Option<DivergenceMark>,
}

/// A point in a filesystem's own change history, recorded when a capture froze.
///
/// Reading the divergence from the filesystem's metadata costs nothing like the
/// full read of both trees that finding it by hand does, so it is worth
/// recording even though nothing depends on it.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum DivergenceMark {
	/// The btrfs transaction generation the subvolume stood at when it was
	/// snapshotted. Everything written to it since carries a higher one.
	///
	/// A generation only means anything against the subvolume it was counted on:
	/// another subvolume, or a filesystem since recreated, has its own sequence
	/// that started again from low numbers, and asking it about this generation
	/// answers cleanly and wrongly. So the subvolume's UUID is recorded with it,
	/// and a restore that cannot match it declines — the same guard the change
	/// journal gets from its journal id. Absent on records written before it was
	/// kept, which therefore cannot be trusted as a basis.
	BtrfsGeneration {
		generation: u64,
		#[serde(default)]
		subvolume: Option<String>,
	},
	/// The NTFS change journal's identity and position when the shadow was
	/// taken. The journal is a fixed-size ring, so the id detects it having been
	/// recreated and the position detects it having wrapped past this point —
	/// either of which makes the record no longer an answer.
	UsnJournal {
		volume: PathBuf,
		journal_id: u64,
		usn: i64,
	},
}

/// The subvolume UUID out of `btrfs subvolume show`.
///
/// Lives with [`DivergenceMark`] because the capture side records it and the
/// restore side checks it, and the two must read the field the same way forever:
/// a divergence between two copies of this would turn the identity guard into a
/// permanent refusal, or into a wrong match.
///
/// Matched on the whole `UUID:` label, since `Parent UUID` and `Received UUID`
/// also end in it. A snapshot with no UUID of its own reports `-`.
pub fn parse_subvolume_uuid(output: &str) -> Option<String> {
	output
		.lines()
		.filter_map(|line| line.trim().strip_prefix("UUID:"))
		.map(str::trim)
		.find(|uuid| !uuid.is_empty() && *uuid != "-")
		.map(str::to_owned)
}

/// The retained capture itself, in the terms its backend needs to release it.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "backend", rename_all = "kebab-case")]
pub enum HeldCapture {
	Btrfs {
		toplevel_mount: PathBuf,
		snapshot_path: PathBuf,
		mount: PathBuf,
		/// The device the subvolume lives on. Releasing reaches it through the
		/// top-level mount, which may no longer be there, so remounting to delete
		/// it needs the device. `None` on records written before it was kept.
		#[serde(default)]
		fsdev: Option<String>,
	},
	Lvm {
		vg: String,
		lv: String,
		mount: PathBuf,
	},
	/// Carried on every platform even though only Windows can release it: a
	/// record is read by whatever bestool runs next on the host that wrote it,
	/// and failing to parse it would strand the hold rather than report it.
	Vss {
		shadow_id: String,
		junction: PathBuf,
	},
	BaseBackup {
		root: PathBuf,
	},
}

impl HeldCapture {
	/// Whether the capture is reached through something separate from itself, and
	/// so can both lose that exposure and have it put back. [`capture_state`] and
	/// [`reattach`] both key off this: reporting a hold detached and then refusing
	/// to reattach it would send an operator to a command that cannot work.
	pub fn exposes_separately(&self) -> bool {
		match self {
			Self::Btrfs { .. } | Self::Lvm { .. } | Self::Vss { .. } => true,
			// The capture is the directory itself, so there is nothing to put back.
			Self::BaseBackup { .. } => false,
		}
	}

	/// The backend's name, for diagnostics and listings.
	pub fn backend(&self) -> &'static str {
		match self {
			Self::Btrfs { .. } => "btrfs",
			Self::Lvm { .. } => "lvm",
			Self::Vss { .. } => "vss",
			Self::BaseBackup { .. } => "basebackup",
		}
	}
}

/// Where hold records live. Under the daemon's own state directory on Unix, and
/// the machine-wide application data directory on Windows: a hold survives the
/// daemon restarting and the machine rebooting, so it can't live anywhere
/// per-boot or per-user.
pub fn records_dir() -> PathBuf {
	#[cfg(unix)]
	{
		PathBuf::from("/var/lib/bestool/held-snapshots")
	}
	#[cfg(not(unix))]
	{
		std::env::var_os("ProgramData")
			.map(PathBuf::from)
			.unwrap_or_else(|| PathBuf::from(r"C:\ProgramData"))
			.join("bestool")
			.join("held-snapshots")
	}
}

/// The record file for a hold id.
fn record_path(id: &str) -> PathBuf {
	records_dir().join(format!("{id}.json"))
}

/// Where a held capture is exposed, keyed by hold rather than by backup type so
/// a later run of the same type neither disturbs a hold nor is disturbed by one.
///
/// A held Windows shadow copy is exposed on its own volume instead, since a
/// junction can't cross volumes; it derives its path from the capture's volume.
pub fn hold_source_dir(id: &str) -> PathBuf {
	#[cfg(unix)]
	{
		PathBuf::from("/var/lib/bestool/held-source").join(id)
	}
	#[cfg(not(unix))]
	{
		std::env::var_os("ProgramData")
			.map(PathBuf::from)
			.unwrap_or_else(|| PathBuf::from(r"C:\ProgramData"))
			.join("bestool")
			.join("held-source")
			.join(id)
	}
}

/// Mint a hold id from the backup type and the moment the capture represents.
/// Sortable, typeable at a prompt, and meaningful in a listing — an operator
/// picking a rollback point is choosing by time, so the time is in the name.
pub fn mint_id(backup_type: &str, at: Timestamp) -> String {
	let stamp = at.strftime("%Y%m%dT%H%M%SZ");
	format!("{backup_type}-{stamp}")
}

/// Write a hold record, creating the records directory if needed.
pub async fn save(record: &HoldRecord) -> Result<()> {
	let dir = records_dir();
	tokio::fs::create_dir_all(&dir)
		.await
		.into_diagnostic()
		.wrap_err_with(|| format!("creating {}", dir.display()))?;

	let path = record_path(&record.id);
	let json = serde_json::to_vec_pretty(record)
		.into_diagnostic()
		.wrap_err("serialising the hold record")?;
	tokio::fs::write(&path, &json)
		.await
		.into_diagnostic()
		.wrap_err_with(|| format!("writing {}", path.display()))?;
	debug!(id = %record.id, path = %path.display(), "wrote hold record");
	Ok(())
}

/// Read one hold record by id.
pub async fn load(id: &str) -> Result<HoldRecord> {
	let path = record_path(id);
	let json = tokio::fs::read(&path)
		.await
		.into_diagnostic()
		.wrap_err_with(|| format!("no hold {id:?} on this device ({})", path.display()))?;
	parse(&json).wrap_err_with(|| format!("reading {}", path.display()))
}

/// Every hold record on the device, oldest capture first. A record that can't be
/// parsed is warned about and skipped rather than failing the listing: one bad
/// file must not hide the others from an operator looking for a rollback point.
pub async fn list() -> Result<Vec<HoldRecord>> {
	let dir = records_dir();
	let mut entries = match tokio::fs::read_dir(&dir).await {
		Ok(entries) => entries,
		Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
		Err(err) => {
			return Err(err)
				.into_diagnostic()
				.wrap_err_with(|| format!("reading {}", dir.display()));
		}
	};

	let mut records = Vec::new();
	while let Some(entry) = entries.next_entry().await.into_diagnostic()? {
		let path = entry.path();
		if path.extension().is_none_or(|ext| ext != "json") {
			continue;
		}
		match tokio::fs::read(&path).await.into_diagnostic().and_then(|j| parse(&j)) {
			Ok(record) => records.push(record),
			Err(err) => warn!("skipping unreadable hold record {}: {err}", path.display()),
		}
	}
	records.sort_by_key(|record| record.taken_at.unwrap_or(record.held_at));
	Ok(records)
}

fn parse(json: &[u8]) -> Result<HoldRecord> {
	serde_json::from_slice(json)
		.into_diagnostic()
		.wrap_err("parsing the hold record")
}

/// Remove a hold's record, leaving its capture alone.
pub async fn remove_record(id: &str) -> Result<()> {
	let path = record_path(id);
	match tokio::fs::remove_file(&path).await {
		Ok(()) => Ok(()),
		Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
		Err(err) => Err(err)
			.into_diagnostic()
			.wrap_err_with(|| format!("removing {}", path.display())),
	}
}

/// What state the capture a hold names is in.
///
/// A hold whose capture has gone is the failure worth catching: the operator
/// believes a rollback point exists when it does not, and nothing about the
/// record itself gives that away. A detached one is the recoverable case and
/// must be told apart, since releasing it as though it were gone would forget
/// the record and strand the capture it names.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaptureState {
	/// Readable where the record says it is.
	Present,
	/// The capture is there, but what exposes it is not mounted.
	Detached,
	/// Nothing of the capture is left.
	Gone,
}

/// Probed at the path a restore reads, not at whatever exposes it.
///
/// For a shadow copy the two are not interchangeable. Its junction substitutes a
/// `\??\GLOBALROOT\Device\HarddiskVolumeShadowCopyN` device path, and enumerating
/// the junction itself opens that device rather than the root directory of the
/// filesystem on it, so it comes back empty however healthy the copy is. Paths
/// *through* the junction resolve normally, which is why the capture reads fine
/// and only the probe of its root did not.
pub async fn capture_state(record: &HoldRecord) -> CaptureState {
	match &record.capture {
		HeldCapture::Btrfs {
			toplevel_mount,
			snapshot_path,
			mount,
			..
		} => {
			if !snapshot_path.exists() {
				if super::postgresql::btrfs::attached(toplevel_mount).await {
					// The top level is mounted and the subvolume still isn't there.
					CaptureState::Gone
				} else {
					CaptureState::Detached
				}
			} else if super::postgresql::btrfs::attached(mount).await {
				CaptureState::Present
			} else {
				// The subvolume is there but nothing exposes it, and a restore reads
				// the mount rather than the subvolume: reporting this present would
				// lay an empty directory over the cluster.
				CaptureState::Detached
			}
		}
		HeldCapture::Lvm { vg, lv, mount } => {
			if !super::postgresql::lvm::held_present(vg, lv).await {
				CaptureState::Gone
			} else if super::postgresql::lvm::attached(mount).await {
				CaptureState::Present
			} else {
				CaptureState::Detached
			}
		}
		HeldCapture::Vss { shadow_id, junction } => {
			if !vss_present(shadow_id, junction).await {
				CaptureState::Gone
			} else if tokio::fs::read_dir(&record.source).await.is_ok() {
				CaptureState::Present
			} else {
				// The shadow is there and the junction is not resolving. VSS can
				// renumber the device a junction points at, so the copy behind it
				// outlives what names it.
				CaptureState::Detached
			}
		}
		HeldCapture::BaseBackup { root } => {
			if root.exists() {
				CaptureState::Present
			} else {
				CaptureState::Gone
			}
		}
	}
}


#[cfg(windows)]
async fn vss_present(shadow_id: &str, _junction: &Path) -> bool {
	super::postgresql::vss::held_present(shadow_id).await
}

/// Off Windows the shadow itself can't be queried, so the mount is the only
/// evidence available — enough to list a record without claiming more.
#[cfg(not(windows))]
async fn vss_present(_shadow_id: &str, junction: &Path) -> bool {
	junction.exists()
}

/// Expose a detached capture again at the path its record names.
///
/// A hold's mount is made by whichever process took it, and the daemon that
/// takes one runs in its own mount namespace, so the mount is not visible to a
/// restore run from a shell and does not outlive the daemon. Reattaching from
/// the shell puts it where both can see it.
pub async fn reattach(capture: &HeldCapture) -> Result<()> {
	if !capture.exposes_separately() {
		bail!(
			"reattaching a {} capture is not supported; nothing exposes it separately",
			capture.backend()
		);
	}
	match capture {
		HeldCapture::Btrfs {
			toplevel_mount,
			snapshot_path,
			mount,
			fsdev,
		} => {
			super::postgresql::btrfs::reattach_held(
				toplevel_mount,
				snapshot_path,
				mount,
				fsdev.as_deref(),
			)
			.await
		}
		HeldCapture::Lvm { vg, lv, mount } => {
			super::postgresql::lvm::reattach_held(vg, lv, mount).await
		}
		HeldCapture::Vss { shadow_id, junction } => reattach_vss(shadow_id, junction).await,
		other => bail!(
			"reattaching a {} capture is not supported; nothing exposes it separately",
			other.backend()
		),
	}
}

/// Release a held capture: undo the promotion and free the underlying snapshot,
/// logical volume, shadow copy, or staged tree.
///
/// A capture that has already gone — evicted, swept by a hand-run tool, or lost
/// with its volume — is not an error. The hold is being dropped either way, and
/// the operator needs the record gone more than they need the failure.
pub async fn release(capture: &HeldCapture) -> Result<()> {
	match capture {
		HeldCapture::Btrfs {
			toplevel_mount,
			snapshot_path,
			mount,
			fsdev,
			..
		} => {
			super::postgresql::btrfs::release_held(toplevel_mount, snapshot_path, mount, fsdev.as_deref())
				.await
		}
		HeldCapture::Lvm { vg, lv, mount } => super::postgresql::lvm::release_held(vg, lv, mount).await,
		HeldCapture::Vss { shadow_id, junction } => release_vss(shadow_id, junction).await,
		HeldCapture::BaseBackup { root } => super::postgresql::basebackup::teardown(root.clone()).await,
	}
}

#[cfg(windows)]
async fn reattach_vss(shadow_id: &str, junction: &Path) -> Result<()> {
	super::postgresql::vss::reattach_held(shadow_id, junction).await
}

/// Only the host that made the shadow can rebuild the junction to it.
#[cfg(not(windows))]
async fn reattach_vss(_shadow_id: &str, _junction: &Path) -> Result<()> {
	bail!("a shadow copy can only be reattached on the Windows host that holds it")
}

#[cfg(windows)]
async fn release_vss(shadow_id: &str, junction: &Path) -> Result<()> {
	super::postgresql::vss::release_held(shadow_id, junction).await
}

#[cfg(not(windows))]
async fn release_vss(shadow_id: &str, _junction: &Path) -> Result<()> {
	miette::bail!("hold {shadow_id} holds a Windows shadow copy, which only Windows can release")
}

#[cfg(test)]
mod tests {
	use super::*;

	fn record(capture: HeldCapture) -> HoldRecord {
		HoldRecord {
			id: "tamanu-postgres-20260814T054412Z".into(),
			backup_type: "tamanu-postgres".into(),
			taken_at: Some("2026-08-14T05:44:12Z".parse().unwrap()),
			held_at: "2026-08-14T11:02:00Z".parse().unwrap(),
			source: PathBuf::from("/var/lib/bestool/held-source/x/16/main"),
			uploaded: true,
			capture,
			diverged_since: None,
		}
	}

	/// The on-disk shape has to survive a bestool upgrade: a hold taken before one
	/// is dropped after it, so every backend's record round-trips.
	#[test]
	fn every_backend_round_trips() {
		let captures = [
			HeldCapture::Btrfs {
				toplevel_mount: "/run/bestool-toplevel".into(),
				snapshot_path: "/run/bestool-toplevel/bestool-held-x".into(),
				mount: "/var/lib/bestool/held-source/x".into(),
				fsdev: Some("/dev/disk/by-uuid/deadbeef".into()),
			},
			HeldCapture::Lvm {
				vg: "vg0".into(),
				lv: "bestool-held-x".into(),
				mount: "/var/lib/bestool/held-source/x".into(),
			},
			HeldCapture::Vss {
				shadow_id: "{deadbeef-0000-0000-0000-000000000000}".into(),
				junction: r"C:\bestool-backup-shadow\held\x".into(),
			},
			HeldCapture::BaseBackup {
				root: "/var/lib/bestool/held-source/x".into(),
			},
		];

		for capture in captures {
			let backend = capture.backend();
			let original = record(capture);
			let json = serde_json::to_vec(&original).unwrap();
			let parsed = parse(&json).unwrap();
			assert_eq!(parsed.id, original.id);
			assert_eq!(parsed.backup_type, original.backup_type);
			assert_eq!(parsed.taken_at, original.taken_at);
			assert_eq!(parsed.source, original.source);
			assert!(parsed.uploaded);
			assert_eq!(parsed.capture.backend(), backend);
		}
	}

	/// A hold taken before the divergence mark existed is still a rollback point,
	/// so its record has to keep parsing. Failing to would strand the hold rather
	/// than report it.
	#[test]
	fn a_record_written_before_the_divergence_mark_still_parses() {
		let json = br#"{
			"id": "x-20260814T054412Z",
			"backup_type": "x",
			"taken_at": "2026-08-14T05:44:12Z",
			"held_at": "2026-08-14T11:02:00Z",
			"source": "/var/lib/bestool/held-source/x",
			"uploaded": true,
			"capture": { "backend": "base-backup", "root": "/var/lib/bestool/held-source/x" }
		}"#;
		let parsed = parse(json).unwrap();
		assert_eq!(parsed.diverged_since, None);
	}

	const SHOW: &str = "\
pgsub
\tName: \t\t\tpgsub
\tUUID: \t\t\t9960cf5a-4a6d-a641-b986-3a71d4549d03
\tParent UUID: \t\t-
\tReceived UUID: \t\t-
\tGeneration: \t\t10
";

	#[test]
	fn reads_the_subvolumes_own_uuid_not_its_parents() {
		assert_eq!(
			parse_subvolume_uuid(SHOW).as_deref(),
			Some("9960cf5a-4a6d-a641-b986-3a71d4549d03")
		);
	}

	#[test]
	fn a_subvolume_with_no_uuid_at_all_is_not_identifiable() {
		assert_eq!(parse_subvolume_uuid("ERROR: not a subvolume"), None);
		assert_eq!(parse_subvolume_uuid("\tUUID: \t-\n"), None);
	}

	/// A btrfs mark from before the subvolume was recorded still parses; it is
	/// the restore that refuses to act on one, not the reader.
	#[test]
	fn a_btrfs_mark_without_its_subvolume_still_parses() {
		let json = br#"{"kind":"btrfs-generation","generation":4211}"#;
		let mark: DivergenceMark = serde_json::from_slice(json).unwrap();
		assert_eq!(
			mark,
			DivergenceMark::BtrfsGeneration {
				generation: 4_211,
				subvolume: None
			}
		);
	}

	/// The mark is what lets a later in-place restore ask the filesystem what
	/// diverged instead of reading both trees, so it has to survive the upgrade
	/// that sits between taking a hold and restoring from it.
	#[test]
	fn every_divergence_mark_round_trips() {
		let marks = [
			DivergenceMark::BtrfsGeneration {
				generation: 4_211,
				subvolume: Some("9960cf5a-4a6d-a641-b986-3a71d4549d03".into()),
			},
			DivergenceMark::UsnJournal {
				volume: PathBuf::from("C:"),
				journal_id: 0x01d5_f4e2_c3b1_a098,
				usn: 0x0012_3456,
			},
		];
		for mark in marks {
			let mut original = record(HeldCapture::BaseBackup {
				root: "/var/lib/bestool/held-source/x".into(),
			});
			original.diverged_since = Some(mark.clone());
			let parsed = parse(&serde_json::to_vec(&original).unwrap()).unwrap();
			assert_eq!(parsed.diverged_since, Some(mark));
		}
	}

	/// Releasing a btrfs hold reaches the subvolume through the top-level mount,
	/// so the device that mount needs has to survive the round trip. Without it
	/// the subvolume cannot be deleted and its space is never returned.
	#[test]
	fn a_btrfs_capture_keeps_what_it_takes_to_reach_the_subvolume() {
		let original = record(HeldCapture::Btrfs {
			toplevel_mount: "/run/bestool-toplevel".into(),
			snapshot_path: "/run/bestool-toplevel/bestool-held-x".into(),
			mount: "/var/lib/bestool/held-source/x".into(),
			fsdev: Some("/dev/disk/by-uuid/deadbeef".into()),
		});
		let parsed = parse(&serde_json::to_vec(&original).unwrap()).unwrap();
		let HeldCapture::Btrfs { fsdev, .. } = parsed.capture else {
			panic!("expected a btrfs capture");
		};
		assert_eq!(fsdev.as_deref(), Some("/dev/disk/by-uuid/deadbeef"));
	}

	/// Reattaching needs the device, and a record written before it was kept
	/// cannot be mounted again. The refusal has to say so rather than report
	/// success over a capture nothing exposed.
	#[tokio::test]
	async fn reattaching_without_a_device_refuses() {
		let scratch = std::env::temp_dir().join("bestool-hold-unit");
		let err = reattach(&HeldCapture::Btrfs {
			toplevel_mount: scratch.join("toplevel"),
			snapshot_path: scratch.join("toplevel/bestool-held-x"),
			mount: scratch.join("held-source/x"),
			fsdev: None,
		})
		.await
		.expect_err("a hold with no device cannot be reattached");
		assert!(err.to_string().contains("no device recorded"), "{err}");
	}

	/// Every backend that can report detached has to have a remedy: telling an
	/// operator to reattach something that cannot be reattached is worse than
	/// refusing up front. Asserted on the decision rather than by calling
	/// `reattach`, which would mount real storage.
	#[test]
	fn every_backend_that_can_detach_can_be_reattached() {
		for capture in [
			HeldCapture::Vss {
				shadow_id: "{deadbeef-0000-0000-0000-000000000000}".into(),
				junction: r"C:\bestool-backup-shadow\held\x".into(),
			},
			HeldCapture::Btrfs {
				toplevel_mount: "/nonexistent/toplevel".into(),
				snapshot_path: "/nonexistent/toplevel/bestool-held-x".into(),
				mount: "/nonexistent/held-source/x".into(),
				fsdev: Some("/dev/disk/by-uuid/deadbeef".into()),
			},
			HeldCapture::Lvm {
				vg: "vg0".into(),
				lv: "bestool-held-x".into(),
				mount: "/nonexistent/held-source/x".into(),
			},
		] {
			assert!(
				capture.exposes_separately(),
				"{} can detach, so it must be reattachable",
				capture.backend()
			);
		}
	}

	/// The capture is the directory itself, so it never detaches and there is
	/// never an exposure to put back.
	#[test]
	fn a_base_backup_exposes_nothing_separately() {
		assert!(
			!HeldCapture::BaseBackup {
				root: "/var/lib/bestool/held-source/x".into(),
			}
			.exposes_separately()
		);
	}

	/// Only the backends whose exposure is a mount this side can make. The others
	/// have to say so rather than silently do nothing and read as reattached.
	#[tokio::test]
	async fn reattaching_a_backend_without_a_mount_refuses() {
		let err = reattach(&HeldCapture::BaseBackup {
			root: std::env::temp_dir().join("bestool-hold-unit/x"),
		})
		.await
		.expect_err("a base backup exposes nothing to reattach");
		assert!(err.to_string().contains("not supported"), "{err}");
	}

	/// A record written before the device was kept still has to parse: dropping
	/// it would leave the hold unreadable and unreleasable at once.
	#[test]
	fn a_btrfs_capture_without_a_device_still_parses() {
		let json = br#"{
			"id": "x-20260814T054412Z",
			"backup_type": "tamanu-postgres",
			"held_at": "2026-08-14T11:02:00Z",
			"source": "/var/lib/bestool/held-source/x/18/main",
			"uploaded": true,
			"capture": {
				"backend": "btrfs",
				"toplevel_mount": "/run/t",
				"snapshot_path": "/run/t/bestool-held-x",
				"mount": "/var/lib/bestool/held-source/x"
			}
		}"#;
		let parsed = parse(json).unwrap();
		let HeldCapture::Btrfs { fsdev, .. } = parsed.capture else {
			panic!("expected a btrfs capture");
		};
		assert!(fsdev.is_none());
	}

	/// A shadow copy's junction substitutes a device path, and opening the
	/// junction itself opens that device rather than the root directory of the
	/// filesystem on it — so it reads empty however healthy the copy is, while
	/// every path through it resolves. Judging a hold by its junction therefore
	/// reports every held shadow copy detached, from the moment it is taken, with
	/// no reattach able to clear it. The state has to be read where a restore
	/// reads the capture.
	///
	/// Off Windows, where the shadow itself cannot be queried, the junction
	/// standing in for it is what makes this expressible as a unit test; the
	/// Windows side is covered end-to-end by `wmi_shadow_roundtrip`.
	#[cfg(not(windows))]
	#[tokio::test]
	async fn a_shadow_copy_hold_is_judged_where_the_restore_reads() {
		let scratch = tempfile::tempdir().unwrap();
		let junction = scratch.path().join("held").join("x");
		let source = junction.join("Program Files").join("PostgreSQL").join("12");
		std::fs::create_dir_all(&source).unwrap();

		let mut held = record(HeldCapture::Vss {
			shadow_id: "{deadbeef-0000-0000-0000-000000000000}".into(),
			junction: junction.clone(),
		});
		held.source = source;
		assert_eq!(capture_state(&held).await, CaptureState::Present);

		// The copy behind the junction stops serving the capture: what a restore
		// reads has gone, even though the junction is still standing.
		std::fs::remove_dir_all(junction.join("Program Files")).unwrap();
		assert_eq!(capture_state(&held).await, CaptureState::Detached);
	}

	/// A base-backup capture has no freeze instant, and the record says so rather
	/// than substituting the time it was held.
	#[test]
	fn a_capture_without_a_freeze_instant_records_none() {
		let mut original = record(HeldCapture::BaseBackup {
			root: "/var/lib/bestool/held-source/x".into(),
		});
		original.taken_at = None;
		let parsed = parse(&serde_json::to_vec(&original).unwrap()).unwrap();
		assert_eq!(parsed.taken_at, None);
		assert_eq!(parsed.held_at, original.held_at);
	}

	#[test]
	fn ids_are_sortable_and_carry_the_type_and_time() {
		let earlier = mint_id("tamanu-postgres", "2026-08-14T05:44:12Z".parse().unwrap());
		let later = mint_id("tamanu-postgres", "2026-08-14T06:00:00Z".parse().unwrap());
		assert_eq!(earlier, "tamanu-postgres-20260814T054412Z");
		assert!(earlier < later, "{earlier} should sort before {later}");
	}
}