use std::path::{Path, PathBuf};
use jiff::Timestamp;
use miette::{Context as _, IntoDiagnostic as _, Result};
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,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "backend", rename_all = "kebab-case")]
pub enum HeldCapture {
Btrfs {
toplevel_mount: PathBuf,
snapshot_path: PathBuf,
mount: PathBuf,
},
Lvm {
vg: String,
lv: String,
mount: PathBuf,
},
Vss {
shadow_id: String,
junction: PathBuf,
},
BaseBackup {
root: PathBuf,
},
}
impl HeldCapture {
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())),
}
}
pub async fn capture_present(capture: &HeldCapture) -> bool {
match capture {
HeldCapture::Btrfs { snapshot_path, .. } => snapshot_path.exists(),
HeldCapture::Lvm { vg, lv, .. } => super::postgresql::lvm::held_present(vg, lv).await,
HeldCapture::Vss { shadow_id, junction } => vss_present(shadow_id, junction).await,
HeldCapture::BaseBackup { root } => root.exists(),
}
}
#[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 release(capture: &HeldCapture) -> Result<()> {
match capture {
HeldCapture::Btrfs {
toplevel_mount,
snapshot_path,
mount,
} => super::postgresql::btrfs::release_held(toplevel_mount, snapshot_path, mount).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 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,
}
}
#[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(),
},
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_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}");
}
}