ommx 3.0.0-beta.1

Open Mathematical prograMming eXchange (OMMX)
Documentation
//! Native `LocalArtifact::save` — SQLite + CAS → on-disk OCI archive.
//!
//! Writes a `.ommx` file directly using the [`tar`] crate. The
//! resulting archive is the standard "tar of OCI Image Layout": an
//! `oci-layout` marker, a one-entry `index.json`, and
//! `blobs/sha256/<digest>` entries for the manifest + config + every
//! layer. The manifest bytes the SQLite Local Registry holds are
//! written verbatim, so the manifest digest is byte-identical across
//! the source registry and the produced archive — importing the
//! archive back via
//! [`super::local_registry::LocalRegistry::import_oci_archive`] round-trips to the
//! same digest.
//!
//! Memory shape: each blob is read into a `Vec<u8>` and streamed
//! through the tar writer; the writer itself is `BufWriter<File>`. A
//! 200 MB layer therefore peaks at ~200 MB resident memory during the
//! save. A streaming variant that copies blobs out of
//! the Local Registry via `std::io::copy` (the registry already
//! keeps each blob in its own file) would replace the
//! `Vec<u8>` allocation with a fixed 64 KB copy buffer; left as a
//! future refinement.

use super::{local_registry::OCI_IMAGE_REF_NAME_ANNOTATION, LocalArtifact};
use anyhow::Context;
use oci_spec::image::{
    Descriptor, DescriptorBuilder, Digest, ImageIndexBuilder, ImageManifest, MediaType,
    OciLayoutBuilder,
};
use std::{
    collections::HashMap,
    fs::OpenOptions,
    io::{BufWriter, Cursor, Write},
    path::Path,
};
use tar::{EntryType, Header};

/// OCI Image Layout version this archive declares. The OCI spec
/// pins this string at `1.0.0`; importers reject anything else.
const OCI_LAYOUT_VERSION: &str = "1.0.0";

