Skip to main content

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_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}