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