use std::collections::BTreeMap;
use std::fs::{self, File};
use std::io::Write;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::cache_dir::unique_tmp_suffix;
use crate::error::{Result, RightsizeError};
use crate::model::FileMount;
pub(crate) fn env_parse(value: Option<&str>) -> bool {
matches!(value, Some("true") | Some("1"))
}
pub(crate) fn env_enabled() -> bool {
env_parse(std::env::var("RIGHTSIZE_REUSE").ok().as_deref())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Identity {
pub hash_hex: String,
pub name: String,
}
pub(crate) fn compute_identity(
image: &str,
env: &[(String, String)],
command: &Option<Vec<String>>,
exposed_ports: &[u16],
memory_limit_mb: Option<u64>,
mounts: &[FileMount],
) -> Result<Identity> {
let mut env_sorted = env.to_vec();
env_sorted.sort_by(|a, b| a.0.cmp(&b.0));
let mut ports_sorted = exposed_ports.to_vec();
ports_sorted.sort_unstable();
let mut copies = Vec::with_capacity(mounts.len());
for mount in mounts {
let content = fs::read(&mount.host_path)?;
copies.push((mount.guest_path.clone(), hex_sha256(&content)));
}
copies.sort_by(|a, b| a.0.cmp(&b.0));
let canonical = canonical_json(
image,
&env_sorted,
command.as_deref().unwrap_or(&[]),
&ports_sorted,
memory_limit_mb,
&copies,
);
let hash_hex = hex_sha256(canonical.as_bytes());
let name = format!("rz-reuse-{}", &hash_hex[..12]);
Ok(Identity { hash_hex, name })
}
fn canonical_json(
image: &str,
env_sorted: &[(String, String)],
command: &[String],
ports_sorted: &[u16],
memory_limit_mb: Option<u64>,
copies_sorted: &[(String, String)],
) -> String {
let mut out = String::new();
out.push_str("{\"image\":");
out.push_str(&json_string(image));
out.push_str(",\"env\":{");
for (i, (k, v)) in env_sorted.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&json_string(k));
out.push(':');
out.push_str(&json_string(v));
}
out.push('}');
out.push_str(",\"command\":[");
for (i, c) in command.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&json_string(c));
}
out.push(']');
out.push_str(",\"exposedPorts\":[");
for (i, p) in ports_sorted.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&p.to_string());
}
out.push(']');
out.push_str(",\"memoryLimitMb\":");
match memory_limit_mb {
Some(mb) => out.push_str(&mb.to_string()),
None => out.push_str("null"),
}
out.push_str(",\"copies\":[");
for (i, (guest_path, sha)) in copies_sorted.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str("{\"guestPath\":");
out.push_str(&json_string(guest_path));
out.push_str(",\"sha256\":");
out.push_str(&json_string(sha));
out.push('}');
}
out.push_str("]}");
out
}
fn json_string(s: &str) -> String {
serde_json::to_string(s).expect("a &str always serializes")
}
fn hex_sha256(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let digest = Sha256::digest(bytes);
digest.iter().fold(String::new(), |mut hex, b| {
let _ = write!(hex, "{b:02x}");
hex
})
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct RegistryEntry {
pub name: String,
pub image: String,
pub ports: BTreeMap<String, u16>,
#[serde(rename = "createdIso")]
pub created_iso: String,
pub backend: String,
}
pub(crate) struct Registry {
path: PathBuf,
}
impl Registry {
pub(crate) fn new(cache_dir: &Path, hash_hex: &str) -> Self {
Registry {
path: cache_dir.join("reuse").join(format!("{hash_hex}.json")),
}
}
pub(crate) fn exists(&self) -> bool {
self.path.exists()
}
pub(crate) fn read(&self) -> Option<RegistryEntry> {
let raw = fs::read(&self.path).ok()?;
serde_json::from_slice(&raw).ok()
}
pub(crate) fn write_atomic(&self, entry: &RegistryEntry) -> std::io::Result<()> {
let dir = self
.path
.parent()
.expect("registry path always has a parent (cache_dir/reuse/)");
fs::create_dir_all(dir)?;
let json =
serde_json::to_vec_pretty(entry).expect("RegistryEntry 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 is_name_conflict(e: &RightsizeError) -> bool {
let mut current: Option<&RightsizeError> = Some(e);
while let Some(err) = current {
if matches!(err, RightsizeError::NameConflict { .. }) {
return true;
}
if err.to_string().to_lowercase().contains("already exists") {
return true;
}
current = match err {
RightsizeError::NameConflict {
source: Some(s), ..
} => Some(s.as_ref()),
_ => None,
};
}
false
}
pub(crate) fn now_iso8601() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let days = secs.div_euclid(86400);
let rem = secs.rem_euclid(86400);
let (y, m, d) = civil_from_days(days);
let hh = rem / 3600;
let mi = (rem % 3600) / 60;
let ss = rem % 60;
format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mi:02}:{ss:02}Z")
}
fn civil_from_days(days: i64) -> (i64, u32, u32) {
let z = days + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = z - era * 146097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn env_parse_accepts_only_the_exact_strings_true_or_1() {
assert!(env_parse(Some("true")));
assert!(env_parse(Some("1")));
assert!(!env_parse(Some("TRUE")));
assert!(!env_parse(Some("yes")));
assert!(!env_parse(Some("on")));
assert!(!env_parse(Some("")));
assert!(!env_parse(None));
}
const PINNED_VECTOR_HASH: &str =
"799aad5a3338ce3d36999c7ff2733d4673c0592d417563f334544693ec1907a5";
fn pinned_vector_identity() -> Identity {
compute_identity(
"redis:7-alpine",
&[
("A".to_string(), "1".to_string()),
("B".to_string(), "2".to_string()),
],
&None,
&[6379],
None,
&[],
)
.unwrap()
}
#[test]
fn pinned_cross_language_vector_hashes_to_the_pinned_value() {
let identity = pinned_vector_identity();
assert_eq!(identity.hash_hex, PINNED_VECTOR_HASH, "{identity:?}");
assert_eq!(
identity.name,
format!("rz-reuse-{}", &PINNED_VECTOR_HASH[..12])
);
}
#[test]
fn canonical_json_matches_the_pinned_shape_exactly() {
let json = canonical_json(
"redis:7-alpine",
&[
("A".to_string(), "1".to_string()),
("B".to_string(), "2".to_string()),
],
&[],
&[6379],
None,
&[],
);
assert_eq!(
json,
r#"{"image":"redis:7-alpine","env":{"A":"1","B":"2"},"command":[],"exposedPorts":[6379],"memoryLimitMb":null,"copies":[]}"#
);
}
#[test]
fn env_key_order_at_the_call_site_does_not_affect_the_hash() {
let a = compute_identity(
"redis:7-alpine",
&[
("A".to_string(), "1".to_string()),
("B".to_string(), "2".to_string()),
],
&None,
&[6379],
None,
&[],
)
.unwrap();
let b = compute_identity(
"redis:7-alpine",
&[
("B".to_string(), "2".to_string()),
("A".to_string(), "1".to_string()),
],
&None,
&[6379],
None,
&[],
)
.unwrap();
assert_eq!(
a.hash_hex, b.hash_hex,
"env insertion order must not matter"
);
}
#[test]
fn a_different_image_changes_the_hash() {
let a = pinned_vector_identity();
let b = compute_identity(
"redis:7",
&[
("A".to_string(), "1".to_string()),
("B".to_string(), "2".to_string()),
],
&None,
&[6379],
None,
&[],
)
.unwrap();
assert_ne!(a.hash_hex, b.hash_hex);
}
#[test]
fn copy_content_change_changes_the_hash() {
let dir = std::env::temp_dir().join(format!(
"rz-reuse-identity-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("f.txt");
std::fs::write(&file, b"one").unwrap();
let mount = FileMount::new(&file, "/guest/f.txt");
let a = compute_identity(
"redis:7-alpine",
&[],
&None,
&[],
None,
std::slice::from_ref(&mount),
)
.unwrap();
std::fs::write(&file, b"two").unwrap();
let b = compute_identity(
"redis:7-alpine",
&[],
&None,
&[],
None,
std::slice::from_ref(&mount),
)
.unwrap();
assert_ne!(a.hash_hex, b.hash_hex);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn compute_identity_propagates_an_unreadable_mount_as_a_real_error() {
let mount = FileMount::new("/definitely/does/not/exist/rightsize-reuse", "/guest/f.txt");
let err = compute_identity("redis:7-alpine", &[], &None, &[], None, &[mount]).unwrap_err();
assert!(matches!(err, RightsizeError::Io(_)), "{err}");
}
fn temp_cache_dir(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"rz-reuse-registry-{label}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn sample_entry() -> RegistryEntry {
RegistryEntry {
name: "rz-reuse-799aad5a3338".to_string(),
image: "redis:7-alpine".to_string(),
ports: BTreeMap::from([("6379".to_string(), 32768)]),
created_iso: "2025-01-01T00:00:00Z".to_string(),
backend: "docker".to_string(),
}
}
#[test]
fn write_atomic_survives_a_stale_leftover_tmp_file() {
let cache = temp_cache_dir("stale-tmp");
let registry = Registry::new(&cache, "deadbeef");
fs::create_dir_all(cache.join("reuse")).unwrap();
fs::write(
cache.join("reuse").join("deadbeef.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.name = "rz-reuse-replacement0".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_then_read_round_trips() {
let cache = temp_cache_dir("round-trip");
let registry = Registry::new(&cache, "deadbeef");
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, "deadbeef");
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, "deadbeef");
std::fs::create_dir_all(cache.join("reuse")).unwrap();
std::fs::write(cache.join("reuse").join("deadbeef.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, "deadbeef");
registry.write_atomic(&sample_entry()).unwrap();
registry.delete();
assert!(!registry.exists());
registry.delete(); }
#[test]
fn registry_json_uses_the_camel_case_created_iso_field_name() {
let cache = temp_cache_dir("camel-case");
let registry = Registry::new(&cache, "deadbeef");
registry.write_atomic(&sample_entry()).unwrap();
let raw = std::fs::read_to_string(cache.join("reuse").join("deadbeef.json")).unwrap();
assert!(raw.contains("\"createdIso\""), "{raw}");
assert!(!raw.contains("created_iso"), "{raw}");
}
#[test]
fn write_atomic_overwrites_an_existing_entry() {
let cache = temp_cache_dir("overwrite");
let registry = Registry::new(&cache, "deadbeef");
registry.write_atomic(&sample_entry()).unwrap();
let mut updated = sample_entry();
updated.ports.insert("9999".to_string(), 40000);
registry.write_atomic(&updated).unwrap();
assert_eq!(registry.read(), Some(updated));
}
#[test]
fn is_name_conflict_matches_the_typed_variant() {
let e = RightsizeError::NameConflict {
message: "boom".to_string(),
source: None,
};
assert!(is_name_conflict(&e));
}
#[test]
fn is_name_conflict_matches_the_typed_variant_nested_under_itself() {
let e = RightsizeError::NameConflict {
message: "outer".to_string(),
source: Some(Box::new(RightsizeError::NameConflict {
message: "sandbox 'rz-reuse-abc' already exists".to_string(),
source: None,
})),
};
assert!(is_name_conflict(&e));
}
#[test]
fn is_name_conflict_matches_an_already_exists_message_fallback() {
let e = RightsizeError::Backend("sandbox 'rz-reuse-abc' already exists".to_string());
assert!(is_name_conflict(&e));
}
#[test]
fn is_name_conflict_negative_case_does_not_match() {
let e = RightsizeError::Backend("connection refused".to_string());
assert!(!is_name_conflict(&e));
}
#[test]
fn now_iso8601_has_the_expected_shape() {
let iso = now_iso8601();
assert_eq!(iso.len(), 20, "{iso}");
assert!(iso.ends_with('Z'), "{iso}");
assert_eq!(iso.as_bytes()[4], b'-');
assert_eq!(iso.as_bytes()[7], b'-');
assert_eq!(iso.as_bytes()[10], b'T');
assert_eq!(iso.as_bytes()[13], b':');
assert_eq!(iso.as_bytes()[16], b':');
}
}