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