greentic-x 0.4.13

Greentic-X CLI for catalog-driven composition, scaffolding, validation, and simulation.
Documentation
use std::fs;
use std::io::{Cursor, Read};
use std::path::{Path, PathBuf};

use flate2::read::GzDecoder;
use sha2::{Digest, Sha256};
use tar::Archive;

use super::catalog::RemoteCatalogFetcher;

pub(crate) fn materialize_bundle_member(
    cwd: &Path,
    bundle_ref: &str,
    member_path: &str,
    fetcher: &dyn RemoteCatalogFetcher,
) -> Result<PathBuf, String> {
    let bundle_root = materialize_bundle(cwd, bundle_ref, fetcher)?;
    resolve_bundle_member_path(&bundle_root, member_path)
}

fn materialize_bundle(
    cwd: &Path,
    bundle_ref: &str,
    fetcher: &dyn RemoteCatalogFetcher,
) -> Result<PathBuf, String> {
    if let Some(local_path) = resolve_local_path(cwd, bundle_ref) {
        let bytes = fs::read(&local_path).map_err(|err| {
            format!(
                "failed to read local bundle {}: {err}",
                local_path.display()
            )
        })?;
        let digest = digest_for_bytes(&bytes);
        let bundle_dir = bundle_cache_dir(cwd, &digest);
        unpack_bundle_bytes(&bundle_dir, &bytes, None)?;
        return Ok(bundle_dir);
    }

    let fetched = fetcher.fetch_pack_artifact(cwd, bundle_ref)?;
    let bundle_dir = bundle_cache_dir(cwd, &fetched.resolved_digest);
    if bundle_dir.join(".bundle-unpacked").exists() {
        return Ok(bundle_dir);
    }
    let bytes = fs::read(&fetched.path).map_err(|err| {
        format!(
            "failed to read fetched bundle artifact {}: {err}",
            fetched.path.display()
        )
    })?;
    unpack_bundle_bytes(&bundle_dir, &bytes, Some(&fetched.media_type))?;
    Ok(bundle_dir)
}

fn unpack_bundle_bytes(
    target_dir: &Path,
    bytes: &[u8],
    media_type: Option<&str>,
) -> Result<(), String> {
    if target_dir.join(".bundle-unpacked").exists() {
        return Ok(());
    }
    if target_dir.exists() {
        fs::remove_dir_all(target_dir).map_err(|err| {
            format!(
                "failed to reset bundle cache {}: {err}",
                target_dir.display()
            )
        })?;
    }
    fs::create_dir_all(target_dir).map_err(|err| {
        format!(
            "failed to create bundle cache {}: {err}",
            target_dir.display()
        )
    })?;

    let is_gzip = media_type.is_some_and(|value| value.contains("tar+gzip"))
        || bytes.starts_with(&[0x1f, 0x8b]);

    if is_gzip {
        let decoder = GzDecoder::new(Cursor::new(bytes));
        unpack_tar_archive(target_dir, decoder)?;
    } else {
        unpack_tar_archive(target_dir, Cursor::new(bytes))?;
    }

    fs::write(target_dir.join(".bundle-unpacked"), b"ok").map_err(|err| {
        format!(
            "failed to write bundle cache marker {}: {err}",
            target_dir.display()
        )
    })?;
    Ok(())
}

fn unpack_tar_archive(target_dir: &Path, reader: impl Read) -> Result<(), String> {
    let mut archive = Archive::new(reader);
    let entries = archive
        .entries()
        .map_err(|err| format!("failed to inspect bundle archive: {err}"))?;
    for entry in entries {
        let mut entry =
            entry.map_err(|err| format!("failed to read bundle archive entry: {err}"))?;
        entry.unpack_in(target_dir).map_err(|err| {
            format!(
                "failed to unpack bundle archive into {}: {err}",
                target_dir.display()
            )
        })?;
    }
    Ok(())
}

