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