1use std::collections::BTreeSet;
2use std::fs;
3use std::io::Write;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use anyhow::{Context, Result, anyhow, bail};
8use base64::Engine;
9use base64::engine::general_purpose::URL_SAFE_NO_PAD;
10use blake3::Hasher;
11use ed25519_dalek::Signer as _;
12use ed25519_dalek::SigningKey;
13use getrandom::fill as fill_random;
14use greentic_types::cbor::canonical;
15use pkcs8::EncodePrivateKey;
16use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair, PKCS_ED25519};
17use rustls_pki_types::PrivatePkcs8KeyDer;
18use schemars::JsonSchema;
19use semver::Version;
20use serde::{Deserialize, Serialize};
21use serde_json::{Map as JsonMap, Value as JsonValue};
22use time::OffsetDateTime;
23use time::format_description::well_known::Rfc3339;
24use zip::write::SimpleFileOptions;
25use zip::{CompressionMethod, DateTime as ZipDateTime, ZipWriter};
26
27use crate::events::EventsSection;
28use crate::kind::PackKind;
29use crate::messaging::MessagingSection;
30use crate::repo::{InterfaceBinding, RepoPackSection};
31
32pub(crate) const SBOM_FORMAT: &str = "greentic-sbom-v1";
33pub(crate) const SIGNATURE_PATH: &str = "signatures/pack.sig";
34pub(crate) const SIGNATURE_CHAIN_PATH: &str = "signatures/chain.pem";
35pub const PACK_VERSION: u32 = 1;
36
37fn default_pack_version() -> u32 {
38 PACK_VERSION
39}
40
41#[derive(Clone, Debug, Serialize, Deserialize)]
42pub struct PackMeta {
43 #[serde(rename = "packVersion", default = "default_pack_version")]
44 pub pack_version: u32,
45 pub pack_id: String,
46 pub version: Version,
47 pub name: String,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub kind: Option<PackKind>,
50 #[serde(default)]
51 pub description: Option<String>,
52 #[serde(default)]
53 pub authors: Vec<String>,
54 #[serde(default)]
55 pub license: Option<String>,
56 #[serde(default)]
57 pub homepage: Option<String>,
58 #[serde(default)]
59 pub support: Option<String>,
60 #[serde(default)]
61 pub vendor: Option<String>,
62 #[serde(default)]
63 pub imports: Vec<ImportRef>,
64 pub entry_flows: Vec<String>,
65 pub created_at_utc: String,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub events: Option<EventsSection>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub repo: Option<RepoPackSection>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub messaging: Option<MessagingSection>,
72 #[serde(default, skip_serializing_if = "Vec::is_empty")]
73 pub interfaces: Vec<InterfaceBinding>,
74 #[serde(default)]
75 pub annotations: JsonMap<String, JsonValue>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub distribution: Option<DistributionSection>,
78 #[serde(default)]
79 pub components: Vec<ComponentDescriptor>,
80}
81
82impl PackMeta {
83 fn validate(&self) -> Result<()> {
84 if self.pack_version != PACK_VERSION {
85 bail!(
86 "unsupported packVersion {}; expected {}",
87 self.pack_version,
88 PACK_VERSION
89 );
90 }
91 if self.pack_id.trim().is_empty() {
92 bail!("pack_id is required");
93 }
94 if self.name.trim().is_empty() {
95 bail!("name is required");
96 }
97 if self.entry_flows.is_empty() {
98 bail!("at least one entry flow is required");
99 }
100 if self.created_at_utc.trim().is_empty() {
101 bail!("created_at_utc is required");
102 }
103 if let Some(kind) = &self.kind {
104 kind.validate_allowed()?;
105 }
106 if let Some(events) = &self.events {
107 events.validate()?;
108 }
109 if let Some(repo) = &self.repo {
110 repo.validate()?;
111 }
112 if let Some(messaging) = &self.messaging {
113 messaging.validate()?;
114 }
115 for binding in &self.interfaces {
116 binding.validate("interfaces")?;
117 }
118 validate_distribution(self.kind.as_ref(), self.distribution.as_ref())?;
119 validate_components(&self.components)?;
120 Ok(())
121 }
122}
123
124pub use greentic_flow::flow_bundle::{ComponentPin, FlowBundle, NodeRef};
125
126#[derive(Clone, Debug, Serialize, Deserialize)]
127pub struct ImportRef {
128 pub pack_id: String,
129 pub version_req: String,
130}
131
132#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
133pub struct ComponentDescriptor {
134 pub component_id: String,
135 pub version: String,
136 pub digest: String,
137 pub artifact_path: String,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub kind: Option<String>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub artifact_type: Option<String>,
142 #[serde(default, skip_serializing_if = "Vec::is_empty")]
143 pub tags: Vec<String>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub platform: Option<String>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub entrypoint: Option<String>,
148}
149
150#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
151pub struct DistributionSection {
152 #[serde(default)]
153 pub bundle_id: Option<String>,
154 #[serde(default)]
155 pub tenant: JsonMap<String, JsonValue>,
156 pub environment_ref: String,
157 pub desired_state_version: String,
158 #[serde(default)]
159 pub components: Vec<ComponentDescriptor>,
160 #[serde(default)]
161 pub platform_components: Vec<ComponentDescriptor>,
162}
163
164#[derive(Clone, Debug)]
165pub struct ComponentArtifact {
166 pub name: String,
167 pub version: Version,
168 pub wasm_path: PathBuf,
169 pub schema_json: Option<String>,
170 pub manifest_json: Option<String>,
171 pub capabilities: Option<JsonValue>,
172 pub world: Option<String>,
173 pub hash_blake3: Option<String>,
174}
175
176#[derive(Clone, Debug, Serialize, Deserialize)]
177pub struct Provenance {
178 pub builder: String,
179 #[serde(default)]
180 pub git_commit: Option<String>,
181 #[serde(default)]
182 pub git_repo: Option<String>,
183 #[serde(default)]
184 pub toolchain: Option<String>,
185 pub built_at_utc: String,
186 #[serde(default)]
187 pub host: Option<String>,
188 #[serde(default)]
189 pub notes: Option<String>,
190}
191
192#[derive(Clone, Debug, Serialize, Deserialize)]
193pub struct ExternalSignature {
194 pub alg: String,
195 pub sig: Vec<u8>,
196}
197
198pub trait Signer: Send + Sync {
199 fn sign(&self, message: &[u8]) -> Result<ExternalSignature>;
200 fn chain_pem(&self) -> Result<Vec<u8>>;
201}
202
203type DynSigner = dyn Signer + Send + Sync + 'static;
204
205#[derive(Clone, Default)]
206pub enum Signing {
207 #[default]
208 Dev,
209 None,
210 External(Arc<DynSigner>),
211}
212
213pub struct PackBuilder {
214 meta: PackMeta,
215 flows: Vec<FlowBundle>,
216 components: Vec<ComponentArtifact>,
217 assets: Vec<Asset>,
218 signing: Signing,
219 provenance: Option<Provenance>,
220 component_descriptors: Vec<ComponentDescriptor>,
221 distribution: Option<DistributionSection>,
222}
223
224struct Asset {
225 path: String,
226 bytes: Vec<u8>,
227}
228
229#[derive(Debug, Clone)]
230pub struct BuildResult {
231 pub out_path: PathBuf,
232 pub manifest_hash_blake3: String,
233 pub files: Vec<SbomEntry>,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
237pub struct SbomEntry {
238 pub path: String,
239 pub size: u64,
240 pub hash_blake3: String,
241 pub media_type: String,
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct PackManifest {
246 pub meta: PackMeta,
247 pub flows: Vec<FlowEntry>,
248 pub components: Vec<ComponentEntry>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub distribution: Option<DistributionSection>,
251 #[serde(default, skip_serializing_if = "Vec::is_empty")]
252 pub component_descriptors: Vec<ComponentDescriptor>,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct FlowEntry {
257 pub id: String,
258 pub kind: String,
259 pub entry: String,
260 pub file_yaml: String,
261 pub file_json: String,
262 pub hash_blake3: String,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct ComponentEntry {
267 pub name: String,
268 pub version: Version,
269 pub file_wasm: String,
270 pub hash_blake3: String,
271 pub schema_file: Option<String>,
272 pub manifest_file: Option<String>,
273 pub world: Option<String>,
274 pub capabilities: Option<JsonValue>,
275}
276
277#[derive(Debug, Serialize, Deserialize)]
278pub(crate) struct SignatureEnvelope {
279 pub alg: String,
280 pub sig: String,
281 pub digest: String,
282 pub signed_at_utc: String,
283 #[serde(skip_serializing_if = "Option::is_none")]
284 pub key_fingerprint: Option<String>,
285}
286
287impl SignatureEnvelope {
288 fn new(
289 alg: impl Into<String>,
290 sig_bytes: &[u8],
291 digest: &blake3::Hash,
292 key_fingerprint: Option<String>,
293 ) -> Self {
294 let signed_at = OffsetDateTime::now_utc()
295 .format(&Rfc3339)
296 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
297 Self {
298 alg: alg.into(),
299 sig: URL_SAFE_NO_PAD.encode(sig_bytes),
300 digest: digest.to_hex().to_string(),
301 signed_at_utc: signed_at,
302 key_fingerprint,
303 }
304 }
305}
306
307struct PendingFile {
308 path: String,
309 media_type: String,
310 bytes: Vec<u8>,
311}
312
313impl PendingFile {
314 fn new(path: String, media_type: impl Into<String>, bytes: Vec<u8>) -> Self {
315 Self {
316 path,
317 media_type: media_type.into(),
318 bytes,
319 }
320 }
321
322 fn size(&self) -> u64 {
323 self.bytes.len() as u64
324 }
325
326 fn hash(&self) -> String {
327 hex_hash(&self.bytes)
328 }
329}
330
331impl PackBuilder {
332 pub fn new(meta: PackMeta) -> Self {
333 Self {
334 component_descriptors: meta.components.clone(),
335 distribution: meta.distribution.clone(),
336 meta,
337 flows: Vec::new(),
338 components: Vec::new(),
339 assets: Vec::new(),
340 signing: Signing::Dev,
341 provenance: None,
342 }
343 }
344
345 pub fn with_flow(mut self, flow: FlowBundle) -> Self {
346 self.flows.push(flow);
347 self
348 }
349
350 pub fn with_component(mut self, component: ComponentArtifact) -> Self {
351 self.components.push(component);
352 self
353 }
354
355 pub fn with_component_wasm(
356 self,
357 name: impl Into<String>,
358 version: Version,
359 wasm_path: impl Into<PathBuf>,
360 ) -> Self {
361 self.with_component(ComponentArtifact {
362 name: name.into(),
363 version,
364 wasm_path: wasm_path.into(),
365 schema_json: None,
366 manifest_json: None,
367 capabilities: None,
368 world: None,
369 hash_blake3: None,
370 })
371 }
372
373 pub fn with_asset_bytes(mut self, path_in_pack: impl Into<String>, bytes: Vec<u8>) -> Self {
374 self.assets.push(Asset {
375 path: path_in_pack.into(),
376 bytes,
377 });
378 self
379 }
380
381 pub fn with_signing(mut self, signing: Signing) -> Self {
382 self.signing = signing;
383 self
384 }
385
386 pub fn with_provenance(mut self, provenance: Provenance) -> Self {
387 self.provenance = Some(provenance);
388 self
389 }
390
391 pub fn with_component_descriptors(
392 mut self,
393 descriptors: impl IntoIterator<Item = ComponentDescriptor>,
394 ) -> Self {
395 self.component_descriptors.extend(descriptors);
396 self
397 }
398
399 pub fn with_distribution(mut self, distribution: DistributionSection) -> Self {
400 self.distribution = Some(distribution);
401 self
402 }
403
404 pub fn build(self, out_path: impl AsRef<Path>) -> Result<BuildResult> {
405 let meta = self.meta;
406 meta.validate()?;
407 let distribution = self.distribution.or_else(|| meta.distribution.clone());
408 let component_descriptors = if self.component_descriptors.is_empty() {
409 meta.components.clone()
410 } else {
411 self.component_descriptors.clone()
412 };
413
414 if self.flows.is_empty() {
415 bail!("at least one flow must be provided");
416 }
417
418 let mut flow_entries = Vec::new();
419 let mut pending_files: Vec<PendingFile> = Vec::new();
420 let mut seen_flow_ids = BTreeSet::new();
421
422 for flow in self.flows {
423 validate_identifier(&flow.id, "flow id")?;
424 if flow.entry.trim().is_empty() {
425 bail!("flow {} is missing an entry node", flow.id);
426 }
427 if !seen_flow_ids.insert(flow.id.clone()) {
428 bail!("duplicate flow id detected: {}", flow.id);
429 }
430
431 let yaml_path = normalize_relative_path(&["flows", &flow.id, "flow.ygtc"])?;
432 let yaml_bytes = normalize_newlines(&flow.yaml).into_bytes();
433 pending_files.push(PendingFile::new(
434 yaml_path.clone(),
435 "application/yaml",
436 yaml_bytes,
437 ));
438
439 let json_path = normalize_relative_path(&["flows", &flow.id, "flow.json"])?;
440 let json_bytes = serde_json::to_vec(&flow.json)?;
441 pending_files.push(PendingFile::new(
442 json_path.clone(),
443 "application/json",
444 json_bytes,
445 ));
446
447 flow_entries.push(FlowEntry {
448 id: flow.id,
449 kind: flow.kind,
450 entry: flow.entry,
451 file_yaml: yaml_path,
452 file_json: json_path,
453 hash_blake3: flow.hash_blake3,
454 });
455 }
456
457 for entry in &meta.entry_flows {
458 if !seen_flow_ids.contains(entry) {
459 bail!("entry flow `{}` not present in provided flows", entry);
460 }
461 }
462
463 flow_entries.sort_by(|a, b| a.id.cmp(&b.id));
464
465 let mut component_entries = Vec::new();
466 let mut seen_components = BTreeSet::new();
467
468 for component in self.components {
469 validate_identifier(&component.name, "component name")?;
470 let key = format!("{}@{}", component.name, component.version);
471 if !seen_components.insert(key.clone()) {
472 bail!("duplicate component artifact detected: {}", key);
473 }
474
475 let wasm_bytes = fs::read(&component.wasm_path).with_context(|| {
476 format!(
477 "failed to read component wasm at {}",
478 component.wasm_path.display()
479 )
480 })?;
481 let wasm_hash = hex_hash(&wasm_bytes);
482 if let Some(expected) = component.hash_blake3.as_deref()
483 && !equals_ignore_case(expected, &wasm_hash)
484 {
485 bail!(
486 "component {} hash mismatch: expected {}, got {}",
487 key,
488 expected,
489 wasm_hash
490 );
491 }
492
493 let wasm_path = normalize_relative_path(&["components", &key, "component.wasm"])?;
494 pending_files.push(PendingFile::new(
495 wasm_path.clone(),
496 "application/wasm",
497 wasm_bytes,
498 ));
499
500 let mut schema_file = None;
501 if let Some(schema) = component.schema_json.as_ref() {
502 let schema_path = normalize_relative_path(&["schemas", &key, "node.schema.json"])?;
503 pending_files.push(PendingFile::new(
504 schema_path.clone(),
505 "application/schema+json",
506 normalize_newlines(schema).into_bytes(),
507 ));
508 schema_file = Some(schema_path);
509 }
510
511 let mut manifest_file = None;
512 if let Some(manifest_json) = component.manifest_json.as_ref() {
513 let manifest_path =
514 normalize_relative_path(&["components", &key, "manifest.json"])?;
515 pending_files.push(PendingFile::new(
516 manifest_path.clone(),
517 "application/json",
518 normalize_newlines(manifest_json).into_bytes(),
519 ));
520 manifest_file = Some(manifest_path);
521 }
522
523 component_entries.push(ComponentEntry {
524 name: component.name,
525 version: component.version,
526 file_wasm: wasm_path,
527 hash_blake3: wasm_hash,
528 schema_file,
529 manifest_file,
530 world: component.world,
531 capabilities: component.capabilities,
532 });
533 }
534
535 component_entries
536 .sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.version.cmp(&b.version)));
537
538 for asset in self.assets {
539 let path = normalize_relative_path(&["assets", &asset.path])?;
540 pending_files.push(PendingFile::new(
541 path,
542 "application/octet-stream",
543 asset.bytes,
544 ));
545 }
546
547 let manifest_model = PackManifest {
548 meta: meta.clone(),
549 flows: flow_entries,
550 components: component_entries,
551 distribution,
552 component_descriptors,
553 };
554
555 let manifest_cbor = encode_manifest_cbor(&manifest_model)?;
556 let manifest_json = serde_json::to_vec_pretty(&manifest_model)?;
557
558 pending_files.push(PendingFile::new(
559 "manifest.cbor".to_string(),
560 "application/cbor",
561 manifest_cbor.clone(),
562 ));
563 pending_files.push(PendingFile::new(
564 "manifest.json".to_string(),
565 "application/json",
566 manifest_json,
567 ));
568
569 let provenance = finalize_provenance(self.provenance);
570 let provenance_json = serde_json::to_vec_pretty(&provenance)?;
571 pending_files.push(PendingFile::new(
572 "provenance.json".to_string(),
573 "application/json",
574 provenance_json,
575 ));
576
577 let mut sbom_entries = Vec::new();
578 for file in pending_files.iter() {
579 sbom_entries.push(SbomEntry {
580 path: file.path.clone(),
581 size: file.size(),
582 hash_blake3: file.hash(),
583 media_type: file.media_type.clone(),
584 });
585 }
586 let build_files = sbom_entries.clone();
587
588 let sbom_document = serde_json::json!({
589 "format": SBOM_FORMAT,
590 "files": sbom_entries,
591 });
592 let sbom_bytes = serde_json::to_vec_pretty(&sbom_document)?;
593 pending_files.push(PendingFile::new(
594 "sbom.json".to_string(),
595 "application/json",
596 sbom_bytes.clone(),
597 ));
598
599 let manifest_hash = hex_hash(&manifest_cbor);
600
601 let mut signature_files = Vec::new();
602 if !matches!(self.signing, Signing::None) {
603 let digest = signature_digest_from_entries(&build_files, &manifest_cbor, &sbom_bytes);
604 let (signature_doc, chain_bytes) = match &self.signing {
605 Signing::Dev => dev_signature(&digest)?,
606 Signing::None => unreachable!(),
607 Signing::External(signer) => external_signature(&**signer, &digest)?,
608 };
609
610 let sig_bytes = serde_json::to_vec_pretty(&signature_doc)?;
611 signature_files.push(PendingFile::new(
612 SIGNATURE_PATH.to_string(),
613 "application/json",
614 sig_bytes,
615 ));
616
617 if let Some(chain) = chain_bytes {
618 signature_files.push(PendingFile::new(
619 SIGNATURE_CHAIN_PATH.to_string(),
620 "application/x-pem-file",
621 chain,
622 ));
623 }
624 }
625
626 let mut all_files = pending_files;
627 all_files.extend(signature_files);
628 all_files.sort_by(|a, b| a.path.cmp(&b.path));
629
630 let out_path = out_path.as_ref().to_path_buf();
631 if let Some(parent) = out_path.parent() {
632 fs::create_dir_all(parent)
633 .with_context(|| format!("failed to create directory {}", parent.display()))?;
634 }
635
636 write_zip(&out_path, &all_files)?;
637
638 Ok(BuildResult {
639 out_path,
640 manifest_hash_blake3: manifest_hash,
641 files: build_files,
642 })
643 }
644}
645
646fn equals_ignore_case(expected: &str, actual: &str) -> bool {
647 expected.trim().eq_ignore_ascii_case(actual.trim())
648}
649
650fn normalize_newlines(input: &str) -> String {
651 input.replace("\r\n", "\n")
652}
653
654fn normalize_relative_path(parts: &[&str]) -> Result<String> {
655 let mut segments = Vec::new();
656 for part in parts {
657 let normalized = part.replace('\\', "/");
658 for piece in normalized.split('/') {
659 if piece.is_empty() {
660 bail!("invalid path segment");
661 }
662 if piece == "." || piece == ".." {
663 bail!("path traversal is not permitted");
664 }
665 segments.push(piece.to_string());
666 }
667 }
668 Ok(segments.join("/"))
669}
670
671fn validate_identifier(value: &str, label: &str) -> Result<()> {
672 if value.trim().is_empty() {
673 bail!("{} must not be empty", label);
674 }
675 if value.contains("..") {
676 bail!("{} must not contain '..'", label);
677 }
678 Ok(())
679}
680
681fn encode_manifest_cbor(manifest: &PackManifest) -> Result<Vec<u8>> {
682 canonical::to_canonical_cbor_allow_floats(manifest).map_err(Into::into)
683}
684
685fn validate_digest(digest: &str) -> Result<()> {
686 if digest.trim().is_empty() {
687 bail!("component digest must not be empty");
688 }
689 if !digest.starts_with("sha256:") {
690 bail!("component digest must start with sha256:");
691 }
692 Ok(())
693}
694
695fn validate_component_descriptor(component: &ComponentDescriptor) -> Result<()> {
696 if component.component_id.trim().is_empty() {
697 bail!("component_id must not be empty");
698 }
699 if component.version.trim().is_empty() {
700 bail!("component version must not be empty");
701 }
702 if component.artifact_path.trim().is_empty() {
703 bail!("component artifact_path must not be empty");
704 }
705 validate_digest(&component.digest)?;
706 if let Some(kind) = &component.kind
707 && kind.trim().is_empty()
708 {
709 bail!("component kind must not be empty when provided");
710 }
711 if let Some(artifact_type) = &component.artifact_type
712 && artifact_type.trim().is_empty()
713 {
714 bail!("component artifact_type must not be empty when provided");
715 }
716 if let Some(platform) = &component.platform
717 && platform.trim().is_empty()
718 {
719 bail!("component platform must not be empty when provided");
720 }
721 if let Some(entrypoint) = &component.entrypoint
722 && entrypoint.trim().is_empty()
723 {
724 bail!("component entrypoint must not be empty when provided");
725 }
726 for tag in &component.tags {
727 if tag.trim().is_empty() {
728 bail!("component tags must not contain empty entries");
729 }
730 }
731 Ok(())
732}
733
734pub fn validate_components(components: &[ComponentDescriptor]) -> Result<()> {
735 let mut seen = BTreeSet::new();
736 for component in components {
737 validate_component_descriptor(component)?;
738 let key = (component.component_id.clone(), component.version.clone());
739 if !seen.insert(key) {
740 bail!("duplicate component entry detected");
741 }
742 }
743 Ok(())
744}
745
746pub fn validate_distribution(
747 kind: Option<&PackKind>,
748 distribution: Option<&DistributionSection>,
749) -> Result<()> {
750 match (kind, distribution) {
751 (Some(PackKind::DistributionBundle), Some(section)) => {
752 if let Some(bundle_id) = §ion.bundle_id
753 && bundle_id.trim().is_empty()
754 {
755 bail!("distribution.bundle_id must not be empty when provided");
756 }
757 if section.environment_ref.trim().is_empty() {
758 bail!("distribution.environment_ref must not be empty");
759 }
760 if section.desired_state_version.trim().is_empty() {
761 bail!("distribution.desired_state_version must not be empty");
762 }
763 validate_components(§ion.components)?;
764 validate_components(§ion.platform_components)?;
765 }
766 (Some(PackKind::DistributionBundle), None) => {
767 bail!("distribution section is required for kind distribution-bundle");
768 }
769 (_, Some(_)) => {
770 bail!("distribution section is only allowed when kind is distribution-bundle");
771 }
772 _ => {}
773 }
774 Ok(())
775}
776
777fn finalize_provenance(provenance: Option<Provenance>) -> Provenance {
778 let builder_default = format!("greentic-pack@{}", env!("CARGO_PKG_VERSION"));
779 let now = OffsetDateTime::now_utc()
780 .format(&Rfc3339)
781 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
782 match provenance {
783 Some(mut prov) => {
784 if prov.builder.trim().is_empty() {
785 prov.builder = builder_default;
786 }
787 if prov.built_at_utc.trim().is_empty() {
788 prov.built_at_utc = now;
789 }
790 prov
791 }
792 None => Provenance {
793 builder: builder_default,
794 git_commit: None,
795 git_repo: None,
796 toolchain: None,
797 built_at_utc: now,
798 host: None,
799 notes: None,
800 },
801 }
802}
803
804pub(crate) fn signature_digest_from_entries(
805 entries: &[SbomEntry],
806 manifest_cbor: &[u8],
807 sbom_bytes: &[u8],
808) -> blake3::Hash {
809 let mut hasher = Hasher::new();
810 hasher.update(manifest_cbor);
811 hasher.update(sbom_bytes);
812
813 let mut records: Vec<(String, String)> = entries
814 .iter()
815 .map(|entry| (entry.path.clone(), entry.hash_blake3.clone()))
816 .collect();
817 records.sort_by(|a, b| a.0.cmp(&b.0));
818
819 for (path, hash) in records {
820 hasher.update(path.as_bytes());
821 hasher.update(b"\n");
822 hasher.update(hash.as_bytes());
823 }
824
825 hasher.finalize()
826}
827
828fn dev_signature(digest: &blake3::Hash) -> Result<(SignatureEnvelope, Option<Vec<u8>>)> {
829 let mut secret = [0u8; 32];
830 fill_random(&mut secret).map_err(|err| anyhow!("failed to generate dev signing key: {err}"))?;
831 let signing_key = SigningKey::from_bytes(&secret);
832 let signature = signing_key.sign(digest.as_bytes());
833 let signature_bytes = signature.to_bytes();
834
835 let pkcs8_doc = signing_key
836 .to_pkcs8_der()
837 .map_err(|err| anyhow!("failed to encode dev keypair: {err}"))?;
838 let pkcs8_der = PrivatePkcs8KeyDer::from(pkcs8_doc.as_bytes().to_vec());
839 let key_pair = KeyPair::from_pkcs8_der_and_sign_algo(&pkcs8_der, &PKCS_ED25519)
840 .map_err(|err| anyhow!("failed to load dev keypair for certificate: {err}"))?;
841
842 let mut params = CertificateParams::new(Vec::<String>::new())?;
843 params.distinguished_name = DistinguishedName::new();
844 params
845 .distinguished_name
846 .push(DnType::CommonName, "greentic-dev-local");
847 let cert = params.self_signed(&key_pair)?;
848 let chain = normalize_newlines(&cert.pem()).into_bytes();
849 let fingerprint = hex_hash(signing_key.verifying_key().as_bytes());
850
851 let envelope = SignatureEnvelope::new("ed25519", &signature_bytes, digest, Some(fingerprint));
852 Ok((envelope, Some(chain)))
853}
854
855fn external_signature(
856 signer: &DynSigner,
857 digest: &blake3::Hash,
858) -> Result<(SignatureEnvelope, Option<Vec<u8>>)> {
859 let ExternalSignature { alg, sig } = signer.sign(digest.as_bytes())?;
860 let chain = signer.chain_pem()?;
861 let chain_bytes = if chain.is_empty() {
862 None
863 } else {
864 let chain_str = String::from_utf8(chain)?;
865 Some(normalize_newlines(&chain_str).into_bytes())
866 };
867 let envelope = SignatureEnvelope::new(alg, &sig, digest, None);
868 Ok((envelope, chain_bytes))
869}
870
871pub(crate) fn hex_hash(bytes: &[u8]) -> String {
872 blake3::hash(bytes).to_hex().to_string()
873}
874
875fn write_zip(out_path: &Path, files: &[PendingFile]) -> Result<()> {
876 let file = fs::File::create(out_path)
877 .with_context(|| format!("failed to create {}", out_path.display()))?;
878 let mut writer = ZipWriter::new(file);
879 let timestamp = zip_timestamp();
880
881 for entry in files {
882 let options = SimpleFileOptions::default()
883 .compression_method(CompressionMethod::Stored)
884 .last_modified_time(timestamp)
885 .unix_permissions(0o644)
886 .large_file(false);
887 writer
888 .start_file(&entry.path, options)
889 .with_context(|| format!("failed to add {} to archive", entry.path))?;
890 writer
891 .write_all(&entry.bytes)
892 .with_context(|| format!("failed to write {}", entry.path))?;
893 }
894
895 writer.finish().context("failed to finish gtpack archive")?;
896 Ok(())
897}
898
899fn zip_timestamp() -> ZipDateTime {
900 ZipDateTime::from_date_and_time(1980, 1, 1, 0, 0, 0).unwrap_or_else(|_| ZipDateTime::default())
901}
902
903#[cfg(test)]
904mod tests {
905 use super::*;
906 use serde_json::json;
907 use tempfile::tempdir;
908 use zip::ZipArchive;
909
910 use std::fs::{self, File};
911 use std::io::Read;
912
913 #[test]
914 fn deterministic_build_without_signing() {
915 let temp = tempdir().unwrap();
916 let wasm_path = temp.path().join("component.wasm");
917 fs::write(&wasm_path, test_wasm_bytes()).unwrap();
918
919 let builder = || {
920 PackBuilder::new(sample_meta())
921 .with_flow(sample_flow())
922 .with_component(sample_component(&wasm_path))
923 .with_signing(Signing::None)
924 .with_provenance(sample_provenance())
925 };
926
927 let out_a = temp.path().join("a.gtpack");
928 let out_b = temp.path().join("b.gtpack");
929
930 builder().build(&out_a).unwrap();
931 builder().build(&out_b).unwrap();
932
933 let bytes_a = fs::read(&out_a).unwrap();
934 let bytes_b = fs::read(&out_b).unwrap();
935 assert_eq!(bytes_a, bytes_b, "gtpack output should be deterministic");
936
937 let result = PackBuilder::new(sample_meta())
938 .with_flow(sample_flow())
939 .with_component(sample_component(&wasm_path))
940 .with_signing(Signing::None)
941 .with_provenance(sample_provenance())
942 .build(temp.path().join("result.gtpack"))
943 .unwrap();
944
945 assert!(
946 result
947 .files
948 .iter()
949 .any(|entry| entry.path == "components/oauth@1.0.0/component.wasm")
950 );
951 }
952
953 #[test]
954 fn dev_signing_writes_signature_files() {
955 let temp = tempdir().unwrap();
956 let wasm_path = temp.path().join("component.wasm");
957 fs::write(&wasm_path, test_wasm_bytes()).unwrap();
958
959 let out_path = temp.path().join("signed.gtpack");
960 PackBuilder::new(sample_meta())
961 .with_flow(sample_flow())
962 .with_component(sample_component(&wasm_path))
963 .with_signing(Signing::Dev)
964 .with_provenance(sample_provenance())
965 .build(&out_path)
966 .unwrap();
967
968 let reader = File::open(&out_path).unwrap();
969 let mut archive = ZipArchive::new(reader).unwrap();
970 let mut signature_found = false;
971 let mut chain_found = false;
972
973 for i in 0..archive.len() {
974 let mut file = archive.by_index(i).unwrap();
975 match file.name() {
976 SIGNATURE_PATH => {
977 signature_found = true;
978 let mut contents = String::new();
979 file.read_to_string(&mut contents).unwrap();
980 assert!(contents.contains("\"alg\": \"ed25519\""));
981 }
982 SIGNATURE_CHAIN_PATH => {
983 chain_found = true;
984 }
985 _ => {}
986 }
987 }
988
989 assert!(signature_found, "signature should be present");
990 assert!(chain_found, "certificate chain should be present");
991 }
992
993 fn sample_meta() -> PackMeta {
994 PackMeta {
995 pack_version: PACK_VERSION,
996 pack_id: "ai.greentic.demo.test".to_string(),
997 version: Version::parse("0.1.0").unwrap(),
998 name: "Test Pack".to_string(),
999 kind: None,
1000 description: Some("integration test".to_string()),
1001 authors: vec!["Greentic".to_string()],
1002 license: Some("MIT".to_string()),
1003 homepage: None,
1004 support: None,
1005 vendor: None,
1006 imports: Vec::new(),
1007 entry_flows: vec!["main".to_string()],
1008 created_at_utc: "2025-01-01T00:00:00Z".to_string(),
1009 events: None,
1010 repo: None,
1011 messaging: None,
1012 interfaces: Vec::new(),
1013 annotations: JsonMap::new(),
1014 distribution: None,
1015 components: Vec::new(),
1016 }
1017 }
1018
1019 fn sample_flow() -> FlowBundle {
1020 let flow_json = json!({
1021 "id": "main",
1022 "kind": "flow/v1",
1023 "entry": "start",
1024 "nodes": []
1025 });
1026 let hash = blake3::hash(&serde_json::to_vec(&flow_json).unwrap())
1027 .to_hex()
1028 .to_string();
1029 FlowBundle {
1030 id: "main".to_string(),
1031 kind: "flow/v1".to_string(),
1032 entry: "start".to_string(),
1033 yaml: "id: main\nentry: start\n".to_string(),
1034 json: flow_json,
1035 hash_blake3: hash,
1036 nodes: Vec::new(),
1037 }
1038 }
1039
1040 fn sample_component(wasm_path: &Path) -> ComponentArtifact {
1041 ComponentArtifact {
1042 name: "oauth".to_string(),
1043 version: Version::parse("1.0.0").unwrap(),
1044 wasm_path: wasm_path.to_path_buf(),
1045 schema_json: None,
1046 manifest_json: None,
1047 capabilities: None,
1048 world: Some("component:tool".to_string()),
1049 hash_blake3: None,
1050 }
1051 }
1052
1053 fn test_wasm_bytes() -> Vec<u8> {
1054 vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]
1055 }
1056
1057 fn sample_provenance() -> Provenance {
1058 Provenance {
1059 builder: "greentic-pack@test".to_string(),
1060 git_commit: Some("abc123".to_string()),
1061 git_repo: Some("https://example.com/repo.git".to_string()),
1062 toolchain: Some("rustc 1.85.0".to_string()),
1063 built_at_utc: "2025-01-01T00:00:00Z".to_string(),
1064 host: Some("ci".to_string()),
1065 notes: None,
1066 }
1067 }
1068}