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#[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#[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#[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#[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#[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
158pub 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, cfg.network.as_ref())?;
179 Ok(Self {
180 cache: PackCache::new(cfg.cache_dir.clone()),
181 cfg,
182 registry,
183 verifier,
184 })
185 }
186
187 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}