use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::checkpoint::NamedRegistrySpec;
use crate::error::{Result, RightsizeError};
pub(crate) const FORMAT_VERSION: u32 = 1;
const MANIFEST_MEMBER: &str = "checkpoint.json";
const ARTIFACT_MEMBER: &str = "artifact";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct ArchiveManifest {
#[serde(rename = "rightsizeArchive")]
pub rightsize_archive: u32,
pub name: Option<String>,
#[serde(rename = "ref")]
pub checkpoint_ref: String,
pub backend: String,
#[serde(rename = "createdIso")]
pub created_iso: String,
pub spec: NamedRegistrySpec,
}
pub(crate) struct TempStagingDir {
path: PathBuf,
}
impl TempStagingDir {
#[cfg(test)]
pub(crate) fn create(label: &str) -> Result<Self> {
Self::create_in(&std::env::temp_dir(), label)
}
pub(crate) fn create_in(parent: &Path, label: &str) -> Result<Self> {
let path = parent.join(format!(
"rightsize-archive-{label}-{}",
crate::cache_dir::unique_tmp_suffix()
));
std::fs::create_dir_all(&path)?;
Ok(TempStagingDir { path })
}
pub(crate) fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TempStagingDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
pub(crate) fn write_manifest(path: &Path, manifest: &ArchiveManifest) -> Result<()> {
let json = serde_json::to_vec_pretty(manifest)
.expect("ArchiveManifest has no non-serializable fields");
std::fs::write(path, json)?;
Ok(())
}
pub(crate) fn read_manifest(
archive_path: &Path,
checkpoint_json_path: &Path,
) -> Result<ArchiveManifest> {
let raw =
std::fs::read(checkpoint_json_path).map_err(|_| RightsizeError::MalformedArchive {
path: archive_path.to_path_buf(),
reason: format!("missing the required '{MANIFEST_MEMBER}' member"),
})?;
serde_json::from_slice(&raw).map_err(|e| RightsizeError::MalformedArchive {
path: archive_path.to_path_buf(),
reason: format!("'{MANIFEST_MEMBER}' could not be parsed as JSON: {e}"),
})
}
pub(crate) fn manifest_path(staging_dir: &Path) -> PathBuf {
staging_dir.join(MANIFEST_MEMBER)
}
pub(crate) fn artifact_path(staging_dir: &Path) -> PathBuf {
staging_dir.join(ARTIFACT_MEMBER)
}
pub(crate) async fn tar_create(dest: &Path, staging_dir: &Path) -> Result<()> {
let (dest_dir, dest_name) = split_for_tar(dest)?;
let output = tokio::process::Command::new("tar")
.current_dir(dest_dir)
.arg("-cf")
.arg(dest_name)
.arg("-C")
.arg(tar_dir_arg(staging_dir))
.arg(MANIFEST_MEMBER)
.arg(ARTIFACT_MEMBER)
.stdin(std::process::Stdio::null())
.output()
.await
.map_err(|e| {
RightsizeError::Backend(format!("failed to spawn tar -cf {}: {e}", dest.display()))
})?;
if !output.status.success() {
return Err(RightsizeError::Backend(format!(
"tar -cf {} failed (exit {}): {}",
dest.display(),
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stderr).trim()
)));
}
Ok(())
}
pub(crate) async fn tar_extract(archive_path: &Path, dest_dir: &Path) -> Result<()> {
if !archive_path.is_file() {
return Err(RightsizeError::MalformedArchive {
path: archive_path.to_path_buf(),
reason: "no such archive file".to_string(),
});
}
let (archive_dir, archive_name) = split_for_tar(archive_path)?;
let output = tokio::process::Command::new("tar")
.current_dir(archive_dir)
.arg("-xf")
.arg(archive_name)
.arg("-C")
.arg(tar_dir_arg(dest_dir))
.stdin(std::process::Stdio::null())
.output()
.await
.map_err(|e| {
RightsizeError::Backend(format!(
"failed to spawn tar -xf {}: {e}",
archive_path.display()
))
})?;
if !output.status.success() {
return Err(RightsizeError::MalformedArchive {
path: archive_path.to_path_buf(),
reason: format!(
"tar could not extract it (exit {}): {}",
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stderr).trim()
),
});
}
Ok(())
}
fn tar_dir_arg(dir: &Path) -> std::ffi::OsString {
if cfg!(windows) {
dir.to_string_lossy().replace('\\', "/").into()
} else {
dir.as_os_str().to_os_string()
}
}
fn split_for_tar(path: &Path) -> Result<(std::path::PathBuf, std::ffi::OsString)> {
let name = path
.file_name()
.ok_or_else(|| RightsizeError::MalformedArchive {
path: path.to_path_buf(),
reason: "archive path has no file name".to_string(),
})?
.to_os_string();
let parent = match path.parent() {
Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
_ => std::path::PathBuf::from("."),
};
Ok((parent, name))
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn sample_manifest() -> ArchiveManifest {
ArchiveManifest {
rightsize_archive: FORMAT_VERSION,
name: Some("seeded-db".to_string()),
checkpoint_ref: "rz-ckpt-deadbeefcafe".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 temp_staging_dir_creates_and_removes_itself() {
let path = {
let staging = TempStagingDir::create("test").expect("create must succeed");
let path = staging.path().to_path_buf();
assert!(path.is_dir(), "the staging dir must exist while held");
path
};
assert!(!path.exists(), "the staging dir must be gone once dropped");
}
#[test]
fn two_staging_dirs_never_collide() {
let a = TempStagingDir::create("dup").unwrap();
let b = TempStagingDir::create("dup").unwrap();
assert_ne!(a.path(), b.path());
}
#[test]
fn write_then_read_manifest_round_trips_with_the_pinned_field_names() {
let staging = TempStagingDir::create("manifest-roundtrip").unwrap();
let path = manifest_path(staging.path());
write_manifest(&path, &sample_manifest()).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
for pinned in [
"\"rightsizeArchive\": 1",
"\"name\": \"seeded-db\"",
"\"ref\": \"rz-ckpt-deadbeefcafe\"",
"\"backend\": \"microsandbox\"",
"\"createdIso\"",
"\"spec\"",
] {
assert!(raw.contains(pinned), "{pinned} missing from {raw}");
}
assert!(!raw.contains("rightsize_archive"), "{raw}");
assert!(!raw.contains("checkpoint_ref"), "{raw}");
let parsed = read_manifest(std::path::Path::new("archive.tar"), &path).unwrap();
assert_eq!(parsed, sample_manifest());
}
#[test]
fn write_manifest_serializes_a_null_name_for_an_unnamed_checkpoint() {
let staging = TempStagingDir::create("manifest-unnamed").unwrap();
let mut manifest = sample_manifest();
manifest.name = None;
let path = manifest_path(staging.path());
write_manifest(&path, &manifest).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.contains("\"name\": null"), "{raw}");
let parsed = read_manifest(std::path::Path::new("archive.tar"), &path).unwrap();
assert_eq!(parsed.name, None);
}
#[test]
fn read_manifest_on_a_missing_member_is_a_typed_malformed_archive_error() {
let staging = TempStagingDir::create("manifest-missing").unwrap();
let err = read_manifest(
std::path::Path::new("archive.tar"),
&manifest_path(staging.path()), )
.expect_err("a missing checkpoint.json must be a typed error");
match err {
RightsizeError::MalformedArchive { path, reason } => {
assert_eq!(path, std::path::PathBuf::from("archive.tar"));
assert!(reason.contains("checkpoint.json"), "{reason}");
}
other => panic!("expected MalformedArchive, got {other:?}"),
}
}
#[test]
fn read_manifest_on_malformed_json_is_a_typed_malformed_archive_error() {
let staging = TempStagingDir::create("manifest-malformed").unwrap();
let path = manifest_path(staging.path());
std::fs::write(&path, b"not json").unwrap();
let err = read_manifest(std::path::Path::new("archive.tar"), &path)
.expect_err("malformed JSON must be a typed error");
assert!(
matches!(err, RightsizeError::MalformedArchive { .. }),
"{err}"
);
}
#[tokio::test]
async fn tar_create_then_tar_extract_round_trips_both_members_byte_for_byte() {
let staging = TempStagingDir::create("tar-roundtrip-src").unwrap();
std::fs::write(manifest_path(staging.path()), b"{\"a\":1}").unwrap();
std::fs::write(artifact_path(staging.path()), b"\x00\x01payload-bytes\xff").unwrap();
let archive_dir = TempStagingDir::create("tar-roundtrip-archive").unwrap();
let archive = archive_dir.path().join("cp.archive");
tar_create(&archive, staging.path()).await.unwrap();
assert!(archive.is_file());
let dest = TempStagingDir::create("tar-roundtrip-dest").unwrap();
tar_extract(&archive, dest.path()).await.unwrap();
assert_eq!(
std::fs::read(manifest_path(dest.path())).unwrap(),
b"{\"a\":1}"
);
assert_eq!(
std::fs::read(artifact_path(dest.path())).unwrap(),
b"\x00\x01payload-bytes\xff"
);
}
#[tokio::test]
async fn tar_create_overwrites_a_pre_existing_destination_file() {
let staging = TempStagingDir::create("tar-overwrite-src").unwrap();
std::fs::write(manifest_path(staging.path()), b"new-manifest").unwrap();
std::fs::write(artifact_path(staging.path()), b"new-artifact").unwrap();
let archive_dir = TempStagingDir::create("tar-overwrite-archive").unwrap();
let archive = archive_dir.path().join("cp.archive");
std::fs::write(&archive, b"stale bytes from a previous export").unwrap();
tar_create(&archive, staging.path()).await.unwrap();
let dest = TempStagingDir::create("tar-overwrite-dest").unwrap();
tar_extract(&archive, dest.path()).await.unwrap();
assert_eq!(
std::fs::read(manifest_path(dest.path())).unwrap(),
b"new-manifest"
);
}
#[tokio::test]
async fn tar_extract_on_a_missing_file_is_a_typed_malformed_archive_error() {
let dest = TempStagingDir::create("tar-missing-dest").unwrap();
let err = tar_extract(
std::path::Path::new("/definitely/not/a/real/archive"),
dest.path(),
)
.await
.expect_err("a missing archive file must be a typed error");
match err {
RightsizeError::MalformedArchive { reason, .. } => {
assert!(reason.contains("no such archive"), "{reason}");
}
other => panic!("expected MalformedArchive, got {other:?}"),
}
}
#[tokio::test]
async fn tar_extract_on_a_non_tar_file_is_a_typed_malformed_archive_error() {
let src_dir = TempStagingDir::create("tar-bad-src").unwrap();
let not_a_tar = src_dir.path().join("not-a-tar");
std::fs::write(
¬_a_tar,
b"just some plain bytes, not a tar archive at all",
)
.unwrap();
let dest = TempStagingDir::create("tar-bad-dest").unwrap();
let err = tar_extract(¬_a_tar, dest.path())
.await
.expect_err("a non-tar file must be a typed error");
assert!(
matches!(err, RightsizeError::MalformedArchive { .. }),
"{err}"
);
}
}