runner_core/packs/
mod.rs

1use std::borrow::Cow;
2use std::collections::BTreeMap;
3use std::fs::File;
4use std::io::{BufReader, Read};
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result, anyhow, bail};
8use greentic_pack::builder::PackManifest;
9use greentic_pack::reader::{PackLoad, SigningPolicy, VerifyReport, open_pack};
10use semver::Version;
11
12use crate::env::PackConfig;
13
14pub use cache::PackCache;
15pub use index::{Index, PackEntry, TenantRecord};
16pub use resolver::{FetchResponse, ResolverRegistry};
17pub use verify::PackVerifier;
18
19mod cache;
20mod index;
21pub mod resolver;
22mod verify;
23
24/// Reference to a pack as defined in the index.
25#[derive(Debug, Clone, PartialEq, Eq, Hash)]
26pub struct PackRef {
27    pub name: String,
28    pub version: PackVersion,
29}
30
31impl PackRef {
32    pub fn cache_key(&self) -> String {
33        format!(
34            "{}-{}",
35            sanitize_segment(&self.name),
36            self.version.cache_label()
37        )
38    }
39}
40
41/// Version metadata for a pack.
42#[derive(Debug, Clone, PartialEq, Eq, Hash)]
43pub enum PackVersion {
44    Semver(Version),
45    Digest(PackDigest),
46}
47
48impl PackVersion {
49    pub fn cache_label(&self) -> Cow<'_, str> {
50        match self {
51            Self::Semver(v) => Cow::Owned(v.to_string()),
52            Self::Digest(digest) => Cow::Owned(digest.cache_label()),
53        }
54    }
55
56    pub fn as_digest(&self) -> Option<&PackDigest> {
57        match self {
58            Self::Digest(digest) => Some(digest),
59            _ => None,
60        }
61    }
62}
63
64/// Digest (algorithm:value) to assert pack integrity.
65#[derive(Debug, Clone, PartialEq, Eq, Hash)]
66pub struct PackDigest {
67    raw: String,
68    algorithm: String,
69    value: String,
70}
71
72impl PackDigest {
73    pub fn parse(raw: impl Into<String>) -> Result<Self> {
74        let raw_string = raw.into();
75        let (algorithm, value) = {
76            let (algorithm_raw, value_raw) = raw_string
77                .split_once(':')
78                .ok_or_else(|| anyhow!("invalid digest `{raw_string}`; expected algo:value"))?;
79            if algorithm_raw.is_empty() || value_raw.is_empty() {
80                bail!("invalid digest format `{raw_string}`");
81            }
82            (algorithm_raw.to_ascii_lowercase(), value_raw.to_string())
83        };
84        Ok(Self {
85            raw: raw_string,
86            algorithm,
87            value,
88        })
89    }
90
91    pub fn sha256_from_bytes(bytes: &[u8]) -> Self {
92        use sha2::{Digest, Sha256};
93        let mut hasher = Sha256::new();
94        hasher.update(bytes);
95        let digest = hasher.finalize();
96        Self {
97            raw: format!("sha256:{digest:0x}"),
98            algorithm: "sha256".into(),
99            value: format!("{digest:0x}"),
100        }
101    }
102
103    pub fn algorithm(&self) -> &str {
104        &self.algorithm
105    }
106
107    pub fn value(&self) -> &str {
108        &self.value
109    }
110
111    pub fn as_str(&self) -> &str {
112        &self.raw
113    }
114
115    pub fn raw_string(&self) -> String {
116        self.raw.clone()
117    }
118
119    pub fn cache_label(&self) -> String {
120        self.raw.replace(':', "_")
121    }
122
123    pub fn matches_file(&self, path: &Path) -> Result<bool> {
124        let computed = compute_digest(path)?;
125        Ok(computed.raw.eq_ignore_ascii_case(&self.raw))
126    }
127}
128
129/// Cached and verified pack metadata.
130#[derive(Debug, Clone)]
131pub struct ResolvedPack {
132    pub reference: PackRef,
133    pub locator: String,
134    pub path: PathBuf,
135    pub manifest: PackManifest,
136    pub digest: PackDigest,
137    pub report: VerifyReport,
138}
139
140/// Per-tenant resolved packs.
141#[derive(Debug, Clone)]
142pub struct TenantPacks {
143    pub main: ResolvedPack,
144    pub overlays: Vec<ResolvedPack>,
145}
146
147#[derive(Debug, Clone)]
148pub struct ResolvedSet {
149    pub tenants: BTreeMap<String, TenantPacks>,
150}
151
152impl ResolvedSet {
153    pub fn tenants(&self) -> &BTreeMap<String, TenantPacks> {
154        &self.tenants
155    }
156}
157
158/// Coordinates resolvers, cache, and verification.
159pub struct PackManager {
160    cfg: PackConfig,
161    cache: PackCache,
162    registry: ResolverRegistry,
163    verifier: Option<PackVerifier>,
164}
165
166impl PackManager {
167    pub fn new(cfg: PackConfig) -> Result<Self> {
168        let verifier = cfg
169            .public_key
170            .as_deref()
171            .map(PackVerifier::from_env_value)
172            .transpose()?;
173        let mut registry = ResolverRegistry::default();
174        registry.register_builtin()?;
175        Ok(Self {
176            cache: PackCache::new(cfg.cache_dir.clone()),
177            cfg,
178            registry,
179            verifier,
180        })
181    }
182
183    /// Resolve all packs referenced in the provided index.
184    pub fn resolve_all_for_index(&self, index: &Index) -> Result<ResolvedSet> {
185        let mut tenants = BTreeMap::new();
186        for (tenant, record) in index.tenants() {
187            let main = self.resolve_entry(&record.main_pack)?;
188            let mut overlays = Vec::new();
189            for overlay in &record.overlays {
190                overlays.push(self.resolve_entry(overlay)?);
191            }
192            tenants.insert(tenant.clone(), TenantPacks { main, overlays });
193        }
194        Ok(ResolvedSet { tenants })
195    }
196
197    fn resolve_entry(&self, entry: &PackEntry) -> Result<ResolvedPack> {
198        let locator = entry
199            .locator
200            .with_fallback(self.cfg.source)
201            .context("pack locator missing scheme")?;
202        let response = self
203            .registry
204            .fetch(&locator)
205            .with_context(|| format!("resolver failed for {}", locator))?;
206
207        let fetched_digest = compute_digest(response.path())?;
208        if entry
209            .content_digest
210            .as_ref()
211            .or_else(|| entry.reference.version.as_digest())
212            .map(|expected| {
213                expected
214                    .as_str()
215                    .eq_ignore_ascii_case(fetched_digest.as_str())
216            })
217            == Some(false)
218        {
219            let expected = entry
220                .content_digest
221                .as_ref()
222                .or_else(|| entry.reference.version.as_digest())
223                .map(|value| value.as_str())
224                .unwrap_or("<unknown>");
225            bail!(
226                "digest mismatch for {}: expected {}, found {}",
227                entry.reference.name,
228                expected,
229                fetched_digest.as_str()
230            );
231        }
232
233        if let Some(verifier) = &self.verifier {
234            let signature = entry.signature.as_deref().ok_or_else(|| {
235                anyhow!(
236                    "signature missing for pack {} but PACK_PUBLIC_KEY is configured",
237                    entry.reference.name
238                )
239            })?;
240            verifier.verify(fetched_digest.as_str().as_bytes(), signature)?;
241        }
242
243        let cached = self.cache.store(entry, response.path(), &fetched_digest)?;
244        let PackLoad {
245            manifest, report, ..
246        } = open_pack(&cached, SigningPolicy::DevOk).map_err(|err| {
247            anyhow!(
248                "failed to open cached pack {}: {}",
249                cached.display(),
250                err.message
251            )
252        })?;
253
254        Ok(ResolvedPack {
255            reference: entry.reference.clone(),
256            locator,
257            path: cached,
258            manifest,
259            digest: fetched_digest,
260            report,
261        })
262    }
263}
264
265fn compute_digest(path: &Path) -> Result<PackDigest> {
266    use sha2::{Digest, Sha256};
267    const BUF_SIZE: usize = 64 * 1024;
268    let file = File::open(path)
269        .with_context(|| format!("failed to open {} for hashing", path.display()))?;
270    let mut reader = BufReader::new(file);
271    let mut hasher = Sha256::new();
272    let mut buf = [0u8; BUF_SIZE];
273    loop {
274        let read = reader.read(&mut buf)?;
275        if read == 0 {
276            break;
277        }
278        hasher.update(&buf[..read]);
279    }
280    let digest = hasher.finalize();
281    PackDigest::parse(format!("sha256:{digest:0x}"))
282}
283
284fn sanitize_segment(value: &str) -> String {
285    value
286        .chars()
287        .map(|ch| match ch {
288            '/' | '\\' | ':' => '_',
289            other => other,
290        })
291        .collect()
292}