use std::collections::BTreeMap;
use std::fs::{self, File};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use serde::{Deserialize, Serialize};
use crate::cache_dir::unique_tmp_suffix;
use crate::error::{Result, RightsizeError};
use crate::model::ContainerSpec;
#[derive(Clone, Debug)]
pub struct Checkpoint {
pub checkpoint_ref: String,
pub backend: String,
pub spec: ContainerSpec,
}
const NAME_PATTERN: &str = "^[a-z0-9][a-z0-9-]{0,40}$";
fn name_regex() -> &'static regex_lite::Regex {
static RE: OnceLock<regex_lite::Regex> = OnceLock::new();
RE.get_or_init(|| regex_lite::Regex::new(NAME_PATTERN).expect("NAME_PATTERN is a valid regex"))
}
pub(crate) fn validate_name(name: &str) -> Result<()> {
if name_regex().is_match(name) {
Ok(())
} else {
Err(RightsizeError::InvalidCheckpointName {
name: name.to_string(),
})
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct NamedRegistrySpec {
pub env: BTreeMap<String, String>,
pub command: Option<Vec<String>>,
#[serde(rename = "exposedPorts")]
pub exposed_ports: Vec<u16>,
#[serde(rename = "memoryLimitMb")]
pub memory_limit_mb: Option<u64>,
}
impl NamedRegistrySpec {
pub(crate) fn from_container_spec(spec: &ContainerSpec) -> Self {
NamedRegistrySpec {
env: spec.env.iter().cloned().collect(),
command: spec.command.clone(),
exposed_ports: spec.ports.iter().map(|p| p.guest_port).collect(),
memory_limit_mb: spec.memory_limit_mb,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct NamedRegistryEntry {
pub name: String,
#[serde(rename = "ref")]
pub checkpoint_ref: String,
pub backend: String,
#[serde(rename = "createdIso")]
pub created_iso: String,
pub spec: NamedRegistrySpec,
}
pub(crate) struct Registry {
path: PathBuf,
}
impl Registry {
pub(crate) fn new(cache_dir: &Path, name: &str) -> Self {
Registry {
path: cache_dir.join("checkpoints").join(format!("{name}.json")),
}
}
pub(crate) fn exists(&self) -> bool {
self.path.exists()
}
pub(crate) fn read(&self) -> Option<NamedRegistryEntry> {
let raw = fs::read(&self.path).ok()?;
serde_json::from_slice(&raw).ok()
}
pub(crate) fn write_atomic(&self, entry: &NamedRegistryEntry) -> std::io::Result<()> {
let dir = self
.path
.parent()
.expect("registry path always has a parent (cache_dir/checkpoints/)");
fs::create_dir_all(dir)?;
let json = serde_json::to_vec_pretty(entry)
.expect("NamedRegistryEntry has no non-serializable fields");
let tmp = self
.path
.with_extension(format!("json.tmp.{}", unique_tmp_suffix()));
{
let mut f = File::create(&tmp)?;
f.write_all(&json)?;
}
fs::rename(&tmp, &self.path)
}
pub(crate) fn delete(&self) {
let _ = fs::remove_file(&self.path);
}
}
pub(crate) fn list_registry_entries(cache_dir: &Path) -> std::io::Result<Vec<NamedRegistryEntry>> {
let dir = cache_dir.join("checkpoints");
let read_dir = match fs::read_dir(&dir) {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let mut entries = Vec::new();
for dir_entry in read_dir {
let path = dir_entry?.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
if let Ok(raw) = fs::read(&path) {
if let Ok(parsed) = serde_json::from_slice::<NamedRegistryEntry>(&raw) {
entries.push(parsed);
}
}
}
Ok(entries)
}
pub(crate) fn generate_ref_nonce() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::SeqCst) as u128;
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let pid = std::process::id() as u128;
let mixed = nanos
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
.wrapping_add(pid)
.wrapping_add(seq);
let low48 = (mixed & 0xFFFF_FFFF_FFFF) as u64;
format!("{low48:012x}")
}
#[cfg(test)]
mod tests {
use super::*;
fn is_twelve_lowercase_hex(s: &str) -> bool {
s.len() == 12
&& s.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
}
#[test]
fn generate_ref_nonce_is_twelve_lowercase_hex_chars() {
let n = generate_ref_nonce();
assert!(is_twelve_lowercase_hex(&n), "{n}");
}
#[test]
fn generate_ref_nonce_differs_across_back_to_back_calls() {
let a = generate_ref_nonce();
let b = generate_ref_nonce();
assert_ne!(a, b, "two checkpoints must never share a ref nonce");
}
#[test]
fn validate_name_accepts_the_pinned_pattern() {
assert!(validate_name("a").is_ok());
assert!(validate_name("seeded-db").is_ok());
assert!(validate_name("a0-1-2-3").is_ok());
let max_len = format!("a{}", "0".repeat(40));
assert_eq!(max_len.len(), 41);
assert!(validate_name(&max_len).is_ok());
}
#[test]
fn validate_name_rejects_anything_outside_the_pattern() {
for bad in [
"",
"-leading-dash",
"Uppercase",
"has_underscore",
"has space",
"a.b",
&format!("a{}", "0".repeat(41)), ] {
let err = validate_name(bad).expect_err(&format!("{bad:?} must be rejected"));
assert!(
matches!(err, RightsizeError::InvalidCheckpointName { ref name } if name == bad),
"{err}"
);
}
}
fn temp_cache_dir(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"rz-checkpoint-registry-{label}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&dir).unwrap();
dir
}
fn sample_entry() -> NamedRegistryEntry {
NamedRegistryEntry {
name: "seeded-db".to_string(),
checkpoint_ref: "rz-ckpt-seeded-db".to_string(),
backend: "microsandbox".to_string(),
created_iso: "2025-01-01T00:00:00Z".to_string(),
spec: NamedRegistrySpec {
env: BTreeMap::from([("A".to_string(), "1".to_string())]),
command: Some(vec!["redis-server".to_string()]),
exposed_ports: vec![6379],
memory_limit_mb: Some(256),
},
}
}
#[test]
fn write_then_read_round_trips() {
let cache = temp_cache_dir("round-trip");
let registry = Registry::new(&cache, "seeded-db");
registry.write_atomic(&sample_entry()).unwrap();
assert!(registry.exists());
assert_eq!(registry.read(), Some(sample_entry()));
}
#[test]
fn missing_registry_neither_exists_nor_reads() {
let cache = temp_cache_dir("missing");
let registry = Registry::new(&cache, "seeded-db");
assert!(!registry.exists());
assert!(registry.read().is_none());
}
#[test]
fn corrupt_registry_exists_but_does_not_parse() {
let cache = temp_cache_dir("corrupt");
let registry = Registry::new(&cache, "seeded-db");
fs::create_dir_all(cache.join("checkpoints")).unwrap();
fs::write(
cache.join("checkpoints").join("seeded-db.json"),
b"not json",
)
.unwrap();
assert!(registry.exists(), "a corrupt file still exists on disk");
assert!(registry.read().is_none(), "but it must not parse");
}
#[test]
fn delete_is_idempotent_and_best_effort() {
let cache = temp_cache_dir("delete");
let registry = Registry::new(&cache, "seeded-db");
registry.write_atomic(&sample_entry()).unwrap();
registry.delete();
assert!(!registry.exists());
registry.delete(); }
#[test]
fn write_atomic_survives_a_stale_leftover_tmp_file() {
let cache = temp_cache_dir("stale-tmp");
let registry = Registry::new(&cache, "seeded-db");
fs::create_dir_all(cache.join("checkpoints")).unwrap();
fs::write(
cache.join("checkpoints").join("seeded-db.json.tmp.stale"),
b"leftover from a crashed writer",
)
.unwrap();
registry.write_atomic(&sample_entry()).unwrap();
assert_eq!(registry.read(), Some(sample_entry()));
let mut replaced = sample_entry();
replaced.checkpoint_ref = "rz-ckpt-seeded-db-2".to_string();
registry.write_atomic(&replaced).unwrap();
assert_eq!(
registry.read(),
Some(replaced),
"a second write must still land cleanly with the stale tmp file still sitting next to it"
);
}
#[test]
fn write_atomic_overwrites_an_existing_entry() {
let cache = temp_cache_dir("overwrite");
let registry = Registry::new(&cache, "seeded-db");
registry.write_atomic(&sample_entry()).unwrap();
let mut replaced = sample_entry();
replaced.checkpoint_ref = "rz-ckpt-seeded-db-2".to_string();
registry.write_atomic(&replaced).unwrap();
assert_eq!(registry.read(), Some(replaced));
}
#[test]
fn registry_json_uses_the_pinned_field_names() {
let cache = temp_cache_dir("field-names");
let registry = Registry::new(&cache, "seeded-db");
registry.write_atomic(&sample_entry()).unwrap();
let raw = fs::read_to_string(cache.join("checkpoints").join("seeded-db.json")).unwrap();
for pinned in [
"\"name\"",
"\"ref\"",
"\"backend\"",
"\"createdIso\"",
"\"spec\"",
"\"env\"",
"\"command\"",
"\"exposedPorts\"",
"\"memoryLimitMb\"",
] {
assert!(raw.contains(pinned), "{pinned} missing from {raw}");
}
assert!(!raw.contains("checkpoint_ref"), "{raw}");
assert!(!raw.contains("created_iso"), "{raw}");
assert!(!raw.contains("exposed_ports"), "{raw}");
assert!(!raw.contains("memory_limit_mb"), "{raw}");
}
#[test]
fn list_registry_entries_returns_empty_for_a_missing_directory() {
let cache = temp_cache_dir("list-missing-dir");
assert_eq!(list_registry_entries(&cache).unwrap(), Vec::new());
}
#[test]
fn list_registry_entries_skips_corrupt_files_and_returns_valid_ones() {
let cache = temp_cache_dir("list-mixed");
Registry::new(&cache, "good-one")
.write_atomic(&sample_entry())
.unwrap();
fs::create_dir_all(cache.join("checkpoints")).unwrap();
fs::write(cache.join("checkpoints").join("corrupt.json"), b"not json").unwrap();
fs::write(cache.join("checkpoints").join("ignored.txt"), b"irrelevant").unwrap();
let entries = list_registry_entries(&cache).unwrap();
assert_eq!(entries.len(), 1, "{entries:?}");
assert_eq!(entries[0].name, "seeded-db");
}
}