Skip to main content

greentic_bundle/catalog/
resolve.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result, bail};
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6
7use super::cache;
8use super::client::{CatalogArtifactClient, DistributorCatalogClient};
9use super::registry::{CatalogEntry, load_catalog_entries, parse_catalog_bytes};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct CatalogResolveOptions {
13    pub offline: bool,
14    pub write_cache: bool,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct CatalogLockEntry {
19    pub requested_ref: String,
20    pub resolved_ref: String,
21    pub digest: String,
22    pub source: String,
23    pub item_count: usize,
24    pub item_ids: Vec<String>,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub cache_path: Option<String>,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct CatalogResolution {
31    pub entries: Vec<CatalogLockEntry>,
32    pub cache_writes: Vec<String>,
33    pub discovered_items: Vec<CatalogEntry>,
34}
35
36impl CatalogResolution {
37    pub fn empty() -> Self {
38        Self {
39            entries: Vec::new(),
40            cache_writes: Vec::new(),
41            discovered_items: Vec::new(),
42        }
43    }
44}
45
46pub fn resolve_catalogs(
47    root: &Path,
48    references: &[String],
49    options: &CatalogResolveOptions,
50) -> Result<CatalogResolution> {
51    resolve_catalogs_with_client(root, references, options, &DistributorCatalogClient)
52}
53
54pub fn resolve_catalogs_with_client(
55    root: &Path,
56    references: &[String],
57    options: &CatalogResolveOptions,
58    client: &dyn CatalogArtifactClient,
59) -> Result<CatalogResolution> {
60    let mut entries = Vec::new();
61    let mut cache_writes = Vec::new();
62    let mut discovered_items = Vec::new();
63
64    let mut refs = references.to_vec();
65    refs.sort();
66    refs.dedup();
67
68    for reference in refs {
69        let resolved = resolve_one(root, &reference, options, client)?;
70        cache_writes.extend(resolved.1);
71        discovered_items.extend(resolved.2);
72        entries.push(resolved.0);
73    }
74
75    cache_writes.sort();
76    cache_writes.dedup();
77    discovered_items.sort_by(|left, right| {
78        left.id
79            .cmp(&right.id)
80            .then(left.reference.cmp(&right.reference))
81    });
82    discovered_items
83        .dedup_by(|left, right| left.id == right.id && left.reference == right.reference);
84
85    Ok(CatalogResolution {
86        entries,
87        cache_writes,
88        discovered_items,
89    })
90}
91
92fn resolve_one(
93    root: &Path,
94    reference: &str,
95    options: &CatalogResolveOptions,
96    client: &dyn CatalogArtifactClient,
97) -> Result<(CatalogLockEntry, Vec<String>, Vec<CatalogEntry>)> {
98    if let Some(local_path) = parse_local_reference(root, reference) {
99        let resolved_path = if local_path.is_absolute() {
100            local_path
101        } else {
102            root.join(local_path)
103        };
104        let bytes = std::fs::read(&resolved_path)
105            .with_context(|| format!("read catalog {}", resolved_path.display()))?;
106        let digest = digest_hex(&bytes);
107        let catalog_entries = load_catalog_entries(&bytes, &resolved_path.display().to_string())?;
108        let summary = parse_catalog_bytes(&bytes, &resolved_path.display().to_string())?;
109        let cache_paths = if options.write_cache {
110            cache::cache_catalog_bytes(root, reference, &digest, &bytes)?
111        } else {
112            Vec::new()
113        };
114        return Ok((
115            CatalogLockEntry {
116                requested_ref: reference.to_string(),
117                resolved_ref: resolved_path.display().to_string(),
118                digest,
119                source: "local_file".to_string(),
120                item_count: summary.item_count,
121                item_ids: summary.item_ids,
122                cache_path: cache::resolve_cached_path(root, reference)?
123                    .map(|path| relative_display(root, &path)),
124            },
125            cache_paths
126                .into_iter()
127                .map(|path| relative_display(root, &path))
128                .collect(),
129            catalog_entries,
130        ));
131    }
132
133    if let Some(cached_path) = cache::resolve_cached_path(root, reference)? {
134        let bytes = std::fs::read(&cached_path)
135            .with_context(|| format!("read cached catalog {}", cached_path.display()))?;
136        let digest = digest_hex(&bytes);
137        let catalog_entries = load_catalog_entries(&bytes, &cached_path.display().to_string())?;
138        let summary = parse_catalog_bytes(&bytes, &cached_path.display().to_string())?;
139        return Ok((
140            CatalogLockEntry {
141                requested_ref: reference.to_string(),
142                resolved_ref: reference.to_string(),
143                digest,
144                source: "workspace_cache".to_string(),
145                item_count: summary.item_count,
146                item_ids: summary.item_ids,
147                cache_path: Some(relative_display(root, &cached_path)),
148            },
149            Vec::new(),
150            catalog_entries,
151        ));
152    }
153
154    if options.offline {
155        bail!(
156            "catalog {reference} is not cached in {} and offline mode is enabled; seed the workspace-local cache first or rerun without --offline",
157            root.join(super::CACHE_ROOT_DIR).display()
158        );
159    }
160
161    let fetched = client.fetch_catalog(root, reference)?;
162    let catalog_entries = load_catalog_entries(&fetched.bytes, reference)?;
163    let summary = parse_catalog_bytes(&fetched.bytes, reference)?;
164    let cache_paths = if options.write_cache {
165        cache::cache_catalog_bytes(root, reference, &fetched.digest, &fetched.bytes)?
166    } else {
167        Vec::new()
168    };
169    Ok((
170        CatalogLockEntry {
171            requested_ref: reference.to_string(),
172            resolved_ref: fetched.resolved_ref,
173            digest: fetched.digest,
174            source: "remote".to_string(),
175            item_count: summary.item_count,
176            item_ids: summary.item_ids,
177            cache_path: cache::resolve_cached_path(root, reference)?
178                .map(|path| relative_display(root, &path)),
179        },
180        cache_paths
181            .into_iter()
182            .map(|path| relative_display(root, &path))
183            .collect(),
184        catalog_entries,
185    ))
186}
187
188fn parse_local_reference(root: &Path, reference: &str) -> Option<PathBuf> {
189    if let Some(path) = reference.strip_prefix("file://") {
190        let trimmed = path.trim();
191        if trimmed.is_empty() {
192            return None;
193        }
194        return Some(PathBuf::from(trimmed));
195    }
196    if reference.contains("://") {
197        return None;
198    }
199    let candidate = PathBuf::from(reference);
200    if candidate.is_absolute() || candidate.exists() || root.join(&candidate).exists() {
201        return Some(candidate);
202    }
203    None
204}
205
206fn digest_hex(bytes: &[u8]) -> String {
207    let mut hasher = Sha256::new();
208    hasher.update(bytes);
209    let digest = hasher.finalize();
210    let mut out = String::from("sha256:");
211    for byte in digest {
212        out.push_str(&format!("{byte:02x}"));
213    }
214    out
215}
216
217fn relative_display(root: &Path, path: &Path) -> String {
218    path.strip_prefix(root)
219        .unwrap_or(path)
220        .display()
221        .to_string()
222}