use ring::digest::{digest, SHA256};
use serde::Deserialize;
use std::collections::{BTreeMap, BTreeSet};
use std::fs::{self, File};
use std::io::{self, Read};
use std::path::{Path, PathBuf};
const SHARED_SNAPSHOT_DIR: &str = "/root/.local/supermachine-snapshots";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum SnapshotKind {
Full,
Diff,
Other,
}
#[derive(Debug, Clone)]
struct Entry {
path: PathBuf,
sha256: Option<String>,
size: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct JsonEntry {
path: PathBuf,
sha256: Option<String>,
size: Option<u64>,
}
fn main() {
if let Err(e) = run() {
eprintln!("error: {e}");
std::process::exit(2);
}
}
fn run() -> io::Result<()> {
let mut apply = false;
let mut allow_shared = false;
let mut worklist = None;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--apply" => apply = true,
"--allow-shared" => allow_shared = true,
"--worklist" => worklist = args.next().map(PathBuf::from),
"-h" | "--help" => {
usage();
return Ok(());
}
_ if worklist.is_none() => worklist = Some(PathBuf::from(a)),
_ => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown arg {a}"),
))
}
}
}
let worklist =
worklist.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing worklist"))?;
let entries = read_worklist(&worklist)?;
let code = execute(entries, apply, allow_shared)?;
if code != 0 {
std::process::exit(code);
}
Ok(())
}
fn usage() {
eprintln!("USAGE: snapshot-gc-execute [--apply] [--allow-shared] --worklist FILE\nDry-run is default. Worklist accepts JSON array, JSONL, or lines: PATH [sha256=HEX] [size=N].");
}
fn read_worklist(path: &Path) -> io::Result<Vec<Entry>> {
let text = fs::read_to_string(path)?;
if text.trim_start().starts_with('[') {
let xs: Vec<JsonEntry> = serde_json::from_str(&text).map_err(invalid)?;
return Ok(xs
.into_iter()
.map(|x| Entry {
path: x.path,
sha256: x.sha256,
size: x.size,
})
.collect());
}
let mut out = Vec::new();
for (idx, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if line.starts_with('{') {
let x: JsonEntry = serde_json::from_str(line).map_err(invalid)?;
out.push(Entry {
path: x.path,
sha256: x.sha256,
size: x.size,
});
continue;
}
let mut parts = line.split_whitespace();
let path = parts
.next()
.ok_or_else(|| invalid(format!("line {} missing path", idx + 1)))?;
let mut e = Entry {
path: PathBuf::from(path),
sha256: None,
size: None,
};
for p in parts {
if let Some(v) = p.strip_prefix("sha256=") {
e.sha256 = Some(v.to_ascii_lowercase());
} else if let Some(v) = p.strip_prefix("size=") {
e.size = Some(v.parse().map_err(invalid)?);
} else if e.sha256.is_none() && p.len() == 64 {
e.sha256 = Some(p.to_ascii_lowercase());
} else {
return Err(invalid(format!("line {} unknown token {p}", idx + 1)));
}
}
out.push(e);
}
Ok(out)
}
fn execute(entries: Vec<Entry>, apply: bool, allow_shared: bool) -> io::Result<i32> {
let listed: BTreeSet<PathBuf> = entries.iter().map(|e| clean(&e.path)).collect();
let kinds: BTreeMap<PathBuf, SnapshotKind> = entries
.iter()
.map(|e| Ok((clean(&e.path), snapshot_kind(&e.path)?)))
.collect::<io::Result<_>>()?;
let external_diffs = discover_diffs(&entries)?;
let mut exit = 0;
let mut ordered = entries;
ordered.sort_by_key(|e| {
if kinds.get(&clean(&e.path)) == Some(&SnapshotKind::Diff) {
0
} else {
1
}
});
for e in ordered {
let path = clean(&e.path);
let kind = *kinds.get(&path).unwrap_or(&SnapshotKind::Other);
let decision = decide(&e, kind, &listed, &external_diffs, allow_shared)?;
println!(
"{} path={} kind={:?} reason={}",
if decision.delete {
if apply {
"delete"
} else {
"dry_run_delete"
}
} else {
"skip"
},
path.display(),
kind,
decision.reason
);
if decision.delete && apply {
if path.is_dir() {
fs::remove_dir_all(&path)?;
} else {
fs::remove_file(&path)?;
}
} else if !decision.delete {
exit = 1;
}
}
Ok(exit)
}
struct Decision {
delete: bool,
reason: &'static str,
}
fn decide(
e: &Entry,
kind: SnapshotKind,
listed: &BTreeSet<PathBuf>,
external_diffs: &BTreeMap<PathBuf, Vec<PathBuf>>,
allow_shared: bool,
) -> io::Result<Decision> {
let path = clean(&e.path);
if !allow_shared && path.starts_with(SHARED_SNAPSHOT_DIR) {
return Ok(skip("shared_snapshot_dir_forbidden"));
}
if !path.exists() {
return Ok(skip("missing_idempotent"));
}
if let Some(sz) = e.size {
if fs::metadata(&path)?.len() != sz {
return Ok(skip("size_mismatch"));
}
}
if let Some(want) = &e.sha256 {
if sha256_hex(&path)? != want.to_ascii_lowercase() {
return Ok(skip("sha256_mismatch"));
}
}
if kind == SnapshotKind::Full {
if is_live_referenced(&path)? {
return Ok(skip("live_process_reference"));
}
if let Some(ds) = external_diffs.get(&path) {
if ds.iter().any(|d| !listed.contains(d)) {
return Ok(skip("base_has_unlisted_diff"));
}
}
}
Ok(Decision {
delete: true,
reason: "guards_passed",
})
}
fn skip(reason: &'static str) -> Decision {
Decision {
delete: false,
reason,
}
}
fn snapshot_kind(path: &Path) -> io::Result<SnapshotKind> {
let mut f = match File::open(path) {
Ok(f) => f,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(SnapshotKind::Other),
Err(e) => return Err(e),
};
let mut magic = [0; 8];
let n = f.read(&mut magic)?;
Ok(if n == 8 && &magic == b"SMSNAP7D" {
SnapshotKind::Diff
} else if n == 8 && magic.starts_with(b"SMSNAP") {
SnapshotKind::Full
} else {
SnapshotKind::Other
})
}
fn diff_base_path(path: &Path) -> io::Result<Option<PathBuf>> {
let mut f = File::open(path)?;
let mut magic = [0; 8];
f.read_exact(&mut magic)?;
if &magic != b"SMSNAP7D" {
return Ok(None);
}
let mut lenb = [0; 4];
f.read_exact(&mut lenb)?;
let len = u32::from_le_bytes(lenb) as usize;
if len > 1_000_000 {
return Err(invalid("diff base path too long"));
}
let mut buf = vec![0; len];
f.read_exact(&mut buf)?;
let s = String::from_utf8(buf).map_err(invalid)?;
Ok(Some(clean(Path::new(&s))))
}
fn discover_diffs(entries: &[Entry]) -> io::Result<BTreeMap<PathBuf, Vec<PathBuf>>> {
let mut roots = BTreeSet::new();
for e in entries {
if let Some(p) = e.path.parent() {
roots.insert(p.to_path_buf());
}
}
let mut out: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
for r in roots {
scan_dir(&r, &mut out)?;
}
Ok(out)
}
fn scan_dir(dir: &Path, out: &mut BTreeMap<PathBuf, Vec<PathBuf>>) -> io::Result<()> {
for ent in fs::read_dir(dir)? {
let p = ent?.path();
if p.is_dir() {
scan_dir(&p, out)?;
} else if snapshot_kind(&p)? == SnapshotKind::Diff {
if let Some(b) = diff_base_path(&p)? {
out.entry(b).or_default().push(clean(&p));
}
}
}
Ok(())
}
fn is_live_referenced(path: &Path) -> io::Result<bool> {
let target = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let self_pid = std::process::id().to_string();
for pid in fs::read_dir("/proc")? {
let pid = pid?.path();
let Some(pid_name) = pid.file_name().and_then(|s| s.to_str()) else {
continue;
};
if pid_name == self_pid || !pid_name.chars().all(|c| c.is_ascii_digit()) {
continue;
}
if let Ok(fds) = fs::read_dir(pid.join("fd")) {
for f in fds.flatten() {
let Ok(link) = fs::read_link(f.path()) else {
continue;
};
let link_text = link.to_string_lossy();
if link == target
|| link_text
.strip_suffix(" (deleted)")
.is_some_and(|p| Path::new(p) == target)
{
return Ok(true);
}
if let Ok(canon) = fs::canonicalize(&link) {
if canon == target {
return Ok(true);
}
}
}
}
if let Ok(maps) = fs::read_to_string(pid.join("maps")) {
if maps.lines().any(|l| {
l.split_whitespace()
.last()
.is_some_and(|p| Path::new(p) == target || clean(Path::new(p)) == target)
}) {
return Ok(true);
}
}
}
Ok(false)
}
fn sha256_hex(path: &Path) -> io::Result<String> {
let bytes = fs::read(path)?;
Ok(hex(digest(&SHA256, &bytes).as_ref()))
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn clean(p: &Path) -> PathBuf {
fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
}
fn invalid<E: std::fmt::Display>(e: E) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
fn tmp() -> PathBuf {
let p = std::env::temp_dir().join(format!(
"supermachine-gc-test-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = fs::remove_dir_all(&p);
fs::create_dir_all(&p).unwrap();
p
}
fn full(p: &Path) {
fs::write(p, b"SMSNAP07full-body").unwrap();
}
fn diff(p: &Path, base: &Path) {
let mut v = b"SMSNAP7D".to_vec();
let s = base.to_string_lossy();
v.extend(&(s.len() as u32).to_le_bytes());
v.extend(s.as_bytes());
v.extend(b"rest");
fs::write(p, v).unwrap();
}
#[test]
fn parses_diff_base() {
let d = tmp();
let b = d.join("base.snap");
let x = d.join("d.snap");
full(&b);
diff(&x, &b);
assert_eq!(diff_base_path(&x).unwrap(), Some(clean(&b)));
}
#[test]
fn refuses_base_with_unlisted_diff() {
let d = tmp();
let b = d.join("base.snap");
let x = d.join("d.snap");
full(&b);
diff(&x, &b);
let e = Entry {
path: b.clone(),
sha256: None,
size: None,
};
let ext = discover_diffs(std::slice::from_ref(&e)).unwrap();
let dec = decide(
&e,
SnapshotKind::Full,
&BTreeSet::from([clean(&b)]),
&ext,
true,
)
.unwrap();
assert_eq!(dec.reason, "base_has_unlisted_diff");
}
#[test]
fn diffs_delete_before_bases_and_idempotent() {
let d = tmp();
let b = d.join("base.snap");
let x = d.join("d.snap");
full(&b);
diff(&x, &b);
let es = vec![
Entry {
path: b.clone(),
sha256: None,
size: None,
},
Entry {
path: x.clone(),
sha256: None,
size: None,
},
];
assert_eq!(execute(es.clone(), true, true).unwrap(), 0);
assert!(!b.exists() && !x.exists());
assert_eq!(execute(es, true, true).unwrap(), 1);
}
#[test]
fn live_mapping_refuses() {
let d = tmp();
let b = d.join("base.snap");
full(&b);
let mut child = Command::new("sh")
.arg("-c")
.arg(format!("exec 9< '{}' ; sleep 2", b.display()))
.spawn()
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(200));
let e = Entry {
path: b.clone(),
sha256: None,
size: None,
};
let dec = decide(
&e,
SnapshotKind::Full,
&BTreeSet::from([clean(&b)]),
&BTreeMap::new(),
true,
)
.unwrap();
let _ = child.kill();
let _ = child.wait();
assert_eq!(dec.reason, "live_process_reference");
}
}