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