fn resolve_bundle_member_path(bundle_root: &Path, member_path: &str) -> Result<PathBuf, String> {
    let direct = bundle_root.join(member_path);
    if direct.exists() {
        return Ok(direct);
    }

    let mut child_dirs = fs::read_dir(bundle_root)
        .map_err(|err| {
            format!(
                "failed to read extracted bundle {}: {err}",
                bundle_root.display()
            )
        })?
        .filter_map(Result::ok)
        .map(|entry| entry.path())
        .filter(|path| path.is_dir())
        .collect::<Vec<_>>();
    child_dirs.sort();

    if child_dirs.len() == 1 {
        let nested = child_dirs[0].join(member_path);
        if nested.exists() {
            return Ok(nested);
        }
    }

    Err(format!(
        "bundle member {member_path} was not found in extracted bundle {}",
        bundle_root.display()
    ))
}

fn bundle_cache_dir(cwd: &Path, digest: &str) -> PathBuf {
    cwd.join(".gx")
        .join("cache")
        .join("bundles")
        .join(trim_digest_prefix(digest))
}

fn trim_digest_prefix(digest: &str) -> &str {
    digest
        .strip_prefix("sha256:")
        .unwrap_or_else(|| digest.trim_start_matches('@'))
}

fn digest_for_bytes(bytes: &[u8]) -> String {
    let mut hasher = Sha256::new();
    hasher.update(bytes);
    let digest = hasher.finalize();
    let hex = digest
        .iter()
        .map(|byte| format!("{byte:02x}"))
        .collect::<String>();
    format!("sha256:{hex}")
}

fn resolve_local_path(cwd: &Path, reference: &str) -> Option<PathBuf> {
    let path = Path::new(reference);
    if path.is_absolute() && path.exists() {
        return Some(path.to_path_buf());
    }
    let candidate = cwd.join(reference);
    candidate.exists().then_some(candidate)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::wizard::catalog::{FetchResult, RemoteCatalogFetcher, ResolvedPackArtifact};
    use flate2::Compression;
    use flate2::write::GzEncoder;
    use tempfile::TempDir;

    struct StubFetcher {
        artifact: Vec<u8>,
    }

    impl RemoteCatalogFetcher for StubFetcher {
        fn fetch_json(&self, _cache_root: &Path, _reference: &str) -> Result<FetchResult, String> {
            Err("unused".to_owned())
        }

        fn resolve_pack_ref(&self, _cache_root: &Path, _reference: &str) -> Result<String, String> {
            Err("unused".to_owned())
        }

        fn fetch_pack_artifact(
            &self,
            cache_root: &Path,
            _reference: &str,
        ) -> Result<ResolvedPackArtifact, String> {
            let path = cache_root.join(".gx/test-bundle.tar.gz");
            if let Some(parent) = path.parent() {
                fs::create_dir_all(parent).map_err(|err| err.to_string())?;
            }
            fs::write(&path, &self.artifact).map_err(|err| err.to_string())?;
            Ok(ResolvedPackArtifact {
                path,
                resolved_digest: "sha256:testbundle".to_owned(),
                media_type: "application/vnd.oci.image.layer.v1.tar+gzip".to_owned(),
            })
        }
    }

    #[test]
    fn materializes_bundle_member_from_remote_archive() -> Result<(), Box<dyn std::error::Error>> {
        let temp = TempDir::new()?;
        let bundle_bytes = gzipped_bundle(&[
            (
                "assistant_templates/network.json",
                br#"{"ok":true}"#.as_slice(),
            ),
            (
                "catalog.json",
                br#"{"schema":"gx.catalog.index.v1"}"#.as_slice(),
            ),
        ])?;
        let path = materialize_bundle_member(
            temp.path(),
            "store://ghcr.io/demo/zain-x-bundle:latest",
            "assistant_templates/network.json",
            &StubFetcher {
                artifact: bundle_bytes,
            },
        )?;
        assert_eq!(fs::read_to_string(path)?, r#"{"ok":true}"#);
        Ok(())
    }

    fn gzipped_bundle(files: &[(&str, &[u8])]) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
        let encoder = GzEncoder::new(Vec::new(), Compression::default());
        let mut builder = tar::Builder::new(encoder);
        for (path, bytes) in files {
            let mut header = tar::Header::new_gnu();
            header.set_size(bytes.len() as u64);
            header.set_mode(0o644);
            header.set_cksum();
            builder.append_data(&mut header, path, *bytes)?;
        }
        let encoder = builder.into_inner()?;
        Ok(encoder.finish()?)
    }
}