Skip to main content

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        let digest_hex = to_hex(&digest);
97        Self {
98            raw: format!("sha256:{digest_hex}"),
99            algorithm: "sha256".into(),
100            value: digest_hex,
101        }
102    }
103
104    pub fn algorithm(&self) -> &str {
105        &self.algorithm
106    }
107
108    pub fn value(&self) -> &str {
109        &self.value
110    }
111
112    pub fn as_str(&self) -> &str {
113        &self.raw
114    }
115
116    pub fn raw_string(&self) -> String {
117        self.raw.clone()
118    }
119
120    pub fn cache_label(&self) -> String {
121        self.raw.replace(':', "_")
122    }
123
124    pub fn matches_file(&self, path: &Path) -> Result<bool> {
125        let computed = compute_digest(path)?;
126        Ok(computed.raw.eq_ignore_ascii_case(&self.raw))
127    }
128}
129
130/// Cached and verified pack metadata.
131#[derive(Debug, Clone)]
132pub struct ResolvedPack {
133    pub reference: PackRef,
134    pub locator: String,
135    pub path: PathBuf,
136    pub manifest: PackManifest,
137    pub digest: PackDigest,
138    pub report: VerifyReport,
139}
140
141/// Per-tenant resolved packs.
142#[derive(Debug, Clone)]
143pub struct TenantPacks {
144    pub main: ResolvedPack,
145    pub overlays: Vec<ResolvedPack>,
146}
147
148#[derive(Debug, Clone)]
149pub struct ResolvedSet {
150    pub tenants: BTreeMap<String, TenantPacks>,
151}
152
153impl ResolvedSet {
154    pub fn tenants(&self) -> &BTreeMap<String, TenantPacks> {
155        &self.tenants
156    }
157}
158
159/// Coordinates resolvers, cache, and verification.
160pub struct PackManager {
161    cfg: PackConfig,
162    cache: PackCache,
163    registry: ResolverRegistry,
164    verifier: Option<PackVerifier>,
165}
166
167impl PackManager {
168    pub fn new(cfg: PackConfig) -> Result<Self> {
169        let verifier = cfg
170            .public_key
171            .as_deref()
172            .map(PackVerifier::from_env_value)
173            .transpose()?;
174        let mut registry = ResolverRegistry::default();
175        let fs_root = std::env::current_dir()
176            .context("failed to resolve current directory")?
177            .canonicalize()
178            .context("failed to canonicalize current directory")?;
179        registry.register_builtin(fs_root, cfg.network.as_ref())?;
180        Ok(Self {
181            cache: PackCache::new(cfg.cache_dir.clone()),
182            cfg,
183            registry,
184            verifier,
185        })
186    }
187
188    /// Resolve all packs referenced in the provided index.
189    pub fn resolve_all_for_index(&self, index: &Index) -> Result<ResolvedSet> {
190        let mut tenants = BTreeMap::new();
191        for (tenant, record) in index.tenants() {
192            let main = self.resolve_entry(&record.main_pack)?;
193            let mut overlays = Vec::new();
194            for overlay in &record.overlays {
195                overlays.push(self.resolve_entry(overlay)?);
196            }
197            tenants.insert(tenant.clone(), TenantPacks { main, overlays });
198        }
199        Ok(ResolvedSet { tenants })
200    }
201
202    fn resolve_entry(&self, entry: &PackEntry) -> Result<ResolvedPack> {
203        let locator = entry
204            .locator
205            .with_fallback(self.cfg.source)
206            .context("pack locator missing scheme")?;
207        let response = self
208            .registry
209            .fetch(&locator)
210            .with_context(|| format!("resolver failed for {}", locator))?;
211
212        let fetched_digest = compute_digest(response.path())?;
213        if entry
214            .content_digest
215            .as_ref()
216            .or_else(|| entry.reference.version.as_digest())
217            .map(|expected| {
218                expected
219                    .as_str()
220                    .eq_ignore_ascii_case(fetched_digest.as_str())
221            })
222            == Some(false)
223        {
224            let expected = entry
225                .content_digest
226                .as_ref()
227                .or_else(|| entry.reference.version.as_digest())
228                .map(|value| value.as_str())
229                .unwrap_or("<unknown>");
230            bail!(
231                "digest mismatch for {}: expected {}, found {}",
232                entry.reference.name,
233                expected,
234                fetched_digest.as_str()
235            );
236        }
237
238        if let Some(verifier) = &self.verifier {
239            let signature = entry.signature.as_deref().ok_or_else(|| {
240                anyhow!(
241                    "signature missing for pack {} but PACK_PUBLIC_KEY is configured",
242                    entry.reference.name
243                )
244            })?;
245            verifier.verify(fetched_digest.as_str().as_bytes(), signature)?;
246        }
247
248        let cached = self.cache.store(entry, response.path(), &fetched_digest)?;
249        let PackLoad {
250            manifest, report, ..
251        } = open_pack(&cached, SigningPolicy::DevOk).map_err(|err| {
252            anyhow!(
253                "failed to open cached pack {}: {}",
254                cached.display(),
255                err.message
256            )
257        })?;
258
259        Ok(ResolvedPack {
260            reference: entry.reference.clone(),
261            locator,
262            path: cached,
263            manifest,
264            digest: fetched_digest,
265            report,
266        })
267    }
268}
269
270fn compute_digest(path: &Path) -> Result<PackDigest> {
271    use sha2::{Digest, Sha256};
272    const BUF_SIZE: usize = 64 * 1024;
273    let file = File::open(path)
274        .with_context(|| format!("failed to open {} for hashing", path.display()))?;
275    let mut reader = BufReader::new(file);
276    let mut hasher = Sha256::new();
277    let mut buf = [0u8; BUF_SIZE];
278    loop {
279        let read = reader.read(&mut buf)?;
280        if read == 0 {
281            break;
282        }
283        hasher.update(&buf[..read]);
284    }
285    let digest = hasher.finalize();
286    let digest_hex = to_hex(&digest);
287    PackDigest::parse(format!("sha256:{digest_hex}"))
288}
289
290fn sanitize_segment(value: &str) -> String {
291    value
292        .chars()
293        .map(|ch| match ch {
294            '/' | '\\' | ':' => '_',
295            other => other,
296        })
297        .collect()
298}
299
300fn to_hex(digest: &[u8]) -> String {
301    digest.iter().map(|byte| format!("{byte:02x}")).collect()
302}