greentic_pack/
reader.rs

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}