1use std::collections::{HashMap, HashSet};
2use std::convert::TryInto;
3use std::fs::File;
4use std::io::{Read, Seek};
5use std::path::Path;
6
7use anyhow::{Context, Result, anyhow, bail};
8use base64::Engine;
9use base64::engine::general_purpose::URL_SAFE_NO_PAD;
10use ed25519_dalek::{Signature, Verifier, VerifyingKey};
11use greentic_types::ComponentManifest;
12use greentic_types::decode_pack_manifest;
13use greentic_types::pack::extensions::component_manifests::{
14 ComponentManifestIndexV1, EXT_COMPONENT_MANIFEST_INDEX_V1, ManifestEncoding,
15};
16use greentic_types::pack_manifest::{ExtensionInline, PackManifest as GpackManifest};
17use serde::Deserialize;
18use serde_cbor;
19use serde_json;
20use sha2::{Digest, Sha256};
21use x509_parser::pem::parse_x509_pem;
22use x509_parser::prelude::*;
23use zip::ZipArchive;
24
25use crate::builder::{
26 ComponentEntry, FlowEntry, ImportRef, PackManifest, PackMeta, SBOM_FORMAT,
27 SIGNATURE_CHAIN_PATH, SIGNATURE_PATH, SbomEntry, SignatureEnvelope, hex_hash,
28 signature_digest_from_entries,
29};
30
31#[cfg(test)]
32const MAX_ARCHIVE_BYTES: u64 = 256 * 1024;
33#[cfg(not(test))]
34const MAX_ARCHIVE_BYTES: u64 = 64 * 1024 * 1024;
35
36#[cfg(test)]
37const MAX_FILE_BYTES: u64 = 64 * 1024;
38#[cfg(not(test))]
39const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
40
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub enum SigningPolicy {
43 DevOk,
44 Strict,
45}
46
47#[derive(Debug, Clone, Default)]
48pub struct VerifyReport {
49 pub signature_ok: bool,
50 pub sbom_ok: bool,
51 pub warnings: Vec<String>,
52}
53
54#[derive(Debug, Clone)]
55pub struct PackLoad {
56 pub manifest: PackManifest,
57 pub report: VerifyReport,
58 pub sbom: Vec<SbomEntry>,
59 pub files: HashMap<String, Vec<u8>>,
60 pub gpack_manifest: Option<GpackManifest>,
61}
62
63#[derive(Debug, Clone)]
64pub struct PackVerifyResult {
65 pub message: String,
66}
67
68impl PackVerifyResult {
69 fn from_error(err: anyhow::Error) -> Self {
70 Self {
71 message: err.to_string(),
72 }
73 }
74}
75
76#[derive(Debug, Clone)]
77pub struct ComponentManifestIndexState {
78 pub present: bool,
79 pub index: Option<ComponentManifestIndexV1>,
80 pub error: Option<String>,
81}
82
83impl ComponentManifestIndexState {
84 pub fn ok(&self) -> bool {
85 !self.present || self.error.is_none()
86 }
87}
88
89#[derive(Debug, Clone)]
90pub struct ComponentManifestFileStatus {
91 pub component_id: String,
92 pub manifest_file: String,
93 pub encoding: ManifestEncoding,
94 pub content_hash: Option<String>,
95 pub file_present: bool,
96 pub hash_ok: Option<bool>,
97 pub decoded: bool,
98 pub inline_match: Option<bool>,
99 pub error: Option<String>,
100}
101
102impl ComponentManifestFileStatus {
103 pub fn is_ok(&self) -> bool {
104 self.error.is_none()
105 && self.file_present
106 && self.decoded
107 && self.hash_ok.unwrap_or(true)
108 && self.inline_match.unwrap_or(true)
109 }
110}
111
112#[derive(Debug, Clone)]
113pub struct ManifestFileVerificationReport {
114 pub extension_present: bool,
115 pub extension_error: Option<String>,
116 pub entries: Vec<ComponentManifestFileStatus>,
117}
118
119impl ManifestFileVerificationReport {
120 pub fn ok(&self) -> bool {
121 if !self.extension_present {
122 return true;
123 }
124 self.extension_error.is_none()
125 && self.entries.iter().all(ComponentManifestFileStatus::is_ok)
126 }
127
128 pub fn first_error(&self) -> Option<String> {
129 if let Some(err) = &self.extension_error {
130 return Some(err.clone());
131 }
132 self.entries.iter().find_map(|status| status.error.clone())
133 }
134}
135
136pub fn open_pack(path: &Path, policy: SigningPolicy) -> Result<PackLoad, PackVerifyResult> {
137 match open_pack_inner(path, policy) {
138 Ok(result) => Ok(result),
139 Err(err) => Err(PackVerifyResult::from_error(err)),
140 }
141}
142
143impl PackLoad {
144 pub fn component_manifest_index_v1(&self) -> ComponentManifestIndexState {
145 let mut state = ComponentManifestIndexState {
146 present: false,
147 index: None,
148 error: None,
149 };
150
151 let manifest = match self.gpack_manifest.as_ref() {
152 Some(manifest) => manifest,
153 None => return state,
154 };
155
156 let Some(extension) = manifest
157 .extensions
158 .as_ref()
159 .and_then(|map| map.get(EXT_COMPONENT_MANIFEST_INDEX_V1))
160 else {
161 return state;
162 };
163 state.present = true;
164
165 let inline = match extension.inline.as_ref() {
166 Some(inline) => inline,
167 None => {
168 state.error = Some("component manifest index missing inline payload".into());
169 return state;
170 }
171 };
172
173 let payload = match inline {
174 ExtensionInline::Other(value) => value,
175 _ => {
176 state.error =
177 Some("component manifest index inline payload has unexpected shape".into());
178 return state;
179 }
180 };
181
182 match ComponentManifestIndexV1::from_extension_value(payload) {
183 Ok(index) => state.index = Some(index),
184 Err(err) => state.error = Some(err.to_string()),
185 }
186
187 state
188 }
189
190 pub fn get_component_manifest_prefer_file(
191 &self,
192 component_id: &str,
193 ) -> Result<Option<ComponentManifest>> {
194 let state = self.component_manifest_index_v1();
195 if let Some(err) = state.error {
196 return Err(anyhow!(err));
197 }
198
199 if let Some(entry) = state.index.as_ref().and_then(|index| {
200 index
201 .entries
202 .iter()
203 .find(|entry| entry.component_id == component_id)
204 }) {
205 if entry.encoding != ManifestEncoding::Cbor {
206 bail!("unsupported manifest encoding {:?}", entry.encoding);
207 }
208
209 if let Some(bytes) = self.files.get(&entry.manifest_file) {
210 if let Some(expected) = entry.content_hash.as_deref() {
211 let actual = sha256_prefixed(bytes);
212 if !expected.eq_ignore_ascii_case(&actual) {
213 bail!(
214 "manifest hash mismatch for {}: expected {}, got {}",
215 entry.manifest_file,
216 expected,
217 actual
218 );
219 }
220 }
221
222 let decoded: ComponentManifest =
223 serde_cbor::from_slice(bytes).context("decode component manifest")?;
224 if decoded.id.to_string() != entry.component_id {
225 bail!(
226 "manifest id {} does not match index component_id {}",
227 decoded.id,
228 entry.component_id
229 );
230 }
231 return Ok(Some(decoded));
232 }
233 }
234
235 if let Some(component) = self.gpack_manifest.as_ref().and_then(|manifest| {
236 manifest
237 .components
238 .iter()
239 .find(|c| c.id.to_string() == component_id)
240 }) {
241 return Ok(Some(component.clone()));
242 }
243
244 Ok(None)
245 }
246
247 pub fn verify_component_manifest_files(&self) -> ManifestFileVerificationReport {
248 let mut report = ManifestFileVerificationReport {
249 extension_present: false,
250 extension_error: None,
251 entries: Vec::new(),
252 };
253
254 let state = self.component_manifest_index_v1();
255 if !state.present {
256 return report;
257 }
258 report.extension_present = true;
259
260 let Some(index) = state.index else {
261 report.extension_error = state.error;
262 return report;
263 };
264
265 let inline_components = self
266 .gpack_manifest
267 .as_ref()
268 .map(|manifest| &manifest.components);
269
270 for entry in index.entries {
271 let mut status = ComponentManifestFileStatus {
272 component_id: entry.component_id.clone(),
273 manifest_file: entry.manifest_file.clone(),
274 encoding: entry.encoding.clone(),
275 content_hash: entry.content_hash.clone(),
276 file_present: false,
277 hash_ok: None,
278 decoded: false,
279 inline_match: None,
280 error: None,
281 };
282
283 if entry.encoding != ManifestEncoding::Cbor {
284 status.error = Some("unsupported manifest encoding (expected cbor)".into());
285 report.entries.push(status);
286 continue;
287 }
288
289 let Some(bytes) = self.files.get(&entry.manifest_file) else {
290 status.error = Some("manifest file missing from archive".into());
291 report.entries.push(status);
292 continue;
293 };
294 status.file_present = true;
295
296 if let Some(expected) = entry.content_hash.as_deref() {
297 if !expected.starts_with("sha256:") {
298 status.hash_ok = Some(false);
299 status.error = Some("content_hash must use sha256:<hex>".into());
300 report.entries.push(status);
301 continue;
302 }
303 let actual = sha256_prefixed(bytes);
304 let matches = expected.eq_ignore_ascii_case(&actual);
305 status.hash_ok = Some(matches);
306 if !matches {
307 status.error = Some(format!(
308 "manifest hash mismatch: expected {}, got {}",
309 expected, actual
310 ));
311 }
312 }
313
314 match serde_cbor::from_slice::<ComponentManifest>(bytes) {
315 Ok(decoded) => {
316 status.decoded = true;
317 if decoded.id.to_string() != entry.component_id {
318 status.error.get_or_insert_with(|| {
319 format!(
320 "component id mismatch: index has {}, manifest has {}",
321 entry.component_id, decoded.id
322 )
323 });
324 }
325
326 if let Some(inline_components) = inline_components {
327 if let Some(inline) = inline_components.iter().find(|c| c.id == decoded.id)
328 {
329 let matches = inline == &decoded;
330 status.inline_match = Some(matches);
331 if !matches {
332 status.error.get_or_insert_with(|| {
333 "external manifest differs from inline manifest".into()
334 });
335 }
336 } else {
337 status.inline_match = Some(false);
338 status.error.get_or_insert_with(|| {
339 "component missing from inline manifest".into()
340 });
341 }
342 }
343 }
344 Err(err) => {
345 status
346 .error
347 .get_or_insert_with(|| format!("failed to decode manifest: {err}"));
348 }
349 }
350
351 report.entries.push(status);
352 }
353
354 report
355 }
356}
357
358fn open_pack_inner(path: &Path, policy: SigningPolicy) -> Result<PackLoad> {
359 let mut archive = ZipArchive::new(
360 File::open(path).with_context(|| format!("failed to open {}", path.display()))?,
361 )
362 .with_context(|| format!("{} is not a valid gtpack archive", path.display()))?;
363
364 let (files, total) = read_archive_entries(&mut archive)?;
365 if total > MAX_ARCHIVE_BYTES {
366 bail!(
367 "gtpack archive exceeds maximum allowed size ({} bytes)",
368 MAX_ARCHIVE_BYTES
369 );
370 }
371
372 let manifest_bytes = files
373 .get("manifest.cbor")
374 .cloned()
375 .ok_or_else(|| anyhow!("manifest.cbor missing from archive"))?;
376 let decoded_gpack_manifest = decode_pack_manifest(&manifest_bytes).ok();
377 match decode_manifest(&manifest_bytes).context("manifest.cbor is invalid")? {
378 ManifestModel::Pack(manifest) => {
379 let manifest = *manifest;
380 let (sbom_doc, sbom_bytes, sbom_name) = read_sbom_required(&files)?;
381 if sbom_doc.format != SBOM_FORMAT {
382 bail!("unexpected SBOM format: {}", sbom_doc.format);
383 }
384
385 let mut warnings = Vec::new();
386 verify_sbom(&files, &sbom_doc.files, sbom_name)?;
387 let signature_ok = match (
388 files.get(SIGNATURE_PATH),
389 files.get(SIGNATURE_CHAIN_PATH),
390 Some(&sbom_bytes),
391 ) {
392 (Some(_), Some(_), Some(sbom_bytes)) => match verify_signature(
393 &files,
394 &manifest_bytes,
395 sbom_bytes,
396 &sbom_doc.files,
397 policy,
398 &mut warnings,
399 ) {
400 Ok(()) => true,
401 Err(err) => {
402 if matches!(policy, SigningPolicy::Strict) {
403 return Err(err);
404 }
405 warnings.push(format!("signature verification failed: {err}"));
406 false
407 }
408 },
409 (None, None, _) => match policy {
410 SigningPolicy::Strict => {
411 bail!("signature file `{}` missing", SIGNATURE_PATH)
412 }
413 SigningPolicy::DevOk => {
414 warnings.push("signature files missing; skipping verification".into());
415 false
416 }
417 },
418 _ => {
419 match policy {
420 SigningPolicy::Strict => bail!("signature files incomplete; missing chain"),
421 SigningPolicy::DevOk => warnings
422 .push("signature files incomplete; skipping verification".into()),
423 }
424 false
425 }
426 };
427
428 Ok(PackLoad {
429 manifest,
430 report: VerifyReport {
431 signature_ok,
432 sbom_ok: true,
433 warnings,
434 },
435 sbom: sbom_doc.files,
436 files,
437 gpack_manifest: decoded_gpack_manifest,
438 })
439 }
440 ManifestModel::Gpack(manifest) => {
441 let manifest = *manifest;
442 let mut warnings = Vec::new();
443 if manifest.schema_version != "pack-v1" {
444 warnings.push(format!(
445 "detected manifest schema {}; applying compatibility reader",
446 manifest.schema_version
447 ));
448 }
449
450 let (sbom, sbom_ok, sbom_bytes, sbom_name) = read_sbom_optional(&files, &mut warnings);
451
452 let signature_ok = match (
453 files.get(SIGNATURE_PATH),
454 files.get(SIGNATURE_CHAIN_PATH),
455 sbom_bytes.as_deref(),
456 sbom_ok,
457 ) {
458 (Some(_), Some(_), Some(sbom_bytes), true) => {
459 match verify_signature(
460 &files,
461 &manifest_bytes,
462 sbom_bytes,
463 &sbom,
464 policy,
465 &mut warnings,
466 ) {
467 Ok(()) => true,
468 Err(err) => {
469 warnings.push(format!("signature verification failed: {err}"));
470 false
471 }
472 }
473 }
474 (Some(_), Some(_), Some(_), false) => {
475 warnings.push(
476 "signature present but sbom validation failed; skipping verification"
477 .into(),
478 );
479 false
480 }
481 (Some(_), Some(_), None, _) => {
482 warnings.push(format!(
483 "signature present but {} missing; skipping verification",
484 sbom_name
485 ));
486 false
487 }
488 (None, None, _, _) => {
489 warnings.push("signature files missing; skipping verification".into());
490 false
491 }
492 _ => {
493 warnings.push("signature files incomplete; skipping verification".into());
494 false
495 }
496 };
497
498 Ok(PackLoad {
499 manifest: convert_gpack_manifest(&manifest, &files),
500 report: VerifyReport {
501 signature_ok,
502 sbom_ok,
503 warnings,
504 },
505 sbom,
506 files,
507 gpack_manifest: Some(manifest),
508 })
509 }
510 }
511}
512
513#[derive(Deserialize)]
514struct SbomDocument {
515 format: String,
516 files: Vec<SbomEntry>,
517}
518
519fn verify_sbom(
520 files: &HashMap<String, Vec<u8>>,
521 entries: &[SbomEntry],
522 sbom_name: &str,
523) -> Result<()> {
524 let mut listed = HashSet::new();
525 for entry in entries {
526 let data = files
527 .get(&entry.path)
528 .ok_or_else(|| anyhow!("sbom references missing file `{}`", entry.path))?;
529 let actual = hex_hash(data);
530 if !actual.eq_ignore_ascii_case(&entry.hash_blake3) {
531 bail!(
532 "hash mismatch for {}: expected {}, found {}",
533 entry.path,
534 entry.hash_blake3,
535 actual
536 );
537 }
538 listed.insert(entry.path.clone());
539 }
540
541 for path in files.keys() {
542 if path == SIGNATURE_PATH
543 || path == SIGNATURE_CHAIN_PATH
544 || path == "sbom.json"
545 || path == "sbom.cbor"
546 {
547 continue;
548 }
549 if !listed.contains(path) {
550 bail!("file `{}` missing from {}", path, sbom_name);
551 }
552 }
553
554 Ok(())
555}
556
557fn read_sbom_required(
558 files: &HashMap<String, Vec<u8>>,
559) -> Result<(SbomDocument, Vec<u8>, &'static str)> {
560 if let Some(sbom_bytes) = files.get("sbom.cbor") {
561 let sbom_doc: SbomDocument =
562 serde_cbor::from_slice(sbom_bytes).context("sbom.cbor is not valid CBOR")?;
563 return Ok((sbom_doc, sbom_bytes.clone(), "sbom.cbor"));
564 }
565 if let Some(sbom_bytes) = files.get("sbom.json") {
566 let sbom_doc: SbomDocument =
567 serde_json::from_slice(sbom_bytes).context("sbom.json is not valid JSON")?;
568 return Ok((sbom_doc, sbom_bytes.clone(), "sbom.json"));
569 }
570 Err(anyhow!("sbom.cbor missing from archive"))
571}
572
573fn read_sbom_optional(
574 files: &HashMap<String, Vec<u8>>,
575 warnings: &mut Vec<String>,
576) -> (Vec<SbomEntry>, bool, Option<Vec<u8>>, &'static str) {
577 if let Some(sbom_bytes) = files.get("sbom.cbor") {
578 match serde_cbor::from_slice::<SbomDocument>(sbom_bytes) {
579 Ok(sbom_doc) => {
580 let mut ok = sbom_doc.format == SBOM_FORMAT;
581 if !ok {
582 warnings.push(format!("unexpected SBOM format: {}", sbom_doc.format));
583 }
584 match verify_sbom(files, &sbom_doc.files, "sbom.cbor") {
585 Ok(()) => {}
586 Err(err) => {
587 warnings.push(err.to_string());
588 ok = false;
589 }
590 }
591 return (sbom_doc.files, ok, Some(sbom_bytes.clone()), "sbom.cbor");
592 }
593 Err(err) => {
594 warnings.push(format!("sbom.cbor is not valid CBOR: {err}"));
595 return (Vec::new(), false, Some(sbom_bytes.clone()), "sbom.cbor");
596 }
597 }
598 }
599 if let Some(sbom_bytes) = files.get("sbom.json") {
600 match serde_json::from_slice::<SbomDocument>(sbom_bytes) {
601 Ok(sbom_doc) => {
602 let mut ok = sbom_doc.format == SBOM_FORMAT;
603 if !ok {
604 warnings.push(format!("unexpected SBOM format: {}", sbom_doc.format));
605 }
606 match verify_sbom(files, &sbom_doc.files, "sbom.json") {
607 Ok(()) => {}
608 Err(err) => {
609 warnings.push(err.to_string());
610 ok = false;
611 }
612 }
613 return (sbom_doc.files, ok, Some(sbom_bytes.clone()), "sbom.json");
614 }
615 Err(err) => {
616 warnings.push(format!("sbom.json is not valid JSON: {err}"));
617 return (Vec::new(), false, Some(sbom_bytes.clone()), "sbom.json");
618 }
619 }
620 }
621 warnings.push("sbom.cbor missing; synthesized inventory for validation".into());
622 (synthesize_sbom(files), false, None, "sbom.cbor")
623}
624
625fn verify_signature(
626 files: &HashMap<String, Vec<u8>>,
627 manifest_bytes: &[u8],
628 sbom_bytes: &[u8],
629 entries: &[SbomEntry],
630 policy: SigningPolicy,
631 warnings: &mut Vec<String>,
632) -> Result<()> {
633 let signature_bytes = files
634 .get(SIGNATURE_PATH)
635 .ok_or_else(|| anyhow!("signature file `{}` missing", SIGNATURE_PATH))?;
636 let chain_bytes = files
637 .get(SIGNATURE_CHAIN_PATH)
638 .ok_or_else(|| anyhow!("certificate chain `{}` missing", SIGNATURE_CHAIN_PATH))?;
639
640 let envelope: SignatureEnvelope =
641 serde_json::from_slice(signature_bytes).context("signatures/pack.sig is not valid JSON")?;
642 let digest = signature_digest_from_entries(entries, manifest_bytes, sbom_bytes);
643 let digest_hex = digest.to_hex().to_string();
644 if !digest_hex.eq_ignore_ascii_case(&envelope.digest) {
645 bail!("signature digest mismatch");
646 }
647
648 match envelope.alg.to_ascii_lowercase().as_str() {
649 "ed25519" => verify_ed25519_signature(&envelope, digest, chain_bytes, policy, warnings)?,
650 other => bail!("unsupported signature algorithm: {}", other),
651 }
652
653 Ok(())
654}
655
656fn verify_ed25519_signature(
657 envelope: &SignatureEnvelope,
658 digest: blake3::Hash,
659 chain_bytes: &[u8],
660 policy: SigningPolicy,
661 warnings: &mut Vec<String>,
662) -> Result<()> {
663 let sig_raw = URL_SAFE_NO_PAD
664 .decode(envelope.sig.as_bytes())
665 .map_err(|err| anyhow!("invalid signature encoding: {err}"))?;
666 let sig_array: [u8; 64] = sig_raw
667 .as_slice()
668 .try_into()
669 .map_err(|_| anyhow!("signature must be 64 bytes"))?;
670 let signature = Signature::from_bytes(&sig_array);
671
672 let cert_der = parse_certificate_chain(chain_bytes)?;
673 enforce_policy(&cert_der, policy, warnings)?;
674 let first_cert = parse_certificate(&cert_der[0])?;
675 let verifying_key = extract_ed25519_key(&first_cert)?;
676 verifying_key
677 .verify(digest.as_bytes(), &signature)
678 .map_err(|err| anyhow!("signature verification failed: {err}"))?;
679 Ok(())
680}
681
682fn extract_ed25519_key(cert: &X509Certificate<'_>) -> Result<VerifyingKey> {
683 let spki = cert.public_key();
684 let key_bytes: &[u8] = spki.subject_public_key.data.as_ref();
685 if key_bytes.len() != 32 {
686 bail!(
687 "expected 32-byte Ed25519 public key, found {} bytes",
688 key_bytes.len()
689 );
690 }
691 let mut raw = [0u8; 32];
692 raw.copy_from_slice(key_bytes);
693 VerifyingKey::from_bytes(&raw).map_err(|err| anyhow!("invalid ed25519 key: {err}"))
694}
695
696fn parse_certificate(bytes: &[u8]) -> Result<X509Certificate<'_>> {
697 let (_, cert) =
698 X509Certificate::from_der(bytes).map_err(|err| anyhow!("invalid certificate: {err}"))?;
699 Ok(cert)
700}
701
702fn parse_certificate_chain(mut data: &[u8]) -> Result<Vec<Vec<u8>>> {
703 let mut certs = Vec::new();
704 loop {
705 data = trim_leading(data);
706 if data.is_empty() {
707 break;
708 }
709 let (rest, pem) = parse_x509_pem(data).map_err(|err| anyhow!("invalid PEM: {err}"))?;
710 if pem.label != "CERTIFICATE" {
711 bail!("unexpected PEM label {}; expected CERTIFICATE", pem.label);
712 }
713 certs.push(pem.contents.to_vec());
714 data = rest;
715 }
716
717 if certs.is_empty() {
718 bail!("certificate chain is empty");
719 }
720
721 Ok(certs)
722}
723
724fn enforce_policy(
725 certs: &[Vec<u8>],
726 policy: SigningPolicy,
727 warnings: &mut Vec<String>,
728) -> Result<()> {
729 let first = certs
730 .first()
731 .ok_or_else(|| anyhow!("certificate chain is empty"))?;
732 let first_cert = parse_certificate(first)?;
733 let is_dev = is_dev_certificate(&first_cert);
734
735 match policy {
736 SigningPolicy::DevOk => {
737 if certs.len() != 1 {
738 warnings.push(format!(
739 "chain contains {} certificates; dev mode expects exactly 1",
740 certs.len()
741 ));
742 }
743 }
744 SigningPolicy::Strict => {
745 if is_dev {
746 bail!("dev self-signed certificate is not allowed under strict policy");
747 }
748 }
749 }
750
751 Ok(())
752}
753
754fn is_dev_certificate(cert: &X509Certificate<'_>) -> bool {
755 let cn_matches = cert
756 .subject()
757 .iter_common_name()
758 .flat_map(|attr| attr.as_str())
759 .any(|cn| cn == "greentic-dev-local");
760 cn_matches && (cert.subject() == cert.issuer())
761}
762
763fn trim_leading(mut data: &[u8]) -> &[u8] {
764 while let Some((&byte, rest)) = data.split_first() {
765 if byte.is_ascii_whitespace() {
766 data = rest;
767 } else {
768 break;
769 }
770 }
771 data
772}
773
774fn read_archive_entries<R: Read + Seek>(
775 archive: &mut ZipArchive<R>,
776) -> Result<(HashMap<String, Vec<u8>>, u64)> {
777 let mut files = HashMap::new();
778 let mut total = 0u64;
779
780 for idx in 0..archive.len() {
781 let mut entry = archive
782 .by_index(idx)
783 .with_context(|| format!("failed to read entry #{idx}"))?;
784
785 if entry.is_dir() {
786 continue;
787 }
788 if !entry.is_file() {
789 bail!("archive entry {} is not a regular file", entry.name());
790 }
791
792 if let Some(mode) = entry.unix_mode() {
793 let file_type = mode & 0o170000;
794 if file_type != 0o100000 {
795 bail!(
796 "unsupported file type for entry {}; only regular files are allowed",
797 entry.name()
798 );
799 }
800 }
801
802 let enclosed_path = entry
803 .enclosed_name()
804 .ok_or_else(|| anyhow!("archive entry contains unsafe path: {}", entry.name()))?
805 .to_path_buf();
806 let logical = normalize_entry_path(&enclosed_path)?;
807 if files.contains_key(&logical) {
808 bail!("duplicate entry detected: {}", logical);
809 }
810
811 let size = entry.size();
812 if size > MAX_FILE_BYTES {
813 bail!(
814 "entry {} exceeds maximum allowed size of {} bytes",
815 logical,
816 MAX_FILE_BYTES
817 );
818 }
819
820 total = total
821 .checked_add(size)
822 .ok_or_else(|| anyhow!("archive size overflow"))?;
823
824 let mut buf = Vec::with_capacity(size as usize);
825 entry
826 .read_to_end(&mut buf)
827 .with_context(|| format!("failed to read {}", logical))?;
828 files.insert(logical, buf);
829 }
830
831 Ok((files, total))
832}
833
834fn normalize_entry_path(path: &Path) -> Result<String> {
835 if path.is_absolute() {
836 bail!("archive entry uses absolute path: {}", path.display());
837 }
838
839 if path.components().any(|comp| {
840 matches!(
841 comp,
842 std::path::Component::ParentDir | std::path::Component::RootDir
843 )
844 }) {
845 bail!(
846 "archive entry contains invalid path segments: {}",
847 path.display()
848 );
849 }
850
851 let mut normalized = Vec::new();
852 for comp in path.components() {
853 match comp {
854 std::path::Component::Normal(seg) => {
855 let segment = seg
856 .to_str()
857 .ok_or_else(|| anyhow!("entry contains non-utf8 segment"))?;
858 if segment.is_empty() {
859 bail!("entry contains empty path segment");
860 }
861 normalized.push(segment.replace('\\', "/"));
862 }
863 std::path::Component::CurDir => continue,
864 _ => bail!(
865 "archive entry contains unsupported segment: {}",
866 path.display()
867 ),
868 }
869 }
870
871 if normalized.is_empty() {
872 bail!("archive entry lacks a valid filename");
873 }
874
875 Ok(normalized.join("/"))
876}
877
878#[derive(Debug)]
879enum ManifestModel {
880 Pack(Box<PackManifest>),
881 Gpack(Box<GpackManifest>),
882}
883
884fn decode_manifest(bytes: &[u8]) -> Result<ManifestModel> {
885 if let Ok(manifest) = serde_cbor::from_slice::<PackManifest>(bytes) {
886 return Ok(ManifestModel::Pack(Box::new(manifest)));
887 }
888
889 let manifest = decode_pack_manifest(bytes)?;
890 Ok(ManifestModel::Gpack(Box::new(manifest)))
891}
892
893fn synthesize_sbom(files: &HashMap<String, Vec<u8>>) -> Vec<SbomEntry> {
894 let mut entries: Vec<_> = files
895 .iter()
896 .filter(|(path, _)| *path != SIGNATURE_PATH && *path != SIGNATURE_CHAIN_PATH)
897 .map(|(path, data)| SbomEntry {
898 path: path.clone(),
899 size: data.len() as u64,
900 hash_blake3: hex_hash(data),
901 media_type: media_type_for(path).to_string(),
902 })
903 .collect();
904 entries.sort_by(|a, b| a.path.cmp(&b.path));
905 entries
906}
907
908fn media_type_for(path: &str) -> &'static str {
909 if path.ends_with(".cbor") {
910 "application/cbor"
911 } else if path.ends_with(".json") {
912 "application/json"
913 } else if path.ends_with(".wasm") {
914 "application/wasm"
915 } else if path.ends_with(".yaml") || path.ends_with(".yml") {
916 "application/yaml"
917 } else {
918 "application/octet-stream"
919 }
920}
921
922fn sha256_prefixed(bytes: &[u8]) -> String {
923 let mut sha = Sha256::new();
924 sha.update(bytes);
925 format!("sha256:{:x}", sha.finalize())
926}
927
928fn convert_gpack_manifest(
929 manifest: &GpackManifest,
930 files: &HashMap<String, Vec<u8>>,
931) -> PackManifest {
932 let publisher = manifest.publisher.clone();
933 let entry_flows = derive_entry_flows(manifest);
934 let imports = manifest
935 .dependencies
936 .iter()
937 .map(|dep| ImportRef {
938 pack_id: dep.pack_id.to_string(),
939 version_req: dep.version_req.to_string(),
940 })
941 .collect();
942 let flows = manifest.flows.iter().map(convert_gpack_flow).collect();
943 let components = manifest
944 .components
945 .iter()
946 .map(|component| {
947 let file_wasm = format!("components/{}.wasm", component.id);
948 ComponentEntry {
949 name: component.id.to_string(),
950 version: component.version.clone(),
951 file_wasm: file_wasm.clone(),
952 hash_blake3: component_hash(&file_wasm, files),
953 schema_file: None,
954 manifest_file: None,
955 world: Some(component.world.clone()),
956 capabilities: serde_json::to_value(&component.capabilities).ok(),
957 }
958 })
959 .collect();
960
961 PackManifest {
962 meta: PackMeta {
963 pack_version: crate::builder::PACK_VERSION,
964 pack_id: manifest.pack_id.to_string(),
965 version: manifest.version.clone(),
966 name: manifest.pack_id.to_string(),
967 kind: None,
968 description: None,
969 authors: if publisher.is_empty() {
970 Vec::new()
971 } else {
972 vec![publisher]
973 },
974 license: None,
975 homepage: None,
976 support: None,
977 vendor: None,
978 imports,
979 entry_flows,
980 created_at_utc: "1970-01-01T00:00:00Z".into(),
981 events: None,
982 repo: None,
983 messaging: None,
984 interfaces: Vec::new(),
985 annotations: Default::default(),
986 distribution: None,
987 components: Vec::new(),
988 },
989 flows,
990 components,
991 distribution: None,
992 component_descriptors: Vec::new(),
993 }
994}
995
996fn convert_gpack_flow(entry: &greentic_types::pack_manifest::PackFlowEntry) -> FlowEntry {
997 let flow_bytes = serde_json::to_vec(&entry.flow).unwrap_or_default();
998 let entry_point = entry
999 .entrypoints
1000 .first()
1001 .cloned()
1002 .or_else(|| entry.flow.entrypoints.keys().next().cloned())
1003 .unwrap_or_else(|| entry.id.to_string());
1004
1005 FlowEntry {
1006 id: entry.id.to_string(),
1007 kind: entry.flow.schema_version.clone(),
1008 entry: entry_point,
1009 file_yaml: format!("flows/{}/flow.ygtc", entry.id),
1010 file_json: format!("flows/{}/flow.json", entry.id),
1011 hash_blake3: hex_hash(&flow_bytes),
1012 }
1013}
1014
1015fn derive_entry_flows(manifest: &GpackManifest) -> Vec<String> {
1016 let mut entries = Vec::new();
1017 for flow in &manifest.flows {
1018 if flow.entrypoints.is_empty() && flow.flow.entrypoints.is_empty() {
1019 entries.push(flow.id.to_string());
1020 continue;
1021 }
1022 entries.extend(flow.entrypoints.iter().cloned());
1023 entries.extend(flow.flow.entrypoints.keys().cloned());
1024 }
1025 if entries.is_empty() {
1026 entries.push(manifest.pack_id.to_string());
1027 }
1028 entries.sort();
1029 entries.dedup();
1030 entries
1031}
1032
1033fn component_hash(path: &str, files: &HashMap<String, Vec<u8>>) -> String {
1034 files
1035 .get(path)
1036 .map(|bytes| hex_hash(bytes))
1037 .unwrap_or_default()
1038}
1039
1040#[cfg(test)]
1041mod tests {
1042 use super::{MAX_ARCHIVE_BYTES, MAX_FILE_BYTES, SigningPolicy, open_pack};
1043 use crate::builder::SIGNATURE_CHAIN_PATH;
1044 use crate::builder::{
1045 ComponentArtifact, FlowBundle, PackBuilder, PackMeta, Provenance, Signing,
1046 };
1047 use blake3;
1048 use semver::Version;
1049 use serde_json::{Map, json};
1050 use std::fs::{self, File};
1051 use std::io::{Read, Write};
1052 use std::path::{Path, PathBuf};
1053 use tempfile::{TempDir, tempdir};
1054 use zip::write::SimpleFileOptions;
1055 use zip::{CompressionMethod, ZipArchive, ZipWriter};
1056
1057 #[test]
1058 fn open_pack_succeeds_for_dev_signature() {
1059 let (_dir, path) = build_pack(true);
1060 let load = open_pack(&path, SigningPolicy::DevOk).expect("reader validates pack");
1061 assert_eq!(load.manifest.meta.pack_id, "ai.greentic.demo.reader");
1062 assert!(load.report.warnings.is_empty());
1063 }
1064
1065 #[test]
1066 fn open_pack_warns_missing_signature_dev_policy() {
1067 let (_dir, path) = build_pack(false);
1068 let load = open_pack(&path, SigningPolicy::DevOk).expect("dev policy tolerates");
1069 assert!(
1070 load.report
1071 .warnings
1072 .iter()
1073 .any(|w| w.contains("signature files missing")),
1074 "expected warning about missing signatures"
1075 );
1076 }
1077
1078 #[test]
1079 fn strict_policy_rejects_dev_certificate() {
1080 let (_dir, path) = build_pack(true);
1081 let err = open_pack(&path, SigningPolicy::Strict).unwrap_err();
1082 assert!(err.message.contains("strict"));
1083 }
1084
1085 #[test]
1086 fn dev_policy_warns_for_multi_certificate_chain() {
1087 let (_dir, original) = build_pack(true);
1088 let (_tmp, rewritten) = duplicate_chain(&original);
1089 let load = open_pack(&rewritten, SigningPolicy::DevOk).expect("dev policy accepts");
1090 assert!(load.report.warnings.iter().any(|msg| msg.contains("chain")));
1091 }
1092
1093 #[test]
1094 fn path_traversal_entry_is_rejected() {
1095 let (_dir, path) = custom_zip(&[zip_entry("../evil", b"oops")]);
1096 let err = open_pack(&path, SigningPolicy::DevOk).unwrap_err();
1097 assert!(err.message.contains("unsafe path") || err.message.contains("invalid path"));
1098 }
1099
1100 #[test]
1101 fn symlink_entry_is_rejected() {
1102 let (dir, path) = custom_zip(&[zip_entry("foo", b"bar")]);
1103 patch_external_attributes(&path, 0o120777 << 16);
1104 let err = open_pack(&path, SigningPolicy::DevOk).unwrap_err();
1105 assert!(
1106 err.message.contains("unsupported file type")
1107 || err.message.contains("not a regular file")
1108 );
1109 drop(dir);
1110 }
1111
1112 #[test]
1113 fn oversized_entry_is_rejected() {
1114 let huge = vec![0u8; (MAX_FILE_BYTES + 1) as usize];
1115 let (_dir, path) = custom_zip(&[zip_entry("huge.bin", &huge)]);
1116 let err = open_pack(&path, SigningPolicy::DevOk).unwrap_err();
1117 assert!(err.message.contains("exceeds maximum"));
1118 }
1119
1120 #[test]
1121 fn oversized_archive_is_rejected() {
1122 let chunk = vec![0u8; (MAX_FILE_BYTES / 2) as usize];
1123 let needed = (MAX_ARCHIVE_BYTES / chunk.len() as u64) + 1;
1124 let mut entries = Vec::new();
1125 for idx in 0..needed {
1126 let name = format!("chunk{idx}");
1127 entries.push((name, chunk.clone()));
1128 }
1129 let (_dir, path) = custom_zip(&entries);
1130 let err = open_pack(&path, SigningPolicy::DevOk).unwrap_err();
1131 assert!(err.message.contains("archive exceeds"));
1132 }
1133
1134 fn temp_wasm(dir: &Path) -> PathBuf {
1135 let path = dir.join("component.wasm");
1136 std::fs::write(&path, [0x00u8, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]).unwrap();
1137 path
1138 }
1139
1140 fn sample_meta() -> PackMeta {
1141 PackMeta {
1142 pack_version: crate::builder::PACK_VERSION,
1143 pack_id: "ai.greentic.demo.reader".into(),
1144 version: Version::parse("0.1.0").unwrap(),
1145 name: "Reader Demo".into(),
1146 kind: None,
1147 description: None,
1148 authors: vec!["Greentic".into()],
1149 license: None,
1150 homepage: None,
1151 support: None,
1152 vendor: None,
1153 imports: vec![],
1154 entry_flows: vec!["demo".into()],
1155 created_at_utc: "2025-01-01T00:00:00Z".into(),
1156 events: None,
1157 repo: None,
1158 messaging: None,
1159 interfaces: Vec::new(),
1160 annotations: Map::new(),
1161 distribution: None,
1162 components: Vec::new(),
1163 }
1164 }
1165
1166 fn sample_flow() -> FlowBundle {
1167 let json = json!({
1168 "id": "demo",
1169 "kind": "flow/v1",
1170 "entry": "start",
1171 "nodes": []
1172 });
1173 FlowBundle {
1174 id: "demo".into(),
1175 kind: "flow/v1".into(),
1176 entry: "start".into(),
1177 yaml: "id: demo\nentry: start\n".into(),
1178 json: json.clone(),
1179 hash_blake3: blake3::hash(&serde_json::to_vec(&json).unwrap())
1180 .to_hex()
1181 .to_string(),
1182 nodes: Vec::new(),
1183 }
1184 }
1185
1186 fn sample_provenance() -> Provenance {
1187 Provenance {
1188 builder: "greentic-pack@test".into(),
1189 git_commit: Some("abc123".into()),
1190 git_repo: None,
1191 toolchain: None,
1192 built_at_utc: "2025-01-01T00:00:00Z".into(),
1193 host: None,
1194 notes: None,
1195 }
1196 }
1197
1198 fn build_pack(include_signature: bool) -> (TempDir, PathBuf) {
1199 let dir = tempdir().unwrap();
1200 let wasm = temp_wasm(dir.path());
1201 let out = dir.path().join("demo.gtpack");
1202 let mut builder = PackBuilder::new(sample_meta())
1203 .with_flow(sample_flow())
1204 .with_component(ComponentArtifact {
1205 name: "demo".into(),
1206 version: Version::parse("1.0.0").unwrap(),
1207 wasm_path: wasm,
1208 schema_json: None,
1209 manifest_json: None,
1210 capabilities: None,
1211 world: None,
1212 hash_blake3: None,
1213 })
1214 .with_provenance(sample_provenance());
1215 if !include_signature {
1216 builder = builder.with_signing(Signing::None);
1217 }
1218 builder.build(&out).unwrap();
1219 (dir, out)
1220 }
1221
1222 fn custom_zip(entries: &[(String, Vec<u8>)]) -> (TempDir, PathBuf) {
1223 use zip::DateTime;
1224
1225 let dir = tempdir().unwrap();
1226 let path = dir.path().join("custom.gtpack");
1227 let file = File::create(&path).unwrap();
1228 let mut writer = ZipWriter::new(file);
1229 let timestamp = DateTime::from_date_and_time(1980, 1, 1, 0, 0, 0).unwrap();
1230 for (name, data) in entries.iter() {
1231 let options = SimpleFileOptions::default()
1232 .compression_method(CompressionMethod::Stored)
1233 .last_modified_time(timestamp)
1234 .unix_permissions(0o644);
1235 writer.start_file(name, options).unwrap();
1236 writer.write_all(data).unwrap();
1237 }
1238 writer.finish().unwrap();
1239 (dir, path)
1240 }
1241
1242 fn zip_entry(name: &str, data: &[u8]) -> (String, Vec<u8>) {
1243 (name.to_string(), data.to_vec())
1244 }
1245
1246 fn patch_external_attributes(path: &Path, attr: u32) {
1247 let mut bytes = fs::read(path).unwrap();
1248 let signature = [0x50, 0x4b, 0x01, 0x02];
1249 let pos = bytes
1250 .windows(4)
1251 .rposition(|window| window == signature)
1252 .expect("central directory missing");
1253 let attr_pos = pos + 38;
1254 bytes[attr_pos..attr_pos + 4].copy_from_slice(&attr.to_le_bytes());
1255 fs::write(path, bytes).unwrap();
1256 }
1257
1258 fn duplicate_chain(original: &Path) -> (TempDir, PathBuf) {
1259 use zip::DateTime;
1260
1261 let mut archive = ZipArchive::new(File::open(original).unwrap()).unwrap();
1262 let dir = tempdir().unwrap();
1263 let new_path = dir.path().join("rewritten.gtpack");
1264 let file = File::create(&new_path).unwrap();
1265 let mut writer = ZipWriter::new(file);
1266 let timestamp = DateTime::from_date_and_time(1980, 1, 1, 0, 0, 0).unwrap();
1267
1268 for i in 0..archive.len() {
1269 let mut entry = archive.by_index(i).unwrap();
1270 let mut data = Vec::new();
1271 entry.read_to_end(&mut data).unwrap();
1272 if entry.name() == SIGNATURE_CHAIN_PATH {
1273 let original = data.clone();
1274 data.push(b'\n');
1275 data.extend_from_slice(&original);
1276 }
1277 let options = SimpleFileOptions::default()
1278 .compression_method(CompressionMethod::Stored)
1279 .last_modified_time(timestamp)
1280 .unix_permissions(0o644);
1281 writer.start_file(entry.name(), options).unwrap();
1282 writer.write_all(&data).unwrap();
1283 }
1284
1285 writer.finish().unwrap();
1286 (dir, new_path)
1287 }
1288}