use std::collections::BTreeMap;
use std::io::{Cursor, Read};
use std::path::Path;
use serde::Deserialize;
use tracing::warn;
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct PackMetaSlim {
#[serde(default)]
pub version: Option<String>,
}
pub(crate) fn probe_inlined_packs(
artifact: &Path,
references: &[&str],
) -> BTreeMap<String, PackMetaSlim> {
let mut out = BTreeMap::new();
if references.is_empty() {
return out;
}
let files = match list_gtpack_files(artifact) {
Ok(files) => files,
Err(err) => {
warn!(
bundle = %artifact.display(),
error = %err,
"could not list .gtpack files in bundle; pack versions will be unavailable",
);
return out;
}
};
for reference in references {
let slug = slug_for_reference(reference);
let Some(inner_path) = match_pack_file(&files, &slug) else {
continue;
};
match extract_pack_metadata(artifact, inner_path) {
Ok(Some(meta)) => {
out.insert((*reference).to_string(), meta);
}
Ok(None) => {
warn!(
bundle = %artifact.display(),
pack = %inner_path,
"pack has no manifest.cbor entry",
);
}
Err(err) => {
warn!(
bundle = %artifact.display(),
pack = %inner_path,
error = %err,
"could not read pack manifest; version unavailable",
);
}
}
}
out
}
pub(crate) fn extract_pack_metadata(
artifact: &Path,
pack_inner_path: &str,
) -> anyhow::Result<Option<PackMetaSlim>> {
let zip_bytes = crate::bundle_fs::read_bundle_file(artifact, pack_inner_path)?;
let reader = Cursor::new(zip_bytes);
let mut archive =
zip::ZipArchive::new(reader).map_err(|e| anyhow::anyhow!("open .gtpack zip: {e}"))?;
let mut cbor_bytes = Vec::new();
{
let mut entry = match archive.by_name("manifest.cbor") {
Ok(e) => e,
Err(zip::result::ZipError::FileNotFound) => return Ok(None),
Err(e) => return Err(anyhow::anyhow!("open manifest.cbor: {e}")),
};
entry
.read_to_end(&mut cbor_bytes)
.map_err(|e| anyhow::anyhow!("read manifest.cbor: {e}"))?;
}
let meta: PackMetaSlim = ciborium::de::from_reader(cbor_bytes.as_slice())
.map_err(|e| anyhow::anyhow!("decode manifest.cbor: {e}"))?;
Ok(Some(meta))
}
fn list_gtpack_files(artifact: &Path) -> anyhow::Result<Vec<String>> {
Ok(crate::bundle_fs::list_bundle(artifact)?
.into_iter()
.map(|entry| entry.path)
.filter(|path| path.ends_with(".gtpack"))
.collect())
}
fn slug_for_reference(reference: &str) -> String {
let without_scheme = match reference.find("://") {
Some(idx) => &reference[idx + 3..],
None => reference,
};
let last_segment = without_scheme
.rsplit(['/', '\\'])
.next()
.unwrap_or(without_scheme);
let without_tag = match last_segment.rsplit_once(':') {
Some((head, _tag)) => head,
None => last_segment,
};
without_tag
.strip_suffix(".gtpack")
.unwrap_or(without_tag)
.to_string()
}
fn match_pack_file<'a>(files: &'a [String], slug: &str) -> Option<&'a str> {
if slug.is_empty() {
return None;
}
let mut best: Option<&str> = None;
for f in files {
let basename = f.rsplit('/').next().unwrap_or(f.as_str());
let stem = basename.strip_suffix(".gtpack").unwrap_or(basename);
if stem == slug {
if best.is_some() {
return None;
}
best = Some(f.as_str());
}
}
best
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slug_strips_oci_scheme_and_tag() {
assert_eq!(
slug_for_reference(
"oci://ghcr.io/greenticai/packs/messaging/messaging-webchat-gui:stable"
),
"messaging-webchat-gui"
);
}
#[test]
fn slug_strips_https_and_gtpack_extension() {
assert_eq!(
slug_for_reference(
"https://github.com/greenticai/greentic-demo/releases/latest/download/hr-onboarding.gtpack"
),
"hr-onboarding"
);
}
#[test]
fn slug_handles_bare_name() {
assert_eq!(slug_for_reference("foo"), "foo");
assert_eq!(slug_for_reference("foo:1.2.3"), "foo");
}
#[test]
fn slug_handles_path_reference() {
assert_eq!(slug_for_reference("./local/bar.gtpack"), "bar");
assert_eq!(slug_for_reference("packs/baz.gtpack"), "baz");
}
#[test]
fn match_pack_file_finds_unique_match() {
let files = vec![
"packs/hr-onboarding.gtpack".to_string(),
"providers/messaging/messaging-webchat-gui.gtpack".to_string(),
"providers/state/state-memory.gtpack".to_string(),
];
assert_eq!(
match_pack_file(&files, "messaging-webchat-gui"),
Some("providers/messaging/messaging-webchat-gui.gtpack")
);
assert_eq!(
match_pack_file(&files, "hr-onboarding"),
Some("packs/hr-onboarding.gtpack")
);
}
#[test]
fn match_pack_file_returns_none_for_miss() {
let files = vec!["packs/foo.gtpack".to_string()];
assert_eq!(match_pack_file(&files, "missing"), None);
}
#[test]
fn match_pack_file_returns_none_for_ambiguous() {
let files = vec!["a/foo.gtpack".to_string(), "b/foo.gtpack".to_string()];
assert_eq!(match_pack_file(&files, "foo"), None);
}
#[test]
fn match_pack_file_returns_none_for_empty_slug() {
let files = vec!["packs/foo.gtpack".to_string()];
assert_eq!(match_pack_file(&files, ""), None);
}
#[test]
fn probe_inlined_packs_with_no_references_returns_empty() {
let out = probe_inlined_packs(Path::new("/nonexistent/bundle.gtbundle"), &[]);
assert!(out.is_empty());
}
#[test]
fn probe_inlined_packs_returns_empty_when_bundle_cannot_be_listed() {
let dir = tempfile::tempdir().expect("tempdir");
let bogus = dir.path().join("not-a-bundle.gtbundle");
std::fs::write(&bogus, b"not a squashfs image").expect("write");
let out = probe_inlined_packs(&bogus, &["foo"]);
assert!(out.is_empty());
}
}