use std::path::Path;
use miette::{Result, bail};
use tracing::{debug, info, warn};
use super::sync::Delta;
use crate::actions::canopy::{
backup::hold::{HeldCapture, HoldRecord},
space::{available as free_space, fmt_bytes},
};
fn with_headroom(bytes: u64) -> u64 {
bytes.saturating_add(bytes / 20).max(64 * 1024 * 1024)
}
#[derive(Debug)]
pub enum CowStore {
SameFilesystem,
Separate {
name: String,
available: Option<u64>,
remedy: String,
},
None,
}
#[cfg(unix)]
pub async fn thin_pool(record: &HoldRecord) -> Option<super::blockdev::ThinPool> {
match &record.capture {
HeldCapture::Lvm { vg, lv, .. } => super::blockdev::thin_pool_of(vg, lv).await,
_ => None,
}
}
#[cfg(unix)]
pub async fn cow_store(record: &HoldRecord, pool: Option<&super::blockdev::ThinPool>) -> CowStore {
cow_store_inner(record, pool).await
}
#[cfg(not(unix))]
pub async fn cow_store(record: &HoldRecord) -> CowStore {
cow_store_inner(record, None).await
}
async fn cow_store_inner(
record: &HoldRecord,
#[cfg(unix)] pool: Option<&super::blockdev::ThinPool>,
#[cfg(not(unix))] pool: Option<&()>,
) -> CowStore {
match &record.capture {
HeldCapture::Btrfs { .. } => CowStore::SameFilesystem,
#[cfg(not(unix))]
HeldCapture::Lvm { .. } => {
let _ = pool;
CowStore::SameFilesystem
}
#[cfg(unix)]
HeldCapture::Lvm { vg, lv, .. } => match pool {
Some(pool) => thin_pool_store(pool).await,
None => thick_snapshot_store(vg, lv).await,
},
#[cfg(windows)]
HeldCapture::Vss { .. } => shadow_storage(record).await,
#[cfg(not(windows))]
HeldCapture::Vss { .. } => CowStore::SameFilesystem,
HeldCapture::BaseBackup { .. } => CowStore::None,
}
}
pub async fn ensure_room(
live: &Path,
delta: &Delta,
store: &CowStore,
cow_bytes: Option<u64>,
) -> Result<()> {
let growth = delta.net_growth();
let written = delta.written_bytes().max(cow_bytes.unwrap_or(0));
info!(
growth,
written,
"sized the divergence between the capture and the live tree"
);
let filesystem_need = match store {
CowStore::SameFilesystem => growth.max(written),
CowStore::Separate { .. } | CowStore::None => growth,
};
if filesystem_need > 0 {
let required = with_headroom(filesystem_need);
let Some(available) = free_space(live) else {
warn!(
"could not read the free space on {}; restoring in place needs about {} there",
live.display(),
fmt_bytes(required),
);
return check_cow_store(store, written);
};
if available < required {
bail!(
"restoring in place needs about {} free on {} but only {} is available; \
free up space and retry",
fmt_bytes(required),
live.display(),
fmt_bytes(available),
);
}
debug!(required, available, "the live filesystem has room for the divergence");
}
check_cow_store(store, written)
}
fn check_cow_store(store: &CowStore, written: u64) -> Result<()> {
if let CowStore::Separate {
name,
available,
remedy,
} = store
{
let required = with_headroom(written);
match available {
Some(available) if *available < required => bail!(
"restoring in place writes about {} over blocks the capture still \
references, which {name} has to hold, but only {} of it is free; \
{remedy}",
fmt_bytes(required),
fmt_bytes(*available),
),
Some(available) => {
debug!(required, available = *available, "{name} has room for the divergence");
}
None => warn!(
"could not read how much room is left in {name}; restoring in place \
writes about {} through it, and the capture is lost if it fills",
fmt_bytes(required),
),
}
}
Ok(())
}
#[cfg(unix)]
async fn thick_snapshot_store(vg: &str, lv: &str) -> CowStore {
let qualified = format!("{vg}/{lv}");
let size: Option<u64> = super::blockdev::lvs("lv_size", &qualified, true)
.await
.and_then(|raw| raw.parse().ok());
let used: Option<f64> = super::blockdev::lvs("snap_percent", &qualified, false)
.await
.and_then(|raw| raw.parse().ok());
let available = match (size, used) {
#[expect(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "a snapshot's fill is reported as a percentage, so the answer is \
approximate by construction and only ever compared against an estimate"
)]
(Some(size), Some(used)) => Some((size as f64 * (100.0 - used) / 100.0) as u64),
_ => None,
};
CowStore::Separate {
name: format!("the snapshot {qualified}'s own allocation"),
available,
remedy: format!("extend it with `lvextend {qualified}` first"),
}
}
#[cfg(unix)]
async fn thin_pool_store(pool: &super::blockdev::ThinPool) -> CowStore {
let qualified = pool.qualified();
let size: Option<u64> = super::blockdev::lvs("lv_size", &qualified, true)
.await
.and_then(|raw| raw.parse().ok());
let used: Option<f64> = super::blockdev::lvs("data_percent", &qualified, false)
.await
.and_then(|raw| raw.parse().ok());
let available = match (size, used) {
#[expect(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "a pool's free space is reported as a percentage, so the answer is \
approximate by construction and only ever compared against an estimate"
)]
(Some(size), Some(used)) => Some((size as f64 * (100.0 - used) / 100.0) as u64),
_ => None,
};
CowStore::Separate {
name: format!("the thin pool {qualified}"),
available,
remedy: format!("extend it with `lvextend --poolmetadatasize` / `lvextend {qualified}` first"),
}
}
#[cfg(windows)]
async fn shadow_storage(record: &HoldRecord) -> CowStore {
let Some(volume) = shadowed_volume(record) else {
warn!(
"could not tell which volume hold {} was shadowed from, so its shadow \
storage headroom is unknown; the capture is lost if that store fills",
record.id,
);
return CowStore::Separate {
name: "the volume's shadow copy storage".to_owned(),
available: None,
remedy: "check it with `vssadmin list shadowstorage`".to_owned(),
};
};
let vssadmin = std::path::PathBuf::from(
std::env::var_os("SystemRoot").unwrap_or_else(|| r"C:\Windows".into()),
)
.join("System32")
.join("vssadmin.exe");
let available = tokio::process::Command::new(vssadmin)
.args(["list", "shadowstorage", &format!("/for={volume}")])
.stdin(std::process::Stdio::null())
.output()
.await
.ok()
.filter(|output| output.status.success())
.and_then(|output| parse_shadow_headroom(&String::from_utf8_lossy(&output.stdout)));
CowStore::Separate {
name: format!("{volume}'s shadow copy storage"),
available,
remedy: format!(
"raise the cap with `vssadmin resize shadowstorage /for={volume} /on=<vol> /maxsize=<size>` first"
),
}
}
#[cfg(windows)]
fn shadowed_volume(record: &HoldRecord) -> Option<String> {
match &record.diverged_since {
Some(crate::actions::canopy::backup::hold::DivergenceMark::UsnJournal { volume, .. }) => {
Some(volume.to_str()?.to_owned())
}
_ => None,
}
}
#[cfg(windows)]
fn parse_shadow_headroom(output: &str) -> Option<u64> {
let used = find_size(output, "Used Shadow Copy Storage space");
let maximum = find_size(output, "Maximum Shadow Copy Storage space");
match (used, maximum) {
(_, Some(None)) => None,
(Some(Some(used)), Some(Some(maximum))) => Some(maximum.saturating_sub(used)),
_ => None,
}
}
#[cfg(windows)]
fn find_size(output: &str, label: &str) -> Option<Option<u64>> {
let line = output
.lines()
.find(|line| line.trim_start().starts_with(label))?;
let (_, value) = line.split_once(':')?;
Some(parse_size(value.trim()))
}
#[cfg(windows)]
fn parse_size(text: &str) -> Option<u64> {
let text = text.split('(').next()?.trim();
let (number, unit) = text.split_once(' ')?;
let number: f64 = number.trim().replace(',', "").parse().ok()?;
let scale: u64 = match unit.trim().to_ascii_uppercase().as_str() {
"B" | "BYTES" => 1,
"KB" => 1024,
"MB" => 1024 * 1024,
"GB" => 1024 * 1024 * 1024,
"TB" => 1024_u64.pow(4),
"PB" => 1024_u64.pow(5),
_ => return None,
};
#[expect(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "vssadmin prints one decimal place, so the value is approximate as printed"
)]
Some((number * scale as f64) as u64)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn headroom_is_five_percent_over_a_floor() {
assert_eq!(with_headroom(0), 64 * 1024 * 1024);
let ten_gib = 10 * 1024 * 1024 * 1024;
assert_eq!(with_headroom(ten_gib), ten_gib + ten_gib / 20);
}
#[tokio::test]
async fn a_delta_that_frees_more_than_it_writes_still_needs_room_on_a_shared_store() {
let delta = Delta {
copy_bytes: 1000,
displaced_bytes: 1000,
remove_bytes: 500,
..Default::default()
};
assert_eq!(delta.net_growth(), 0);
assert_eq!(delta.written_bytes(), 1000);
}
#[tokio::test]
async fn an_empty_delta_needs_nothing_anywhere() {
let tmp = tempfile::tempdir().unwrap();
ensure_room(tmp.path(), &Delta::default(), &CowStore::None, None)
.await
.unwrap();
}
#[tokio::test]
async fn a_separate_store_without_room_refuses_and_says_what_to_do() {
let tmp = tempfile::tempdir().unwrap();
let delta = Delta {
copy_bytes: 10 * 1024 * 1024 * 1024,
displaced_bytes: 10 * 1024 * 1024 * 1024,
..Default::default()
};
let store = CowStore::Separate {
name: "the thin pool vg0/pool".into(),
available: Some(1024),
remedy: "extend it first".into(),
};
let err = ensure_room(tmp.path(), &delta, &store, None).await.unwrap_err().to_string();
assert!(err.contains("the thin pool vg0/pool"), "got: {err}");
assert!(err.contains("extend it first"), "got: {err}");
}
#[tokio::test]
async fn a_separate_store_of_unknown_size_does_not_refuse() {
let tmp = tempfile::tempdir().unwrap();
let delta = Delta {
copy_bytes: 10 * 1024 * 1024 * 1024,
displaced_bytes: 10 * 1024 * 1024 * 1024,
..Default::default()
};
let store = CowStore::Separate {
name: "shadow copy storage".into(),
available: None,
remedy: "raise the cap".into(),
};
ensure_room(tmp.path(), &delta, &store, None).await.unwrap();
}
#[cfg(windows)]
#[test]
fn reads_the_shadow_storage_headroom() {
let output = "\r\nShadow Copy Storage association\r\n For volume: (C:)\\\\?\\Volume{a}\\\r\n Shadow Copy Storage volume: (C:)\\\\?\\Volume{a}\\\r\n Used Shadow Copy Storage space: 25.0 GB (2%)\r\n Allocated Shadow Copy Storage space: 26.0 GB (2%)\r\n Maximum Shadow Copy Storage space: 100.0 GB (10%)\r\n";
let headroom = parse_shadow_headroom(output).unwrap();
assert_eq!(headroom, 75 * 1024 * 1024 * 1024);
}
#[cfg(windows)]
#[test]
fn an_unbounded_cap_is_not_a_number_to_gate_on() {
let output = " Used Shadow Copy Storage space: 25.0 GB (2%)\r\n Maximum Shadow Copy Storage space: UNBOUNDED (100%)\r\n";
assert_eq!(parse_shadow_headroom(output), None);
}
#[cfg(windows)]
#[test]
fn unrecognised_output_yields_nothing_rather_than_a_wrong_number() {
assert_eq!(parse_shadow_headroom("No items found that satisfy the query."), None);
}
#[cfg(windows)]
#[test]
fn parses_the_sizes_vssadmin_prints() {
assert_eq!(parse_size("25.0 GB (2%)"), Some(25 * 1024 * 1024 * 1024));
assert_eq!(parse_size("512 MB"), Some(512 * 1024 * 1024));
assert_eq!(parse_size("1,024 MB"), Some(1024 * 1024 * 1024));
assert_eq!(parse_size("UNBOUNDED"), None);
}
}