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}