use std::path::{Path, PathBuf};
use jiff::Timestamp;
use miette::{Context as _, IntoDiagnostic as _, Result, bail};
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HoldRecord {
pub id: String,
pub backup_type: String,
#[serde(default)]
pub taken_at: Option<Timestamp>,
pub held_at: Timestamp,
pub source: PathBuf,
pub uploaded: bool,
pub capture: HeldCapture,
#[serde(default)]
pub diverged_since: Option<DivergenceMark>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum DivergenceMark {
BtrfsGeneration {
generation: u64,
#[serde(default)]
subvolume: Option<String>,
},
UsnJournal {
volume: PathBuf,
journal_id: u64,
usn: i64,
},
}
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)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "backend", rename_all = "kebab-case")]
pub enum HeldCapture {
Btrfs {
toplevel_mount: PathBuf,
snapshot_path: PathBuf,
mount: PathBuf,
#[serde(default)]
fsdev: Option<String>,
},
Lvm {
vg: String,
lv: String,
mount: PathBuf,
},
Vss {
shadow_id: String,
junction: PathBuf,
},
BaseBackup {
root: PathBuf,
},
}
impl HeldCapture {
pub fn exposes_separately(&self) -> bool {
match self {
Self::Btrfs { .. } | Self::Lvm { .. } | Self::Vss { .. } => true,
Self::BaseBackup { .. } => false,
}
}
pub fn backend(&self) -> &'static str {
match self {
Self::Btrfs { .. } => "btrfs",
Self::Lvm { .. } => "lvm",
Self::Vss { .. } => "vss",
Self::BaseBackup { .. } => "basebackup",
}
}
}
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")
}
}
fn record_path(id: &str) -> PathBuf {
records_dir().join(format!("{id}.json"))
}
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)
}
}
pub fn mint_id(backup_type: &str, at: Timestamp) -> String {
let stamp = at.strftime("%Y%m%dT%H%M%SZ");
format!("{backup_type}-{stamp}")
}
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(())
}
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()))
}
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")
}
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())),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaptureState {
Present,
Detached,
Gone,
}
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 {
CaptureState::Gone
} else {
CaptureState::Detached
}
} else if super::postgresql::btrfs::attached(mount).await {
CaptureState::Present
} else {
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 {
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
}
#[cfg(not(windows))]
async fn vss_present(_shadow_id: &str, junction: &Path) -> bool {
junction.exists()
}
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()
),
}
}
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
}
#[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,
}
}
#[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);
}
}
#[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);
}
#[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
}
);
}
#[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));
}
}
#[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"));
}
#[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}");
}
#[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()
);
}
}
#[test]
fn a_base_backup_exposes_nothing_separately() {
assert!(
!HeldCapture::BaseBackup {
root: "/var/lib/bestool/held-source/x".into(),
}
.exposes_separately()
);
}
#[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}");
}
#[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());
}
#[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);
std::fs::remove_dir_all(junction.join("Program Files")).unwrap();
assert_eq!(capture_state(&held).await, CaptureState::Detached);
}
#[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}");
}
}