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, FsResolver, 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        let fs_root = std::env::current_dir()
175            .context("failed to resolve current directory")?
176            .canonicalize()
177            .context("failed to canonicalize current directory")?;
178        registry.register_builtin(fs_root)?;
179        Ok(Self {
180            cache: PackCache::new(cfg.cache_dir.clone()),
181            cfg,
182            registry,
183            verifier,
184        })
185    }
186
187    /// Resolve all packs referenced in the provided index.
188    pub fn resolve_all_for_index(&self, index: &Index) -> Result<ResolvedSet> {
189        let mut tenants = BTreeMap::new();
190        for (tenant, record) in index.tenants() {
191            let main = self.resolve_entry(&record.main_pack)?;
192            let mut overlays = Vec::new();
193            for overlay in &record.overlays {
194                overlays.push(self.resolve_entry(overlay)?);
195            }
196            tenants.insert(tenant.clone(), TenantPacks { main, overlays });
197        }
198        Ok(ResolvedSet { tenants })
199    }
200
201    fn resolve_entry(&self, entry: &PackEntry) -> Result<ResolvedPack> {
202        let locator = entry
203            .locator
204            .with_fallback(self.cfg.source)
205            .context("pack locator missing scheme")?;
206        let response = self
207            .registry
208            .fetch(&locator)
209            .with_context(|| format!("resolver failed for {}", locator))?;
210
211        let fetched_digest = compute_digest(response.path())?;
212        if entry
213            .content_digest
214            .as_ref()
215            .or_else(|| entry.reference.version.as_digest())
216            .map(|expected| {
217                expected
218                    .as_str()
219                    .eq_ignore_ascii_case(fetched_digest.as_str())
220            })
221            == Some(false)
222        {
223            let expected = entry
224                .content_digest
225                .as_ref()
226                .or_else(|| entry.reference.version.as_digest())
227                .map(|value| value.as_str())
228                .unwrap_or("<unknown>");
229            bail!(
230                "digest mismatch for {}: expected {}, found {}",
231                entry.reference.name,
232                expected,
233                fetched_digest.as_str()
234            );
235        }
236
237        if let Some(verifier) = &self.verifier {
238            let signature = entry.signature.as_deref().ok_or_else(|| {
239                anyhow!(
240                    "signature missing for pack {} but PACK_PUBLIC_KEY is configured",
241                    entry.reference.name
242                )
243            })?;
244            verifier.verify(fetched_digest.as_str().as_bytes(), signature)?;
245        }
246
247        let cached = self.cache.store(entry, response.path(), &fetched_digest)?;
248        let PackLoad {
249            manifest, report, ..
250        } = open_pack(&cached, SigningPolicy::DevOk).map_err(|err| {
251            anyhow!(
252                "failed to open cached pack {}: {}",
253                cached.display(),
254                err.message
255            )
256        })?;
257
258        Ok(ResolvedPack {
259            reference: entry.reference.clone(),
260            locator,
261            path: cached,
262            manifest,
263            digest: fetched_digest,
264            report,
265        })
266    }
267}
268
269fn compute_digest(path: &Path) -> Result<PackDigest> {
270    use sha2::{Digest, Sha256};
271    const BUF_SIZE: usize = 64 * 1024;
272    let file = File::open(path)
273        .with_context(|| format!("failed to open {} for hashing", path.display()))?;
274    let mut reader = BufReader::new(file);
275    let mut hasher = Sha256::new();
276    let mut buf = [0u8; BUF_SIZE];
277    loop {
278        let read = reader.read(&mut buf)?;
279        if read == 0 {
280            break;
281        }
282        hasher.update(&buf[..read]);
283    }
284    let digest = hasher.finalize();
285    PackDigest::parse(format!("sha256:{digest:0x}"))
286}
287
288fn sanitize_segment(value: &str) -> String {
289    value
290        .chars()
291        .map(|ch| match ch {
292            '/' | '\\' | ':' => '_',
293            other => other,
294        })
295        .collect()
296}