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}
294
295#[derive(Clone)]
296struct Asset {
297 path: String,
298 bytes: Vec<u8>,
299}
300
301#[derive(Debug, Clone)]
302pub struct BuildResult {
303 pub out_path: PathBuf,
304 pub manifest_hash_blake3: String,
305 pub files: Vec<SbomEntry>,
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
309pub struct SbomEntry {
310 pub path: String,
311 pub size: u64,
312 pub hash_blake3: String,
313 pub media_type: String,
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize)]
317pub struct PackManifest {
318 pub meta: PackMeta,
319 pub flows: Vec<FlowEntry>,
320 pub components: Vec<ComponentEntry>,
321 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub distribution: Option<DistributionSection>,
323 #[serde(default, skip_serializing_if = "Vec::is_empty")]
324 pub component_descriptors: Vec<ComponentDescriptor>,
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct FlowEntry {
329 pub id: String,
330 pub kind: String,
331 pub entry: String,
332 pub file_yaml: String,
333 pub file_json: String,
334 pub hash_blake3: String,
335}
336
337#[derive(Debug, Clone, Serialize, Deserialize)]
338pub struct ComponentEntry {
339 pub name: String,
340 pub version: Version,
341 pub file_wasm: String,
342 pub hash_blake3: String,
343 pub schema_file: Option<String>,
344 pub manifest_file: Option<String>,
345 pub world: Option<String>,
346 pub capabilities: Option<JsonValue>,
347}
348
349#[derive(Debug, Serialize, Deserialize)]
350pub(crate) struct SignatureEnvelope {
351 pub alg: String,
352 pub sig: String,
353 pub digest: String,
354 pub signed_at_utc: String,
355 #[serde(skip_serializing_if = "Option::is_none")]
356 pub key_fingerprint: Option<String>,
357}
358
359impl SignatureEnvelope {
360 fn new(
361 alg: impl Into<String>,
362 sig_bytes: &[u8],
363 digest: &blake3::Hash,
364 key_fingerprint: Option<String>,
365 ) -> Self {
366 #[cfg(feature = "native")]
367 let signed_at = OffsetDateTime::now_utc()
368 .format(&Rfc3339)
369 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
370 #[cfg(not(feature = "native"))]
371 let signed_at = "1970-01-01T00:00:00Z".to_string();
372
373 Self {
374 alg: alg.into(),
375 sig: URL_SAFE_NO_PAD.encode(sig_bytes),
376 digest: digest.to_hex().to_string(),
377 signed_at_utc: signed_at,
378 key_fingerprint,
379 }
380 }
381}
382
383struct PendingFile {
384 path: String,
385 media_type: String,
386 bytes: Vec<u8>,
387}
388
389impl PendingFile {
390 fn new(path: String, media_type: impl Into<String>, bytes: Vec<u8>) -> Self {
391 Self {
392 path,
393 media_type: media_type.into(),
394 bytes,
395 }
396 }
397
398 fn size(&self) -> u64 {
399 self.bytes.len() as u64
400 }
401
402 fn hash(&self) -> String {
403 hex_hash(&self.bytes)
404 }
405}
406
407impl PackBuilder {
408 pub fn new(meta: PackMeta) -> Self {
409 Self {
410 component_descriptors: meta.components.clone(),
411 distribution: meta.distribution.clone(),
412 meta,
413 flows: Vec::new(),
414 components: Vec::new(),
415 assets: Vec::new(),
416 signing: Signing::default(),
417 provenance: None,
418 }
419 }
420
421 pub fn with_flow(mut self, flow: FlowBundle) -> Self {
422 self.flows.push(flow);
423 self
424 }
425
426 pub fn with_component(mut self, component: ComponentArtifact) -> Self {
427 self.components.push(component);
428 self
429 }
430
431 pub fn with_component_wasm(
432 self,
433 name: impl Into<String>,
434 version: Version,
435 wasm_path: impl Into<PathBuf>,
436 ) -> Self {
437 self.with_component(ComponentArtifact {
438 name: name.into(),
439 version,
440 wasm_path: wasm_path.into(),
441 schema_json: None,
442 manifest_json: None,
443 capabilities: None,
444 world: None,
445 hash_blake3: None,
446 })
447 }
448
449 pub fn with_asset_bytes(mut self, path_in_pack: impl Into<String>, bytes: Vec<u8>) -> Self {
450 self.assets.push(Asset {
451 path: path_in_pack.into(),
452 bytes,
453 });
454 self
455 }
456
457 pub fn with_signing(mut self, signing: Signing) -> Self {
458 self.signing = signing;
459 self
460 }
461
462 pub fn with_provenance(mut self, provenance: Provenance) -> Self {
463 self.provenance = Some(provenance);
464 self
465 }
466
467 pub fn with_component_descriptors(
468 mut self,
469 descriptors: impl IntoIterator<Item = ComponentDescriptor>,
470 ) -> Self {
471 self.component_descriptors.extend(descriptors);
472 self
473 }
474
475 pub fn with_distribution(mut self, distribution: DistributionSection) -> Self {
476 self.distribution = Some(distribution);
477 self
478 }
479
480 pub fn entries(&self) -> Result<std::collections::BTreeMap<String, Vec<u8>>> {
484 let meta = &self.meta;
485 meta.validate()?;
486 let distribution = self
487 .distribution
488 .clone()
489 .or_else(|| meta.distribution.clone());
490 let component_descriptors = if self.component_descriptors.is_empty() {
491 meta.components.clone()
492 } else {
493 self.component_descriptors.clone()
494 };
495
496 if self.flows.is_empty() {
497 bail!("at least one flow must be provided");
498 }
499
500 let mut flow_entries = Vec::new();
501 let mut pending_files: Vec<PendingFile> = Vec::new();
502 let mut seen_flow_ids = BTreeSet::new();
503
504 for flow in &self.flows {
505 validate_identifier(&flow.id, "flow id")?;
506 if flow.entry.trim().is_empty() {
507 bail!("flow {} is missing an entry node", flow.id);
508 }
509 if !seen_flow_ids.insert(flow.id.clone()) {
510 bail!("duplicate flow id detected: {}", flow.id);
511 }
512
513 let yaml_path = normalize_relative_path(&["flows", &flow.id, "flow.ygtc"])?;
514 let yaml_bytes = normalize_newlines(&flow.yaml).into_bytes();
515 pending_files.push(PendingFile::new(
516 yaml_path.clone(),
517 "application/yaml",
518 yaml_bytes,
519 ));
520
521 let json_path = normalize_relative_path(&["flows", &flow.id, "flow.json"])?;
522 let json_bytes = serde_json::to_vec(&flow.json)?;
523 pending_files.push(PendingFile::new(
524 json_path.clone(),
525 "application/json",
526 json_bytes,
527 ));
528
529 flow_entries.push(FlowEntry {
530 id: flow.id.clone(),
531 kind: flow.kind.clone(),
532 entry: flow.entry.clone(),
533 file_yaml: yaml_path,
534 file_json: json_path,
535 hash_blake3: flow.hash_blake3.clone(),
536 });
537 }
538
539 for entry in &meta.entry_flows {
540 if !seen_flow_ids.contains(entry) {
541 bail!("entry flow `{}` not present in provided flows", entry);
542 }
543 }
544
545 flow_entries.sort_by(|a, b| a.id.cmp(&b.id));
546
547 let mut component_entries: Vec<ComponentEntry> = Vec::new();
548 #[cfg(feature = "native")]
549 let mut seen_components: BTreeSet<String> = BTreeSet::new();
550
551 #[cfg(feature = "native")]
552 for component in &self.components {
553 validate_identifier(&component.name, "component name")?;
554 let key = format!("{}@{}", component.name, component.version);
555 if !seen_components.insert(key.clone()) {
556 bail!("duplicate component artifact detected: {}", key);
557 }
558
559 let wasm_bytes = fs::read(&component.wasm_path).with_context(|| {
560 format!(
561 "failed to read component wasm at {}",
562 component.wasm_path.display()
563 )
564 })?;
565 let wasm_hash = hex_hash(&wasm_bytes);
566 if let Some(expected) = component.hash_blake3.as_deref()
567 && !equals_ignore_case(expected, &wasm_hash)
568 {
569 bail!(
570 "component {} hash mismatch: expected {}, got {}",
571 key,
572 expected,
573 wasm_hash
574 );
575 }
576
577 let wasm_path = normalize_relative_path(&["components", &key, "component.wasm"])?;
578 pending_files.push(PendingFile::new(
579 wasm_path.clone(),
580 "application/wasm",
581 wasm_bytes,
582 ));
583
584 let mut schema_file = None;
585 if let Some(schema) = component.schema_json.as_ref() {
586 let schema_path = normalize_relative_path(&["schemas", &key, "node.schema.json"])?;
587 pending_files.push(PendingFile::new(
588 schema_path.clone(),
589 "application/schema+json",
590 normalize_newlines(schema).into_bytes(),
591 ));
592 schema_file = Some(schema_path);
593 }
594
595 let mut manifest_file = None;
596 if let Some(manifest_json) = component.manifest_json.as_ref() {
597 let manifest_path =
598 normalize_relative_path(&["components", &key, "manifest.json"])?;
599 pending_files.push(PendingFile::new(
600 manifest_path.clone(),
601 "application/json",
602 normalize_newlines(manifest_json).into_bytes(),
603 ));
604 manifest_file = Some(manifest_path);
605 }
606
607 component_entries.push(ComponentEntry {
608 name: component.name.clone(),
609 version: component.version.clone(),
610 file_wasm: wasm_path,
611 hash_blake3: wasm_hash,
612 schema_file,
613 manifest_file,
614 world: component.world.clone(),
615 capabilities: component.capabilities.clone(),
616 });
617 }
618 #[cfg(not(feature = "native"))]
619 if !self.components.is_empty() {
620 bail!("component wasm loading requires the `native` feature");
621 }
622
623 component_entries
624 .sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.version.cmp(&b.version)));
625
626 for asset in &self.assets {
627 let path = normalize_relative_path(&["assets", &asset.path])?;
628 pending_files.push(PendingFile::new(
629 path,
630 "application/octet-stream",
631 asset.bytes.clone(),
632 ));
633 }
634
635 let manifest_model = PackManifest {
636 meta: meta.clone(),
637 flows: flow_entries,
638 components: component_entries,
639 distribution,
640 component_descriptors,
641 };
642
643 let manifest_cbor = encode_manifest_cbor(&manifest_model)?;
644 let manifest_json = serde_json::to_vec_pretty(&manifest_model)?;
645
646 pending_files.push(PendingFile::new(
647 "manifest.cbor".to_string(),
648 "application/cbor",
649 manifest_cbor.clone(),
650 ));
651 pending_files.push(PendingFile::new(
652 "manifest.json".to_string(),
653 "application/json",
654 manifest_json,
655 ));
656
657 let provenance = finalize_provenance(self.provenance.clone());
658 let provenance_json = serde_json::to_vec_pretty(&provenance)?;
659 pending_files.push(PendingFile::new(
660 "provenance.json".to_string(),
661 "application/json",
662 provenance_json,
663 ));
664
665 let mut sbom_entries = Vec::new();
666 for file in pending_files.iter() {
667 sbom_entries.push(SbomEntry {
668 path: file.path.clone(),
669 size: file.size(),
670 hash_blake3: file.hash(),
671 media_type: file.media_type.clone(),
672 });
673 }
674
675 let sbom_document = serde_json::json!({
676 "format": SBOM_FORMAT,
677 "files": sbom_entries,
678 });
679 let sbom_bytes = serde_json::to_vec_pretty(&sbom_document)?;
680 pending_files.push(PendingFile::new(
681 "sbom.json".to_string(),
682 "application/json",
683 sbom_bytes.clone(),
684 ));
685
686 let mut signature_files = Vec::new();
687 if !matches!(self.signing, Signing::None) {
688 let digest = signature_digest_from_entries(&sbom_entries, &manifest_cbor, &sbom_bytes);
689 let (signature_doc, chain_bytes) = match &self.signing {
690 #[cfg(feature = "native")]
691 Signing::Dev => dev_signature(&digest)?,
692 Signing::None => bail!("internal: signing block entered with Signing::None"),
693 Signing::External(signer) => external_signature(&**signer, &digest)?,
694 };
695
696 let sig_bytes = serde_json::to_vec_pretty(&signature_doc)?;
697 signature_files.push(PendingFile::new(
698 SIGNATURE_PATH.to_string(),
699 "application/json",
700 sig_bytes,
701 ));
702
703 if let Some(chain) = chain_bytes {
704 signature_files.push(PendingFile::new(
705 SIGNATURE_CHAIN_PATH.to_string(),
706 "application/x-pem-file",
707 chain,
708 ));
709 }
710 }
711
712 let mut all_files = pending_files;
713 all_files.extend(signature_files);
714
715 let mut map = std::collections::BTreeMap::new();
717 for f in all_files {
718 map.insert(f.path, f.bytes);
719 }
720 Ok(map)
721 }
722
723 #[cfg(feature = "native")]
724 pub fn build(self, out_path: impl AsRef<Path>) -> Result<BuildResult> {
725 let entries = self.entries()?;
726
727 let excluded = ["sbom.json", SIGNATURE_PATH, SIGNATURE_CHAIN_PATH];
730 let build_files: Vec<SbomEntry> = entries
731 .iter()
732 .filter(|(path, _)| !excluded.contains(&path.as_str()))
733 .map(|(path, bytes)| SbomEntry {
734 path: path.clone(),
735 size: bytes.len() as u64,
736 hash_blake3: hex_hash(bytes),
737 media_type: media_type_for(path).to_string(),
738 })
739 .collect();
740
741 let manifest_hash = entries
742 .get("manifest.cbor")
743 .map(|b| hex_hash(b))
744 .ok_or_else(|| anyhow!("manifest.cbor missing from PackBuilder::entries()"))?;
745
746 let pending: Vec<PendingFile> = entries
747 .into_iter()
748 .map(|(path, bytes)| {
749 let mt = media_type_for(&path);
750 PendingFile::new(path, mt, bytes)
751 })
752 .collect();
753
754 let out_path = out_path.as_ref().to_path_buf();
755 if let Some(parent) = out_path.parent() {
756 fs::create_dir_all(parent)
757 .with_context(|| format!("failed to create directory {}", parent.display()))?;
758 }
759
760 write_zip(&out_path, &pending)?;
761
762 Ok(BuildResult {
763 out_path,
764 manifest_hash_blake3: manifest_hash,
765 files: build_files,
766 })
767 }
768}
769
770#[cfg(feature = "native")]
771fn equals_ignore_case(expected: &str, actual: &str) -> bool {
772 expected.trim().eq_ignore_ascii_case(actual.trim())
773}
774
775fn normalize_newlines(input: &str) -> String {
776 input.replace("\r\n", "\n")
777}
778
779fn normalize_relative_path(parts: &[&str]) -> Result<String> {
780 let mut segments = Vec::new();
781 for part in parts {
782 let normalized = part.replace('\\', "/");
783 for piece in normalized.split('/') {
784 if piece.is_empty() {
785 bail!("invalid path segment");
786 }
787 if piece == "." || piece == ".." {
788 bail!("path traversal is not permitted");
789 }
790 segments.push(piece.to_string());
791 }
792 }
793 Ok(segments.join("/"))
794}
795
796fn validate_identifier(value: &str, label: &str) -> Result<()> {
797 if value.trim().is_empty() {
798 bail!("{} must not be empty", label);
799 }
800 if value.contains("..") {
801 bail!("{} must not contain '..'", label);
802 }
803 Ok(())
804}
805
806fn encode_manifest_cbor(manifest: &PackManifest) -> Result<Vec<u8>> {
807 canonical::to_canonical_cbor_allow_floats(manifest).map_err(Into::into)
808}
809
810fn validate_digest(digest: &str) -> Result<()> {
811 if digest.trim().is_empty() {
812 bail!("component digest must not be empty");
813 }
814 if !digest.starts_with("sha256:") {
815 bail!("component digest must start with sha256:");
816 }
817 Ok(())
818}
819
820fn validate_component_descriptor(component: &ComponentDescriptor) -> Result<()> {
821 if component.component_id.trim().is_empty() {
822 bail!("component_id must not be empty");
823 }
824 if component.version.trim().is_empty() {
825 bail!("component version must not be empty");
826 }
827 if component.artifact_path.trim().is_empty() {
828 bail!("component artifact_path must not be empty");
829 }
830 validate_digest(&component.digest)?;
831 if let Some(kind) = &component.kind
832 && kind.trim().is_empty()
833 {
834 bail!("component kind must not be empty when provided");
835 }
836 if let Some(artifact_type) = &component.artifact_type
837 && artifact_type.trim().is_empty()
838 {
839 bail!("component artifact_type must not be empty when provided");
840 }
841 if let Some(platform) = &component.platform
842 && platform.trim().is_empty()
843 {
844 bail!("component platform must not be empty when provided");
845 }
846 if let Some(entrypoint) = &component.entrypoint
847 && entrypoint.trim().is_empty()
848 {
849 bail!("component entrypoint must not be empty when provided");
850 }
851 for tag in &component.tags {
852 if tag.trim().is_empty() {
853 bail!("component tags must not contain empty entries");
854 }
855 }
856 Ok(())
857}
858
859pub fn validate_components(components: &[ComponentDescriptor]) -> Result<()> {
860 let mut seen = BTreeSet::new();
861 for component in components {
862 validate_component_descriptor(component)?;
863 let key = (component.component_id.clone(), component.version.clone());
864 if !seen.insert(key) {
865 bail!("duplicate component entry detected");
866 }
867 }
868 Ok(())
869}
870
871pub fn validate_distribution(
872 kind: Option<&PackKind>,
873 distribution: Option<&DistributionSection>,
874) -> Result<()> {
875 match (kind, distribution) {
876 (Some(PackKind::DistributionBundle), Some(section)) => {
877 if let Some(bundle_id) = §ion.bundle_id
878 && bundle_id.trim().is_empty()
879 {
880 bail!("distribution.bundle_id must not be empty when provided");
881 }
882 if section.environment_ref.trim().is_empty() {
883 bail!("distribution.environment_ref must not be empty");
884 }
885 if section.desired_state_version.trim().is_empty() {
886 bail!("distribution.desired_state_version must not be empty");
887 }
888 validate_components(§ion.components)?;
889 validate_components(§ion.platform_components)?;
890 }
891 (Some(PackKind::DistributionBundle), None) => {
892 bail!("distribution section is required for kind distribution-bundle");
893 }
894 (_, Some(_)) => {
895 bail!("distribution section is only allowed when kind is distribution-bundle");
896 }
897 _ => {}
898 }
899 Ok(())
900}
901
902fn finalize_provenance(provenance: Option<Provenance>) -> Provenance {
903 let builder_default = format!("greentic-pack@{}", env!("CARGO_PKG_VERSION"));
904
905 #[cfg(feature = "native")]
906 let now = OffsetDateTime::now_utc()
907 .format(&Rfc3339)
908 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
909 #[cfg(not(feature = "native"))]
910 let now = "1970-01-01T00:00:00Z".to_string();
911
912 match provenance {
913 Some(mut prov) => {
914 if prov.builder.trim().is_empty() {
915 prov.builder = builder_default;
916 }
917 if prov.built_at_utc.trim().is_empty() {
918 prov.built_at_utc = now;
919 }
920 prov
921 }
922 None => Provenance {
923 builder: builder_default,
924 git_commit: None,
925 git_repo: None,
926 toolchain: None,
927 built_at_utc: now,
928 host: None,
929 notes: None,
930 },
931 }
932}
933
934pub(crate) fn signature_digest_from_entries(
935 entries: &[SbomEntry],
936 manifest_cbor: &[u8],
937 sbom_bytes: &[u8],
938) -> blake3::Hash {
939 let mut hasher = Hasher::new();
940 hasher.update(manifest_cbor);
941 hasher.update(sbom_bytes);
942
943 let mut records: Vec<(String, String)> = entries
944 .iter()
945 .map(|entry| (entry.path.clone(), entry.hash_blake3.clone()))
946 .collect();
947 records.sort_by(|a, b| a.0.cmp(&b.0));
948
949 for (path, hash) in records {
950 hasher.update(path.as_bytes());
951 hasher.update(b"\n");
952 hasher.update(hash.as_bytes());
953 }
954
955 hasher.finalize()
956}
957
958#[cfg(feature = "native")]
959fn dev_signature(digest: &blake3::Hash) -> Result<(SignatureEnvelope, Option<Vec<u8>>)> {
960 let mut secret = [0u8; 32];
961 fill_random(&mut secret).map_err(|err| anyhow!("failed to generate dev signing key: {err}"))?;
962 let signing_key = SigningKey::from_bytes(&secret);
963 let signature = signing_key.sign(digest.as_bytes());
964 let signature_bytes = signature.to_bytes();
965
966 let pkcs8_doc = signing_key
967 .to_pkcs8_der()
968 .map_err(|err| anyhow!("failed to encode dev keypair: {err}"))?;
969 let pkcs8_der = PrivatePkcs8KeyDer::from(pkcs8_doc.as_bytes().to_vec());
970 let key_pair = KeyPair::from_pkcs8_der_and_sign_algo(&pkcs8_der, &PKCS_ED25519)
971 .map_err(|err| anyhow!("failed to load dev keypair for certificate: {err}"))?;
972
973 let mut params = CertificateParams::new(Vec::<String>::new())?;
974 params.distinguished_name = DistinguishedName::new();
975 params
976 .distinguished_name
977 .push(DnType::CommonName, "greentic-dev-local");
978 let cert = params.self_signed(&key_pair)?;
979 let chain = normalize_newlines(&cert.pem()).into_bytes();
980 let fingerprint = hex_hash(signing_key.verifying_key().as_bytes());
981
982 let envelope = SignatureEnvelope::new("ed25519", &signature_bytes, digest, Some(fingerprint));
983 Ok((envelope, Some(chain)))
984}
985
986fn external_signature(
987 signer: &DynSigner,
988 digest: &blake3::Hash,
989) -> Result<(SignatureEnvelope, Option<Vec<u8>>)> {
990 let ExternalSignature { alg, sig } = signer.sign(digest.as_bytes())?;
991 let chain = signer.chain_pem()?;
992 let chain_bytes = if chain.is_empty() {
993 None
994 } else {
995 let chain_str = String::from_utf8(chain)?;
996 Some(normalize_newlines(&chain_str).into_bytes())
997 };
998 let envelope = SignatureEnvelope::new(alg, &sig, digest, None);
999 Ok((envelope, chain_bytes))
1000}
1001
1002pub(crate) fn hex_hash(bytes: &[u8]) -> String {
1003 blake3::hash(bytes).to_hex().to_string()
1004}
1005
1006#[cfg(feature = "native")]
1007fn media_type_for(path: &str) -> &'static str {
1008 if path.ends_with(".ygtc") {
1009 "application/yaml"
1010 } else if path.starts_with("schemas/") && path.ends_with(".json") {
1011 "application/schema+json"
1012 } else if path.ends_with(".json") {
1013 "application/json"
1014 } else if path.ends_with(".cbor") {
1015 "application/cbor"
1016 } else if path.ends_with(".wasm") {
1017 "application/wasm"
1018 } else if path.ends_with(".pem") {
1019 "application/x-pem-file"
1020 } else {
1021 "application/octet-stream"
1022 }
1023}
1024
1025#[cfg(feature = "native")]
1026fn write_zip(out_path: &Path, files: &[PendingFile]) -> Result<()> {
1027 let file = fs::File::create(out_path)
1028 .with_context(|| format!("failed to create {}", out_path.display()))?;
1029 let mut writer = ZipWriter::new(file);
1030 let timestamp = zip_timestamp();
1031
1032 for entry in files {
1033 let options = SimpleFileOptions::default()
1034 .compression_method(CompressionMethod::Stored)
1035 .last_modified_time(timestamp)
1036 .unix_permissions(0o644)
1037 .large_file(false);
1038 writer
1039 .start_file(&entry.path, options)
1040 .with_context(|| format!("failed to add {} to archive", entry.path))?;
1041 writer
1042 .write_all(&entry.bytes)
1043 .with_context(|| format!("failed to write {}", entry.path))?;
1044 }
1045
1046 writer.finish().context("failed to finish gtpack archive")?;
1047 Ok(())
1048}
1049
1050#[cfg(feature = "native")]
1051fn zip_timestamp() -> ZipDateTime {
1052 ZipDateTime::from_date_and_time(1980, 1, 1, 0, 0, 0).unwrap_or_else(|_| ZipDateTime::default())
1053}
1054
1055#[cfg(test)]
1056mod tests {
1057 use super::*;
1058 use serde_json::json;
1059 use tempfile::tempdir;
1060 use zip::ZipArchive;
1061
1062 use std::fs::{self, File};
1063 use std::io::Read;
1064
1065 #[test]
1066 fn deterministic_build_without_signing() {
1067 let temp = tempdir().unwrap();
1068 let wasm_path = temp.path().join("component.wasm");
1069 fs::write(&wasm_path, test_wasm_bytes()).unwrap();
1070
1071 let builder = || {
1072 PackBuilder::new(sample_meta())
1073 .with_flow(sample_flow())
1074 .with_component(sample_component(&wasm_path))
1075 .with_signing(Signing::None)
1076 .with_provenance(sample_provenance())
1077 };
1078
1079 let out_a = temp.path().join("a.gtpack");
1080 let out_b = temp.path().join("b.gtpack");
1081
1082 builder().build(&out_a).unwrap();
1083 builder().build(&out_b).unwrap();
1084
1085 let bytes_a = fs::read(&out_a).unwrap();
1086 let bytes_b = fs::read(&out_b).unwrap();
1087 assert_eq!(bytes_a, bytes_b, "gtpack output should be deterministic");
1088
1089 let result = PackBuilder::new(sample_meta())
1090 .with_flow(sample_flow())
1091 .with_component(sample_component(&wasm_path))
1092 .with_signing(Signing::None)
1093 .with_provenance(sample_provenance())
1094 .build(temp.path().join("result.gtpack"))
1095 .unwrap();
1096
1097 assert!(
1098 result
1099 .files
1100 .iter()
1101 .any(|entry| entry.path == "components/oauth@1.0.0/component.wasm")
1102 );
1103 }
1104
1105 #[test]
1106 fn dev_signing_writes_signature_files() {
1107 let temp = tempdir().unwrap();
1108 let wasm_path = temp.path().join("component.wasm");
1109 fs::write(&wasm_path, test_wasm_bytes()).unwrap();
1110
1111 let out_path = temp.path().join("signed.gtpack");
1112 PackBuilder::new(sample_meta())
1113 .with_flow(sample_flow())
1114 .with_component(sample_component(&wasm_path))
1115 .with_signing(Signing::Dev)
1116 .with_provenance(sample_provenance())
1117 .build(&out_path)
1118 .unwrap();
1119
1120 let reader = File::open(&out_path).unwrap();
1121 let mut archive = ZipArchive::new(reader).unwrap();
1122 let mut signature_found = false;
1123 let mut chain_found = false;
1124
1125 for i in 0..archive.len() {
1126 let mut file = archive.by_index(i).unwrap();
1127 match file.name() {
1128 SIGNATURE_PATH => {
1129 signature_found = true;
1130 let mut contents = String::new();
1131 file.read_to_string(&mut contents).unwrap();
1132 assert!(contents.contains("\"alg\": \"ed25519\""));
1133 }
1134 SIGNATURE_CHAIN_PATH => {
1135 chain_found = true;
1136 }
1137 _ => {}
1138 }
1139 }
1140
1141 assert!(signature_found, "signature should be present");
1142 assert!(chain_found, "certificate chain should be present");
1143 }
1144
1145 fn sample_meta() -> PackMeta {
1146 PackMeta {
1147 pack_version: PACK_VERSION,
1148 pack_id: "ai.greentic.demo.test".to_string(),
1149 version: Version::parse("0.1.0").unwrap(),
1150 name: "Test Pack".to_string(),
1151 kind: None,
1152 description: Some("integration test".to_string()),
1153 authors: vec!["Greentic".to_string()],
1154 license: Some("MIT".to_string()),
1155 homepage: None,
1156 support: None,
1157 vendor: None,
1158 imports: Vec::new(),
1159 entry_flows: vec!["main".to_string()],
1160 created_at_utc: "2025-01-01T00:00:00Z".to_string(),
1161 events: None,
1162 repo: None,
1163 messaging: None,
1164 interfaces: Vec::new(),
1165 annotations: JsonMap::new(),
1166 distribution: None,
1167 components: Vec::new(),
1168 }
1169 }
1170
1171 fn sample_flow() -> FlowBundle {
1172 let flow_json = json!({
1173 "id": "main",
1174 "kind": "flow/v1",
1175 "entry": "start",
1176 "nodes": []
1177 });
1178 let hash = blake3::hash(&serde_json::to_vec(&flow_json).unwrap())
1179 .to_hex()
1180 .to_string();
1181 FlowBundle {
1182 id: "main".to_string(),
1183 kind: "flow/v1".to_string(),
1184 entry: "start".to_string(),
1185 yaml: "id: main\nentry: start\n".to_string(),
1186 json: flow_json,
1187 hash_blake3: hash,
1188 nodes: Vec::new(),
1189 }
1190 }
1191
1192 fn sample_component(wasm_path: &Path) -> ComponentArtifact {
1193 ComponentArtifact {
1194 name: "oauth".to_string(),
1195 version: Version::parse("1.0.0").unwrap(),
1196 wasm_path: wasm_path.to_path_buf(),
1197 schema_json: None,
1198 manifest_json: None,
1199 capabilities: None,
1200 world: Some("component:tool".to_string()),
1201 hash_blake3: None,
1202 }
1203 }
1204
1205 fn test_wasm_bytes() -> Vec<u8> {
1206 vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]
1207 }
1208
1209 fn sample_provenance() -> Provenance {
1210 Provenance {
1211 builder: "greentic-pack@test".to_string(),
1212 git_commit: Some("abc123".to_string()),
1213 git_repo: Some("https://example.com/repo.git".to_string()),
1214 toolchain: Some("rustc 1.85.0".to_string()),
1215 built_at_utc: "2025-01-01T00:00:00Z".to_string(),
1216 host: Some("ci".to_string()),
1217 notes: None,
1218 }
1219 }
1220
1221 #[test]
1222 fn entries_contains_manifest_and_assets_without_fs() {
1223 let meta = sample_meta();
1224 let builder = PackBuilder::new(meta)
1225 .with_signing(Signing::None)
1226 .with_flow(sample_flow())
1227 .with_asset_bytes("x.json", b"{}".to_vec());
1228 let entries = builder.entries().expect("entries");
1229 assert!(entries.contains_key("manifest.cbor"));
1230 assert!(entries.contains_key("manifest.json"));
1231 assert!(entries.contains_key("assets/x.json"));
1232 let keys: Vec<_> = entries.keys().cloned().collect();
1234 let mut sorted = keys.clone();
1235 sorted.sort();
1236 assert_eq!(keys, sorted);
1237 }
1238}