Skip to main content

greentic_pack/
builder.rs

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// On native builds, re-export the canonical types from greentic-flow.
52// On wasm (no-default-features), define minimal compatible types locally so
53// that the entries() API surface compiles without pulling in wasmtime.
54#[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// Manual impl instead of derive because the default variant differs by feature flag.
270#[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    /// Returns every in-archive file keyed by archive path, in deterministic (sorted) order.
481    /// Includes flows, components, assets, manifest.cbor/json, provenance.json, sbom.json,
482    /// and signature files when signing is not None.
483    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        // BTreeMap provides sorted iteration by key — matches the original sort_by path.
716        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        // build_files = SBOM of content files only (excludes sbom.json and signature files),
728        // matching what the original code captured before adding those to pending_files.
729        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) = &section.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(&section.components)?;
889            validate_components(&section.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        // deterministic ordering
1233        let keys: Vec<_> = entries.keys().cloned().collect();
1234        let mut sorted = keys.clone();
1235        sorted.sort();
1236        assert_eq!(keys, sorted);
1237    }
1238}