impl LocalArtifact<'_> {
    /// Pack this artifact into a `.ommx` OCI archive at `output`.
    ///
    /// Identity-preserving: the manifest bytes the SQLite Local
    /// Registry holds are written verbatim, so the manifest digest
    /// is byte-identical across the source SQLite registry and the
    /// produced archive. Importing the archive back into a fresh
    /// registry round-trips to the same digest.
    pub fn save(&self, output: &Path) -> crate::Result<()> {
        let manifest_digest = self.manifest_digest().clone();
        let manifest_bytes = self.read_blob_by_digest(&manifest_digest)?;
        let manifest: ImageManifest = serde_json::from_slice(&manifest_bytes)
            .context("Failed to parse manifest from SQLite Local Registry")?;

        // `create_new(true)` closes the TOCTOU window between an `exists()`
        // probe and the open: a concurrent process that races us cannot
        // have its file silently truncated. The kernel's atomic
        // O_CREAT|O_EXCL is the only safe surface here.
        let file = OpenOptions::new()
            .write(true)
            .create_new(true)
            .open(output)
            .with_context(|| {
                format!(
                    "Failed to create archive at {} (file already exists or path is invalid)",
                    output.display()
                )
            })?;
        let mut tar = tar::Builder::new(BufWriter::new(file));
        // Deterministic mode pins uid/gid/mtime to known values so a
        // bit-for-bit `save` against the same registry produces the
        // same archive bytes — useful for caching, content-addressed
        // build outputs, and CI reproducibility checks.
        tar.mode(tar::HeaderMode::Deterministic);

        // 1. `oci-layout` marker.
        let oci_layout = OciLayoutBuilder::default()
            .image_layout_version(OCI_LAYOUT_VERSION.to_string())
            .build()
            .context("Failed to build oci-layout JSON")?;
        let layout_bytes =
            serde_json::to_vec(&oci_layout).context("Failed to serialise oci-layout JSON")?;
        append_tar_file(&mut tar, "oci-layout", &layout_bytes)?;

        // 2. Blobs — manifest first (so a `tar tvf` listing reads
        //    naturally from "what is the artifact" to "what does it
        //    contain"), then config, then layers. Order is not
        //    semantically significant — OCI Image Layout readers index
        //    by `blobs/<algorithm>/<encoded>` regardless of tar order.
        append_blob_entry(&mut tar, &manifest_digest, &manifest_bytes)?;
        let config = self
            .registry()
            .stored_descriptor(manifest.config().clone())?;
        let config_bytes = self.get_blob(&config)?;
        verify_blob(&config, config_bytes.len(), "config")?;
        append_blob_entry(&mut tar, config.digest(), &config_bytes)?;
        for layer in manifest.layers() {
            let layer = self.registry().stored_descriptor(layer.clone())?;
            let layer_bytes = self.get_blob(&layer)?;
            verify_blob(&layer, layer_bytes.len(), "layer")?;
            append_blob_entry(&mut tar, layer.digest(), &layer_bytes)?;
        }

        // 3. `index.json` — single-entry ImageIndex pointing at the
        //    manifest, annotated with the image ref so the archive can
        //    be imported back under the same name without a side
        //    channel.
        let manifest_descriptor = DescriptorBuilder::default()
            .media_type(MediaType::ImageManifest)
            .digest(manifest_digest)
            .size(manifest_bytes.len() as u64)
            .annotations({
                let mut map = HashMap::new();
                map.insert(
                    OCI_IMAGE_REF_NAME_ANNOTATION.to_string(),
                    self.image_name().to_string(),
                );
                map
            })
            .build()
            .context("Failed to build manifest descriptor for archive index.json")?;
        let image_index = ImageIndexBuilder::default()
            .schema_version(2u32)
            .media_type(MediaType::ImageIndex)
            .manifests(vec![manifest_descriptor])
            .build()
            .context("Failed to build OCI Image Index for archive index.json")?;
        let index_bytes = serde_json::to_vec(&image_index)
            .context("Failed to serialise OCI Image Index for archive index.json")?;
        append_tar_file(&mut tar, "index.json", &index_bytes)?;

        let mut writer = tar.into_inner().context("Failed to finalise tar archive")?;
        writer
            .flush()
            .with_context(|| format!("Failed to flush archive writer to {}", output.display()))?;
        Ok(())
    }
}

/// Append a regular file at `path` with the given bytes. Path is
/// relative to the archive root (no leading `/`).
fn append_tar_file<W: Write>(
    tar: &mut tar::Builder<W>,
    path: &str,
    bytes: &[u8],
) -> crate::Result<()> {
    let mut header = Header::new_ustar();
    header.set_size(bytes.len() as u64);
    header.set_mode(0o644);
    header.set_mtime(0);
    header.set_entry_type(EntryType::Regular);
    header
        .set_path(path)
        .with_context(|| format!("Tar entry path {path} is not representable in USTAR"))?;
    header.set_cksum();
    tar.append(&header, Cursor::new(bytes))
        .with_context(|| format!("Failed to append tar entry {path}"))
}

/// Append a CAS blob under `blobs/<algorithm>/<encoded>`. `digest` is
/// the full `algorithm:encoded` form the manifest stores.
fn append_blob_entry<W: Write>(
    tar: &mut tar::Builder<W>,
    digest: &Digest,
    bytes: &[u8],
) -> crate::Result<()> {
    let path = format!("blobs/{}/{}", digest.algorithm().as_ref(), digest.digest());
    append_tar_file(tar, &path, bytes)
}

/// Cross-check a CAS blob's recorded size against what the manifest
/// descriptor claims so corrupted local registry content surfaces here instead of
/// producing a silently mis-tagged archive.
fn verify_blob(descriptor: &Descriptor, actual_size: usize, kind: &str) -> crate::Result<()> {
    crate::ensure!(
        actual_size as u64 == descriptor.size(),
        "{kind} size mismatch on save: manifest claims {}, blob is {} bytes",
        descriptor.size(),
        actual_size,
    );
    Ok(())
}