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 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#[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#[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
159pub 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 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}