Skip to main content

packc/
build.rs

1use crate::cli::resolve::{self, ResolveArgs};
2use crate::config::{
3    AssetConfig, ComponentConfig, ComponentOperationConfig, FlowConfig, PackConfig,
4};
5use crate::extensions::validate_components_extension;
6use crate::flow_resolve::load_flow_resolve_summary;
7use crate::runtime::{NetworkPolicy, RuntimeContext};
8use anyhow::{Context, Result, anyhow};
9use greentic_distributor_client::{DistClient, DistOptions};
10use greentic_flow::add_step::normalize::normalize_node_map;
11use greentic_flow::compile_ygtc_file;
12use greentic_flow::loader::load_ygtc_from_path;
13use greentic_pack::builder::SbomEntry;
14use greentic_pack::pack_lock::read_pack_lock;
15use greentic_types::component_source::ComponentSourceRef;
16use greentic_types::flow_resolve_summary::FlowResolveSummaryV1;
17use greentic_types::pack::extensions::component_manifests::{
18    ComponentManifestIndexEntryV1, ComponentManifestIndexV1, EXT_COMPONENT_MANIFEST_INDEX_V1,
19    ManifestEncoding,
20};
21use greentic_types::pack::extensions::component_sources::{
22    ArtifactLocationV1, ComponentSourceEntryV1, ComponentSourcesV1, EXT_COMPONENT_SOURCES_V1,
23    ResolvedComponentV1,
24};
25use greentic_types::{
26    BootstrapSpec, ComponentCapability, ComponentConfigurators, ComponentId, ComponentManifest,
27    ComponentOperation, ExtensionInline, ExtensionRef, Flow, FlowId, PackDependency, PackFlowEntry,
28    PackId, PackKind, PackManifest, PackSignatures, SecretRequirement, SecretScope, SemverReq,
29    encode_pack_manifest,
30};
31use semver::Version;
32use serde::Serialize;
33use serde_cbor;
34use serde_yaml_bw::Value as YamlValue;
35use sha2::{Digest, Sha256};
36use std::collections::{BTreeMap, BTreeSet};
37use std::fs;
38use std::io::Write;
39use std::path::{Path, PathBuf};
40use std::str::FromStr;
41use tracing::info;
42use walkdir::WalkDir;
43use zip::write::SimpleFileOptions;
44use zip::{CompressionMethod, ZipWriter};
45
46const SBOM_FORMAT: &str = "greentic-sbom-v1";
47
48#[derive(Serialize)]
49struct SbomDocument {
50    format: String,
51    files: Vec<SbomEntry>,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
55pub enum BundleMode {
56    Cache,
57    None,
58}
59
60#[derive(Clone)]
61pub struct BuildOptions {
62    pub pack_dir: PathBuf,
63    pub component_out: Option<PathBuf>,
64    pub manifest_out: PathBuf,
65    pub sbom_out: Option<PathBuf>,
66    pub gtpack_out: Option<PathBuf>,
67    pub lock_path: PathBuf,
68    pub bundle: BundleMode,
69    pub dry_run: bool,
70    pub secrets_req: Option<PathBuf>,
71    pub default_secret_scope: Option<String>,
72    pub allow_oci_tags: bool,
73    pub require_component_manifests: bool,
74    pub no_extra_dirs: bool,
75    pub runtime: RuntimeContext,
76    pub skip_update: bool,
77}
78
79impl BuildOptions {
80    pub fn from_args(args: crate::BuildArgs, runtime: &RuntimeContext) -> Result<Self> {
81        let pack_dir = args
82            .input
83            .canonicalize()
84            .with_context(|| format!("failed to canonicalize pack dir {}", args.input.display()))?;
85
86        let component_out = args
87            .component_out
88            .map(|p| if p.is_absolute() { p } else { pack_dir.join(p) });
89        let manifest_out = args
90            .manifest
91            .map(|p| if p.is_relative() { pack_dir.join(p) } else { p })
92            .unwrap_or_else(|| pack_dir.join("dist").join("manifest.cbor"));
93        let sbom_out = args
94            .sbom
95            .map(|p| if p.is_absolute() { p } else { pack_dir.join(p) });
96        let default_gtpack_name = pack_dir
97            .file_name()
98            .and_then(|name| name.to_str())
99            .unwrap_or("pack");
100        let default_gtpack_out = pack_dir
101            .join("dist")
102            .join(format!("{default_gtpack_name}.gtpack"));
103        let gtpack_out = Some(
104            args.gtpack_out
105                .map(|p| if p.is_absolute() { p } else { pack_dir.join(p) })
106                .unwrap_or(default_gtpack_out),
107        );
108        let lock_path = args
109            .lock
110            .map(|p| if p.is_absolute() { p } else { pack_dir.join(p) })
111            .unwrap_or_else(|| pack_dir.join("pack.lock.json"));
112
113        Ok(Self {
114            pack_dir,
115            component_out,
116            manifest_out,
117            sbom_out,
118            gtpack_out,
119            lock_path,
120            bundle: args.bundle,
121            dry_run: args.dry_run,
122            secrets_req: args.secrets_req,
123            default_secret_scope: args.default_secret_scope,
124            allow_oci_tags: args.allow_oci_tags,
125            require_component_manifests: args.require_component_manifests,
126            no_extra_dirs: args.no_extra_dirs,
127            runtime: runtime.clone(),
128            skip_update: args.no_update,
129        })
130    }
131}
132
133pub async fn run(opts: &BuildOptions) -> Result<()> {
134    info!(
135        pack_dir = %opts.pack_dir.display(),
136        manifest_out = %opts.manifest_out.display(),
137        gtpack_out = ?opts.gtpack_out,
138        dry_run = opts.dry_run,
139        "building greentic pack"
140    );
141
142    if !opts.skip_update {
143        // Keep pack.yaml in sync before building.
144        crate::cli::update::update_pack(&opts.pack_dir, false)?;
145    }
146
147    // Resolve component references into pack.lock.json before building to ensure
148    // manifests/extensions can rely on the lockfile contents.
149    resolve::handle(
150        ResolveArgs {
151            input: opts.pack_dir.clone(),
152            lock: Some(opts.lock_path.clone()),
153        },
154        &opts.runtime,
155        false,
156    )
157    .await?;
158
159    let config = crate::config::load_pack_config(&opts.pack_dir)?;
160    info!(
161        id = %config.pack_id,
162        version = %config.version,
163        kind = %config.kind,
164        components = config.components.len(),
165        flows = config.flows.len(),
166        dependencies = config.dependencies.len(),
167        "loaded pack.yaml"
168    );
169    validate_components_extension(&config.extensions, opts.allow_oci_tags)?;
170
171    let secret_requirements = aggregate_secret_requirements(
172        &config.components,
173        opts.secrets_req.as_deref(),
174        opts.default_secret_scope.as_deref(),
175    )?;
176
177    if !opts.lock_path.exists() {
178        anyhow::bail!(
179            "pack.lock.json is required (run `greentic-pack resolve`); missing: {}",
180            opts.lock_path.display()
181        );
182    }
183    let mut pack_lock = read_pack_lock(&opts.lock_path).with_context(|| {
184        format!(
185            "failed to read pack lock {} (try `greentic-pack resolve`)",
186            opts.lock_path.display()
187        )
188    })?;
189
190    let mut build = assemble_manifest(
191        &config,
192        &opts.pack_dir,
193        &secret_requirements,
194        !opts.no_extra_dirs,
195    )?;
196    build.lock_components =
197        collect_lock_component_artifacts(&mut pack_lock, &opts.runtime, opts.bundle, opts.dry_run)
198            .await?;
199
200    let materialized = materialize_flow_components(
201        &opts.pack_dir,
202        &build.manifest.flows,
203        &pack_lock,
204        &build.components,
205        &build.lock_components,
206        opts.require_component_manifests,
207    )?;
208    build.manifest.components.extend(materialized.components);
209    build.component_manifest_files = materialized.manifest_files;
210    build.manifest.components.sort_by(|a, b| a.id.cmp(&b.id));
211
212    let component_manifest_files =
213        collect_component_manifest_files(&build.components, &build.component_manifest_files);
214    build.manifest.extensions =
215        merge_component_manifest_extension(build.manifest.extensions, &component_manifest_files)?;
216    build.manifest.extensions = merge_component_sources_extension(
217        build.manifest.extensions,
218        &pack_lock,
219        opts.bundle,
220        materialized.manifest_paths.as_ref(),
221    )?;
222    if !opts.dry_run {
223        greentic_pack::pack_lock::write_pack_lock(&opts.lock_path, &pack_lock)?;
224    }
225
226    let manifest_bytes = encode_pack_manifest(&build.manifest)?;
227    info!(len = manifest_bytes.len(), "encoded manifest.cbor");
228
229    if opts.dry_run {
230        info!("dry-run complete; no files written");
231        return Ok(());
232    }
233
234    if let Some(component_out) = opts.component_out.as_ref() {
235        write_stub_wasm(component_out)?;
236    }
237
238    write_bytes(&opts.manifest_out, &manifest_bytes)?;
239
240    if let Some(sbom_out) = opts.sbom_out.as_ref() {
241        write_bytes(sbom_out, br#"{"files":[]} "#)?;
242    }
243
244    if let Some(gtpack_out) = opts.gtpack_out.as_ref() {
245        let mut build = build;
246        if !secret_requirements.is_empty() {
247            let logical = "secret-requirements.json".to_string();
248            let req_path =
249                write_secret_requirements_file(&opts.pack_dir, &secret_requirements, &logical)?;
250            build.assets.push(AssetFile {
251                logical_path: logical,
252                source: req_path,
253            });
254        }
255        package_gtpack(gtpack_out, &manifest_bytes, &build, opts.bundle)?;
256        info!(gtpack_out = %gtpack_out.display(), "gtpack archive ready");
257        eprintln!("wrote {}", gtpack_out.display());
258    }
259
260    Ok(())
261}
262
263struct BuildProducts {
264    manifest: PackManifest,
265    components: Vec<ComponentBinary>,
266    lock_components: Vec<LockComponentBinary>,
267    component_manifest_files: Vec<ComponentManifestFile>,
268    flow_files: Vec<FlowFile>,
269    assets: Vec<AssetFile>,
270    extra_files: Vec<ExtraFile>,
271}
272
273#[derive(Clone)]
274struct ComponentBinary {
275    id: String,
276    source: PathBuf,
277    manifest_bytes: Vec<u8>,
278    manifest_path: String,
279    manifest_hash_sha256: String,
280}
281
282#[derive(Clone)]
283struct LockComponentBinary {
284    logical_path: String,
285    source: PathBuf,
286}
287
288#[derive(Clone)]
289struct ComponentManifestFile {
290    component_id: String,
291    manifest_path: String,
292    manifest_bytes: Vec<u8>,
293    manifest_hash_sha256: String,
294}
295
296struct AssetFile {
297    logical_path: String,
298    source: PathBuf,
299}
300
301struct ExtraFile {
302    logical_path: String,
303    source: PathBuf,
304}
305
306#[derive(Clone)]
307struct FlowFile {
308    logical_path: String,
309    bytes: Vec<u8>,
310    media_type: &'static str,
311}
312
313fn assemble_manifest(
314    config: &PackConfig,
315    pack_root: &Path,
316    secret_requirements: &[SecretRequirement],
317    include_extra_dirs: bool,
318) -> Result<BuildProducts> {
319    let components = build_components(&config.components)?;
320    let (flows, flow_files) = build_flows(&config.flows, pack_root)?;
321    let dependencies = build_dependencies(&config.dependencies)?;
322    let assets = collect_assets(&config.assets, pack_root)?;
323    let extra_files = if include_extra_dirs {
324        collect_extra_dir_files(pack_root)?
325    } else {
326        Vec::new()
327    };
328    let component_manifests: Vec<_> = components.iter().map(|c| c.0.clone()).collect();
329    let bootstrap = build_bootstrap(config, &flows, &component_manifests)?;
330    let extensions = normalize_extensions(&config.extensions);
331
332    let manifest = PackManifest {
333        schema_version: "pack-v1".to_string(),
334        pack_id: PackId::new(config.pack_id.clone()).context("invalid pack_id")?,
335        name: config.name.clone(),
336        version: Version::parse(&config.version)
337            .context("invalid pack version (expected semver)")?,
338        kind: map_kind(&config.kind)?,
339        publisher: config.publisher.clone(),
340        components: component_manifests,
341        flows,
342        dependencies,
343        capabilities: derive_pack_capabilities(&components),
344        secret_requirements: secret_requirements.to_vec(),
345        signatures: PackSignatures::default(),
346        bootstrap,
347        extensions,
348    };
349
350    Ok(BuildProducts {
351        manifest,
352        components: components.into_iter().map(|(_, bin)| bin).collect(),
353        lock_components: Vec::new(),
354        component_manifest_files: Vec::new(),
355        flow_files,
356        assets,
357        extra_files,
358    })
359}
360
361fn build_components(
362    configs: &[ComponentConfig],
363) -> Result<Vec<(ComponentManifest, ComponentBinary)>> {
364    let mut seen = BTreeSet::new();
365    let mut result = Vec::new();
366
367    for cfg in configs {
368        if !seen.insert(cfg.id.clone()) {
369            anyhow::bail!("duplicate component id {}", cfg.id);
370        }
371
372        info!(id = %cfg.id, wasm = %cfg.wasm.display(), "adding component");
373        let (manifest, binary) = resolve_component_artifacts(cfg)?;
374
375        result.push((manifest, binary));
376    }
377
378    Ok(result)
379}
380
381fn resolve_component_artifacts(
382    cfg: &ComponentConfig,
383) -> Result<(ComponentManifest, ComponentBinary)> {
384    let resolved_wasm = resolve_component_wasm_path(&cfg.wasm)?;
385
386    let mut manifest =
387        if let Some(from_disk) = load_component_manifest_from_disk(&resolved_wasm, &cfg.id)? {
388            if from_disk.id.to_string() != cfg.id {
389                anyhow::bail!(
390                    "component manifest id {} does not match pack.yaml id {}",
391                    from_disk.id,
392                    cfg.id
393                );
394            }
395            if from_disk.version.to_string() != cfg.version {
396                anyhow::bail!(
397                    "component manifest version {} does not match pack.yaml version {}",
398                    from_disk.version,
399                    cfg.version
400                );
401            }
402            from_disk
403        } else {
404            manifest_from_config(cfg)?
405        };
406
407    // Ensure operations are populated from pack.yaml when missing in the on-disk manifest.
408    if manifest.operations.is_empty() && !cfg.operations.is_empty() {
409        manifest.operations = cfg
410            .operations
411            .iter()
412            .map(operation_from_config)
413            .collect::<Result<Vec<_>>>()?;
414    }
415
416    let manifest_bytes =
417        serde_cbor::to_vec(&manifest).context("encode component manifest to cbor")?;
418    let mut sha = Sha256::new();
419    sha.update(&manifest_bytes);
420    let manifest_hash_sha256 = format!("sha256:{:x}", sha.finalize());
421    let manifest_path = format!("components/{}.manifest.cbor", cfg.id);
422
423    let binary = ComponentBinary {
424        id: cfg.id.clone(),
425        source: resolved_wasm,
426        manifest_bytes,
427        manifest_path,
428        manifest_hash_sha256,
429    };
430
431    Ok((manifest, binary))
432}
433
434fn manifest_from_config(cfg: &ComponentConfig) -> Result<ComponentManifest> {
435    Ok(ComponentManifest {
436        id: ComponentId::new(cfg.id.clone())
437            .with_context(|| format!("invalid component id {}", cfg.id))?,
438        version: Version::parse(&cfg.version)
439            .context("invalid component version (expected semver)")?,
440        supports: cfg.supports.iter().map(|k| k.to_kind()).collect(),
441        world: cfg.world.clone(),
442        profiles: cfg.profiles.clone(),
443        capabilities: cfg.capabilities.clone(),
444        configurators: convert_configurators(cfg)?,
445        operations: cfg
446            .operations
447            .iter()
448            .map(operation_from_config)
449            .collect::<Result<Vec<_>>>()?,
450        config_schema: cfg.config_schema.clone(),
451        resources: cfg.resources.clone().unwrap_or_default(),
452        dev_flows: BTreeMap::new(),
453    })
454}
455
456fn resolve_component_wasm_path(path: &Path) -> Result<PathBuf> {
457    if path.is_file() {
458        return Ok(path.to_path_buf());
459    }
460    if !path.exists() {
461        anyhow::bail!("component path {} does not exist", path.display());
462    }
463    if !path.is_dir() {
464        anyhow::bail!(
465            "component path {} must be a file or directory",
466            path.display()
467        );
468    }
469
470    let mut component_candidates = Vec::new();
471    let mut wasm_candidates = Vec::new();
472    let mut stack = vec![path.to_path_buf()];
473    while let Some(current) = stack.pop() {
474        for entry in fs::read_dir(&current)
475            .with_context(|| format!("failed to list components in {}", current.display()))?
476        {
477            let entry = entry?;
478            let entry_type = entry.file_type()?;
479            let entry_path = entry.path();
480            if entry_type.is_dir() {
481                stack.push(entry_path);
482                continue;
483            }
484            if entry_type.is_file() && entry_path.extension() == Some(std::ffi::OsStr::new("wasm"))
485            {
486                let file_name = entry_path
487                    .file_name()
488                    .and_then(|n| n.to_str())
489                    .unwrap_or_default();
490                if file_name.ends_with(".component.wasm") {
491                    component_candidates.push(entry_path);
492                } else {
493                    wasm_candidates.push(entry_path);
494                }
495            }
496        }
497    }
498
499    let choose = |mut list: Vec<PathBuf>| -> Result<PathBuf> {
500        list.sort();
501        if list.len() == 1 {
502            Ok(list.remove(0))
503        } else {
504            let options = list
505                .iter()
506                .map(|p| p.strip_prefix(path).unwrap_or(p).display().to_string())
507                .collect::<Vec<_>>()
508                .join(", ");
509            anyhow::bail!(
510                "multiple wasm artifacts found under {}: {} (pick a single *.component.wasm or *.wasm)",
511                path.display(),
512                options
513            );
514        }
515    };
516
517    if !component_candidates.is_empty() {
518        return choose(component_candidates);
519    }
520    if !wasm_candidates.is_empty() {
521        return choose(wasm_candidates);
522    }
523
524    anyhow::bail!(
525        "no wasm artifact found under {}; expected *.component.wasm or *.wasm",
526        path.display()
527    );
528}
529
530fn load_component_manifest_from_disk(
531    path: &Path,
532    component_id: &str,
533) -> Result<Option<ComponentManifest>> {
534    let manifest_dir = if path.is_dir() {
535        path.to_path_buf()
536    } else {
537        path.parent()
538            .map(Path::to_path_buf)
539            .ok_or_else(|| anyhow!("component path {} has no parent directory", path.display()))?
540    };
541    let mut candidates = vec![
542        manifest_dir.join("component.manifest.cbor"),
543        manifest_dir.join("component.manifest.json"),
544        manifest_dir.join("component.json"),
545    ];
546    let id_manifest_suffix = format!("{}.manifest", component_id);
547    candidates.push(manifest_dir.join(format!("{id_manifest_suffix}.cbor")));
548    candidates.push(manifest_dir.join(format!("{id_manifest_suffix}.json")));
549    candidates.push(manifest_dir.join(format!("{component_id}.json")));
550
551    for manifest_path in candidates {
552        if !manifest_path.exists() {
553            continue;
554        }
555        let manifest = load_component_manifest_from_file(&manifest_path)?;
556        return Ok(Some(manifest));
557    }
558
559    Ok(None)
560}
561
562fn operation_from_config(cfg: &ComponentOperationConfig) -> Result<ComponentOperation> {
563    Ok(ComponentOperation {
564        name: cfg.name.clone(),
565        input_schema: cfg.input_schema.clone(),
566        output_schema: cfg.output_schema.clone(),
567    })
568}
569
570fn convert_configurators(cfg: &ComponentConfig) -> Result<Option<ComponentConfigurators>> {
571    let Some(configurators) = cfg.configurators.as_ref() else {
572        return Ok(None);
573    };
574
575    let basic = match &configurators.basic {
576        Some(id) => Some(FlowId::new(id).context("invalid configurator flow id")?),
577        None => None,
578    };
579    let full = match &configurators.full {
580        Some(id) => Some(FlowId::new(id).context("invalid configurator flow id")?),
581        None => None,
582    };
583
584    Ok(Some(ComponentConfigurators { basic, full }))
585}
586
587fn build_bootstrap(
588    config: &PackConfig,
589    flows: &[PackFlowEntry],
590    components: &[ComponentManifest],
591) -> Result<Option<BootstrapSpec>> {
592    let Some(raw) = config.bootstrap.as_ref() else {
593        return Ok(None);
594    };
595
596    let flow_ids: BTreeSet<_> = flows.iter().map(|flow| flow.id.to_string()).collect();
597    let component_ids: BTreeSet<_> = components.iter().map(|c| c.id.to_string()).collect();
598
599    let mut spec = BootstrapSpec::default();
600
601    if let Some(install_flow) = &raw.install_flow {
602        if !flow_ids.contains(install_flow) {
603            anyhow::bail!(
604                "bootstrap.install_flow references unknown flow {}",
605                install_flow
606            );
607        }
608        spec.install_flow = Some(install_flow.clone());
609    }
610
611    if let Some(upgrade_flow) = &raw.upgrade_flow {
612        if !flow_ids.contains(upgrade_flow) {
613            anyhow::bail!(
614                "bootstrap.upgrade_flow references unknown flow {}",
615                upgrade_flow
616            );
617        }
618        spec.upgrade_flow = Some(upgrade_flow.clone());
619    }
620
621    if let Some(component) = &raw.installer_component {
622        if !component_ids.contains(component) {
623            anyhow::bail!(
624                "bootstrap.installer_component references unknown component {}",
625                component
626            );
627        }
628        spec.installer_component = Some(component.clone());
629    }
630
631    if spec.install_flow.is_none()
632        && spec.upgrade_flow.is_none()
633        && spec.installer_component.is_none()
634    {
635        return Ok(None);
636    }
637
638    Ok(Some(spec))
639}
640
641fn build_flows(
642    configs: &[FlowConfig],
643    pack_root: &Path,
644) -> Result<(Vec<PackFlowEntry>, Vec<FlowFile>)> {
645    let mut seen = BTreeSet::new();
646    let mut entries = Vec::new();
647    let mut flow_files = Vec::new();
648
649    for cfg in configs {
650        info!(id = %cfg.id, path = %cfg.file.display(), "compiling flow");
651        let yaml_bytes = fs::read(&cfg.file)
652            .with_context(|| format!("failed to read flow {}", cfg.file.display()))?;
653        let mut flow: Flow = compile_ygtc_file(&cfg.file)
654            .with_context(|| format!("failed to compile {}", cfg.file.display()))?;
655        populate_component_exec_operations(&mut flow, &cfg.file).with_context(|| {
656            format!(
657                "failed to resolve component.exec operations in {}",
658                cfg.file.display()
659            )
660        })?;
661        normalize_legacy_component_exec_ids(&mut flow)?;
662        let summary = load_flow_resolve_summary(pack_root, cfg, &flow)?;
663        apply_summary_component_ids(&mut flow, &summary).with_context(|| {
664            format!("failed to resolve component ids in {}", cfg.file.display())
665        })?;
666
667        let flow_id = flow.id.to_string();
668        if !seen.insert(flow_id.clone()) {
669            anyhow::bail!("duplicate flow id {}", flow_id);
670        }
671
672        let entrypoints = if cfg.entrypoints.is_empty() {
673            flow.entrypoints.keys().cloned().collect()
674        } else {
675            cfg.entrypoints.clone()
676        };
677
678        let flow_entry = PackFlowEntry {
679            id: flow.id.clone(),
680            kind: flow.kind,
681            flow,
682            tags: cfg.tags.clone(),
683            entrypoints,
684        };
685
686        let flow_id = flow_entry.id.to_string();
687        flow_files.push(FlowFile {
688            logical_path: format!("flows/{flow_id}/flow.ygtc"),
689            bytes: yaml_bytes,
690            media_type: "application/yaml",
691        });
692        flow_files.push(FlowFile {
693            logical_path: format!("flows/{flow_id}/flow.json"),
694            bytes: serde_json::to_vec(&flow_entry.flow).context("encode flow json")?,
695            media_type: "application/json",
696        });
697        entries.push(flow_entry);
698    }
699
700    Ok((entries, flow_files))
701}
702
703fn apply_summary_component_ids(flow: &mut Flow, summary: &FlowResolveSummaryV1) -> Result<()> {
704    for (node_id, node) in flow.nodes.iter_mut() {
705        let resolved = summary.nodes.get(node_id.as_str()).ok_or_else(|| {
706            anyhow!(
707                "flow resolve summary missing node {} (expected component id for node)",
708                node_id
709            )
710        })?;
711        let summary_id = resolved.component_id.as_str();
712        if node.component.id.as_str().is_empty() || node.component.id.as_str() == "component.exec" {
713            node.component.id = resolved.component_id.clone();
714            continue;
715        }
716        if node.component.id.as_str() != summary_id {
717            anyhow::bail!(
718                "node {} component id {} does not match resolve summary {}",
719                node_id,
720                node.component.id.as_str(),
721                summary_id
722            );
723        }
724    }
725    Ok(())
726}
727
728fn populate_component_exec_operations(flow: &mut Flow, path: &Path) -> Result<()> {
729    let needs_op = flow.nodes.values().any(|node| {
730        node.component.id.as_str() == "component.exec" && node.component.operation.is_none()
731    });
732    if !needs_op {
733        return Ok(());
734    }
735
736    let flow_doc = load_ygtc_from_path(path)?;
737    let mut operations = BTreeMap::new();
738
739    for (node_id, node_doc) in flow_doc.nodes {
740        let value = serde_json::to_value(&node_doc)
741            .with_context(|| format!("failed to normalize component.exec node {}", node_id))?;
742        let normalized = normalize_node_map(value)?;
743        if !normalized.operation.trim().is_empty() {
744            operations.insert(node_id, normalized.operation);
745        }
746    }
747
748    for (node_id, node) in flow.nodes.iter_mut() {
749        if node.component.id.as_str() != "component.exec" || node.component.operation.is_some() {
750            continue;
751        }
752        if let Some(op) = operations.get(node_id.as_str()) {
753            node.component.operation = Some(op.clone());
754        }
755    }
756
757    Ok(())
758}
759
760fn normalize_legacy_component_exec_ids(flow: &mut Flow) -> Result<()> {
761    for (node_id, node) in flow.nodes.iter_mut() {
762        if node.component.id.as_str() != "component.exec" {
763            continue;
764        }
765        let Some(op) = node.component.operation.as_deref() else {
766            continue;
767        };
768        if !op.contains('.') && !op.contains(':') {
769            continue;
770        }
771        node.component.id = ComponentId::new(op).with_context(|| {
772            format!("invalid component id {} resolved for node {}", op, node_id)
773        })?;
774        node.component.operation = None;
775    }
776    Ok(())
777}
778
779fn build_dependencies(configs: &[crate::config::DependencyConfig]) -> Result<Vec<PackDependency>> {
780    let mut deps = Vec::new();
781    let mut seen = BTreeSet::new();
782    for cfg in configs {
783        if !seen.insert(cfg.alias.clone()) {
784            anyhow::bail!("duplicate dependency alias {}", cfg.alias);
785        }
786        deps.push(PackDependency {
787            alias: cfg.alias.clone(),
788            pack_id: PackId::new(cfg.pack_id.clone()).context("invalid dependency pack_id")?,
789            version_req: SemverReq::parse(&cfg.version_req)
790                .context("invalid dependency version requirement")?,
791            required_capabilities: cfg.required_capabilities.clone(),
792        });
793    }
794    Ok(deps)
795}
796
797fn collect_assets(configs: &[AssetConfig], pack_root: &Path) -> Result<Vec<AssetFile>> {
798    let mut assets = Vec::new();
799    for cfg in configs {
800        let logical = cfg
801            .path
802            .strip_prefix(pack_root)
803            .unwrap_or(&cfg.path)
804            .components()
805            .map(|c| c.as_os_str().to_string_lossy().into_owned())
806            .collect::<Vec<_>>()
807            .join("/");
808        if logical.is_empty() {
809            anyhow::bail!("invalid asset path {}", cfg.path.display());
810        }
811        assets.push(AssetFile {
812            logical_path: logical,
813            source: cfg.path.clone(),
814        });
815    }
816    Ok(assets)
817}
818
819fn is_reserved_extra_file(logical_path: &str) -> bool {
820    matches!(logical_path, "sbom.cbor" | "sbom.json")
821}
822
823fn collect_extra_dir_files(pack_root: &Path) -> Result<Vec<ExtraFile>> {
824    let excluded = [
825        "components",
826        "flows",
827        "assets",
828        "dist",
829        "target",
830        ".git",
831        ".github",
832        ".idea",
833        ".vscode",
834        "node_modules",
835    ];
836    let mut entries = Vec::new();
837    let mut seen = BTreeSet::new();
838    for entry in fs::read_dir(pack_root)
839        .with_context(|| format!("failed to list pack root {}", pack_root.display()))?
840    {
841        let entry = entry?;
842        let entry_type = entry.file_type()?;
843        let name = entry.file_name();
844        let name = name.to_string_lossy();
845        if entry_type.is_file() {
846            let logical = name.to_string();
847            if is_reserved_extra_file(&logical) {
848                continue;
849            }
850            if !logical.is_empty() && seen.insert(logical.clone()) {
851                entries.push(ExtraFile {
852                    logical_path: logical,
853                    source: entry.path(),
854                });
855            }
856            continue;
857        }
858        if !entry_type.is_dir() {
859            continue;
860        }
861        if name.starts_with('.') || excluded.contains(&name.as_ref()) {
862            continue;
863        }
864        let root = entry.path();
865        for sub in WalkDir::new(&root)
866            .into_iter()
867            .filter_entry(|walk| !walk.file_name().to_string_lossy().starts_with('.'))
868            .filter_map(Result::ok)
869        {
870            if !sub.file_type().is_file() {
871                continue;
872            }
873            let logical = sub
874                .path()
875                .strip_prefix(pack_root)
876                .unwrap_or(sub.path())
877                .components()
878                .map(|c| c.as_os_str().to_string_lossy().into_owned())
879                .collect::<Vec<_>>()
880                .join("/");
881            if logical.is_empty() || !seen.insert(logical.clone()) {
882                continue;
883            }
884            if is_reserved_extra_file(&logical) {
885                continue;
886            }
887            entries.push(ExtraFile {
888                logical_path: logical,
889                source: sub.path().to_path_buf(),
890            });
891        }
892    }
893    Ok(entries)
894}
895
896fn normalize_extensions(
897    extensions: &Option<BTreeMap<String, greentic_types::ExtensionRef>>,
898) -> Option<BTreeMap<String, greentic_types::ExtensionRef>> {
899    extensions.as_ref().filter(|map| !map.is_empty()).cloned()
900}
901
902fn merge_component_manifest_extension(
903    extensions: Option<BTreeMap<String, ExtensionRef>>,
904    manifest_files: &[ComponentManifestFile],
905) -> Result<Option<BTreeMap<String, ExtensionRef>>> {
906    if manifest_files.is_empty() {
907        return Ok(extensions);
908    }
909
910    let entries: Vec<_> = manifest_files
911        .iter()
912        .map(|entry| ComponentManifestIndexEntryV1 {
913            component_id: entry.component_id.clone(),
914            manifest_file: entry.manifest_path.clone(),
915            encoding: ManifestEncoding::Cbor,
916            content_hash: Some(entry.manifest_hash_sha256.clone()),
917        })
918        .collect();
919
920    let index = ComponentManifestIndexV1::new(entries);
921    let value = index
922        .to_extension_value()
923        .context("serialize component manifest index extension")?;
924
925    let ext = ExtensionRef {
926        kind: EXT_COMPONENT_MANIFEST_INDEX_V1.to_string(),
927        version: "v1".to_string(),
928        digest: None,
929        location: None,
930        inline: Some(ExtensionInline::Other(value)),
931    };
932
933    let mut map = extensions.unwrap_or_default();
934    map.insert(EXT_COMPONENT_MANIFEST_INDEX_V1.to_string(), ext);
935    if map.is_empty() {
936        Ok(None)
937    } else {
938        Ok(Some(map))
939    }
940}
941
942fn merge_component_sources_extension(
943    extensions: Option<BTreeMap<String, ExtensionRef>>,
944    lock: &greentic_pack::pack_lock::PackLockV1,
945    _bundle: BundleMode,
946    manifest_paths: Option<&std::collections::BTreeMap<String, String>>,
947) -> Result<Option<BTreeMap<String, ExtensionRef>>> {
948    let mut entries = Vec::new();
949    for comp in &lock.components {
950        if comp.r#ref.starts_with("file://") {
951            continue;
952        }
953        let source = match ComponentSourceRef::from_str(&comp.r#ref) {
954            Ok(parsed) => parsed,
955            Err(_) => {
956                eprintln!(
957                    "warning: skipping pack.lock entry `{}` with unsupported ref {}",
958                    comp.name, comp.r#ref
959                );
960                continue;
961            }
962        };
963        let manifest_path = manifest_paths.and_then(|paths| {
964            comp.component_id
965                .as_ref()
966                .map(|id| id.as_str())
967                .and_then(|key| paths.get(key))
968                .or_else(|| paths.get(&comp.name))
969                .cloned()
970        });
971        let artifact = if comp.bundled {
972            let wasm_path = comp.bundled_path.clone().ok_or_else(|| {
973                anyhow!(
974                    "pack.lock entry {} marked bundled but missing bundled_path",
975                    comp.name
976                )
977            })?;
978            ArtifactLocationV1::Inline {
979                wasm_path,
980                manifest_path,
981            }
982        } else {
983            ArtifactLocationV1::Remote
984        };
985        entries.push(ComponentSourceEntryV1 {
986            name: comp.name.clone(),
987            component_id: comp.component_id.clone(),
988            source,
989            resolved: ResolvedComponentV1 {
990                digest: comp.digest.clone(),
991                signature: None,
992                signed_by: None,
993            },
994            artifact,
995            licensing_hint: None,
996            metering_hint: None,
997        });
998    }
999
1000    if entries.is_empty() {
1001        return Ok(extensions);
1002    }
1003
1004    let payload = ComponentSourcesV1::new(entries)
1005        .to_extension_value()
1006        .context("serialize component_sources extension")?;
1007
1008    let ext = ExtensionRef {
1009        kind: EXT_COMPONENT_SOURCES_V1.to_string(),
1010        version: "v1".to_string(),
1011        digest: None,
1012        location: None,
1013        inline: Some(ExtensionInline::Other(payload)),
1014    };
1015
1016    let mut map = extensions.unwrap_or_default();
1017    map.insert(EXT_COMPONENT_SOURCES_V1.to_string(), ext);
1018    if map.is_empty() {
1019        Ok(None)
1020    } else {
1021        Ok(Some(map))
1022    }
1023}
1024
1025fn derive_pack_capabilities(
1026    components: &[(ComponentManifest, ComponentBinary)],
1027) -> Vec<ComponentCapability> {
1028    let mut seen = BTreeSet::new();
1029    let mut caps = Vec::new();
1030
1031    for (component, _) in components {
1032        let mut add = |name: &str| {
1033            if seen.insert(name.to_string()) {
1034                caps.push(ComponentCapability {
1035                    name: name.to_string(),
1036                    description: None,
1037                });
1038            }
1039        };
1040
1041        if component.capabilities.host.secrets.is_some() {
1042            add("host:secrets");
1043        }
1044        if let Some(state) = &component.capabilities.host.state {
1045            if state.read {
1046                add("host:state:read");
1047            }
1048            if state.write {
1049                add("host:state:write");
1050            }
1051        }
1052        if component.capabilities.host.messaging.is_some() {
1053            add("host:messaging");
1054        }
1055        if component.capabilities.host.events.is_some() {
1056            add("host:events");
1057        }
1058        if component.capabilities.host.http.is_some() {
1059            add("host:http");
1060        }
1061        if component.capabilities.host.telemetry.is_some() {
1062            add("host:telemetry");
1063        }
1064        if component.capabilities.host.iac.is_some() {
1065            add("host:iac");
1066        }
1067        if let Some(fs) = component.capabilities.wasi.filesystem.as_ref() {
1068            add(&format!(
1069                "wasi:fs:{}",
1070                format!("{:?}", fs.mode).to_lowercase()
1071            ));
1072            if !fs.mounts.is_empty() {
1073                add("wasi:fs:mounts");
1074            }
1075        }
1076        if component.capabilities.wasi.random {
1077            add("wasi:random");
1078        }
1079        if component.capabilities.wasi.clocks {
1080            add("wasi:clocks");
1081        }
1082    }
1083
1084    caps
1085}
1086
1087fn map_kind(raw: &str) -> Result<PackKind> {
1088    match raw.to_ascii_lowercase().as_str() {
1089        "application" => Ok(PackKind::Application),
1090        "provider" => Ok(PackKind::Provider),
1091        "infrastructure" => Ok(PackKind::Infrastructure),
1092        "library" => Ok(PackKind::Library),
1093        other => Err(anyhow!("unknown pack kind {}", other)),
1094    }
1095}
1096
1097fn package_gtpack(
1098    out_path: &Path,
1099    manifest_bytes: &[u8],
1100    build: &BuildProducts,
1101    bundle: BundleMode,
1102) -> Result<()> {
1103    if let Some(parent) = out_path.parent() {
1104        fs::create_dir_all(parent)
1105            .with_context(|| format!("failed to create {}", parent.display()))?;
1106    }
1107
1108    let file = fs::File::create(out_path)
1109        .with_context(|| format!("failed to create {}", out_path.display()))?;
1110    let mut writer = ZipWriter::new(file);
1111    let options = SimpleFileOptions::default()
1112        .compression_method(CompressionMethod::Stored)
1113        .unix_permissions(0o644);
1114
1115    let mut sbom_entries = Vec::new();
1116    let mut written_paths = BTreeSet::new();
1117    record_sbom_entry(
1118        &mut sbom_entries,
1119        "manifest.cbor",
1120        manifest_bytes,
1121        "application/cbor",
1122    );
1123    written_paths.insert("manifest.cbor".to_string());
1124    write_zip_entry(&mut writer, "manifest.cbor", manifest_bytes, options)?;
1125
1126    let mut flow_files = build.flow_files.clone();
1127    flow_files.sort_by(|a, b| a.logical_path.cmp(&b.logical_path));
1128    for flow_file in flow_files {
1129        if written_paths.insert(flow_file.logical_path.clone()) {
1130            record_sbom_entry(
1131                &mut sbom_entries,
1132                &flow_file.logical_path,
1133                &flow_file.bytes,
1134                flow_file.media_type,
1135            );
1136            write_zip_entry(
1137                &mut writer,
1138                &flow_file.logical_path,
1139                &flow_file.bytes,
1140                options,
1141            )?;
1142        }
1143    }
1144
1145    let mut component_wasm_paths = BTreeSet::new();
1146    if bundle != BundleMode::None {
1147        for comp in &build.components {
1148            component_wasm_paths.insert(format!("components/{}.wasm", comp.id));
1149        }
1150    }
1151
1152    let mut lock_components = build.lock_components.clone();
1153    lock_components.sort_by(|a, b| a.logical_path.cmp(&b.logical_path));
1154    for comp in lock_components {
1155        if component_wasm_paths.contains(&comp.logical_path) {
1156            continue;
1157        }
1158        if !written_paths.insert(comp.logical_path.clone()) {
1159            continue;
1160        }
1161        let bytes = fs::read(&comp.source).with_context(|| {
1162            format!("failed to read cached component {}", comp.source.display())
1163        })?;
1164        record_sbom_entry(
1165            &mut sbom_entries,
1166            &comp.logical_path,
1167            &bytes,
1168            "application/wasm",
1169        );
1170        write_zip_entry(&mut writer, &comp.logical_path, &bytes, options)?;
1171    }
1172
1173    let mut lock_manifests = build.component_manifest_files.clone();
1174    lock_manifests.sort_by(|a, b| a.manifest_path.cmp(&b.manifest_path));
1175    for manifest in lock_manifests {
1176        if written_paths.insert(manifest.manifest_path.clone()) {
1177            record_sbom_entry(
1178                &mut sbom_entries,
1179                &manifest.manifest_path,
1180                &manifest.manifest_bytes,
1181                "application/cbor",
1182            );
1183            write_zip_entry(
1184                &mut writer,
1185                &manifest.manifest_path,
1186                &manifest.manifest_bytes,
1187                options,
1188            )?;
1189        }
1190    }
1191
1192    if bundle != BundleMode::None {
1193        let mut components = build.components.clone();
1194        components.sort_by(|a, b| a.id.cmp(&b.id));
1195        for comp in components {
1196            let logical_wasm = format!("components/{}.wasm", comp.id);
1197            let wasm_bytes = fs::read(&comp.source)
1198                .with_context(|| format!("failed to read component {}", comp.source.display()))?;
1199            if written_paths.insert(logical_wasm.clone()) {
1200                record_sbom_entry(
1201                    &mut sbom_entries,
1202                    &logical_wasm,
1203                    &wasm_bytes,
1204                    "application/wasm",
1205                );
1206                write_zip_entry(&mut writer, &logical_wasm, &wasm_bytes, options)?;
1207            }
1208
1209            if written_paths.insert(comp.manifest_path.clone()) {
1210                record_sbom_entry(
1211                    &mut sbom_entries,
1212                    &comp.manifest_path,
1213                    &comp.manifest_bytes,
1214                    "application/cbor",
1215                );
1216                write_zip_entry(
1217                    &mut writer,
1218                    &comp.manifest_path,
1219                    &comp.manifest_bytes,
1220                    options,
1221                )?;
1222            }
1223        }
1224    }
1225
1226    let mut asset_entries: Vec<_> = build
1227        .assets
1228        .iter()
1229        .map(|a| (format!("assets/{}", &a.logical_path), a.source.clone()))
1230        .collect();
1231    asset_entries.sort_by(|a, b| a.0.cmp(&b.0));
1232    for (logical, source) in asset_entries {
1233        let bytes = fs::read(&source)
1234            .with_context(|| format!("failed to read asset {}", source.display()))?;
1235        if written_paths.insert(logical.clone()) {
1236            record_sbom_entry(
1237                &mut sbom_entries,
1238                &logical,
1239                &bytes,
1240                "application/octet-stream",
1241            );
1242            write_zip_entry(&mut writer, &logical, &bytes, options)?;
1243        }
1244    }
1245
1246    let mut extra_entries: Vec<_> = build
1247        .extra_files
1248        .iter()
1249        .map(|e| (e.logical_path.clone(), e.source.clone()))
1250        .collect();
1251    extra_entries.sort_by(|a, b| a.0.cmp(&b.0));
1252    for (logical, source) in extra_entries {
1253        if !written_paths.insert(logical.clone()) {
1254            continue;
1255        }
1256        let bytes = fs::read(&source)
1257            .with_context(|| format!("failed to read extra file {}", source.display()))?;
1258        record_sbom_entry(
1259            &mut sbom_entries,
1260            &logical,
1261            &bytes,
1262            "application/octet-stream",
1263        );
1264        write_zip_entry(&mut writer, &logical, &bytes, options)?;
1265    }
1266
1267    sbom_entries.sort_by(|a, b| a.path.cmp(&b.path));
1268    let sbom_doc = SbomDocument {
1269        format: SBOM_FORMAT.to_string(),
1270        files: sbom_entries,
1271    };
1272    let sbom_bytes = serde_cbor::to_vec(&sbom_doc).context("failed to encode sbom.cbor")?;
1273    write_zip_entry(&mut writer, "sbom.cbor", &sbom_bytes, options)?;
1274
1275    writer
1276        .finish()
1277        .context("failed to finalise gtpack archive")?;
1278    Ok(())
1279}
1280
1281async fn collect_lock_component_artifacts(
1282    lock: &mut greentic_pack::pack_lock::PackLockV1,
1283    runtime: &RuntimeContext,
1284    bundle: BundleMode,
1285    allow_missing: bool,
1286) -> Result<Vec<LockComponentBinary>> {
1287    let dist = DistClient::new(DistOptions {
1288        cache_dir: runtime.cache_dir(),
1289        allow_tags: true,
1290        offline: runtime.network_policy() == NetworkPolicy::Offline,
1291        allow_insecure_local_http: false,
1292    });
1293
1294    let mut artifacts = Vec::new();
1295    let mut seen_paths = BTreeSet::new();
1296    for comp in &mut lock.components {
1297        if comp.r#ref.starts_with("file://") {
1298            comp.bundled = false;
1299            comp.bundled_path = None;
1300            comp.wasm_sha256 = None;
1301            comp.resolved_digest = None;
1302            continue;
1303        }
1304        let parsed = ComponentSourceRef::from_str(&comp.r#ref).ok();
1305        let is_tag = parsed.as_ref().map(|r| r.is_tag()).unwrap_or(false);
1306        let should_bundle = is_tag || bundle == BundleMode::Cache;
1307        if !should_bundle {
1308            comp.bundled = false;
1309            comp.bundled_path = None;
1310            comp.wasm_sha256 = None;
1311            comp.resolved_digest = None;
1312            continue;
1313        }
1314
1315        let resolved = if is_tag {
1316            let item = if runtime.network_policy() == NetworkPolicy::Offline {
1317                dist.ensure_cached(&comp.digest).await.map_err(|err| {
1318                    anyhow!(
1319                        "tag ref {} must be bundled but cache is missing ({})",
1320                        comp.r#ref,
1321                        err
1322                    )
1323                })?
1324            } else {
1325                dist.resolve_ref(&comp.r#ref)
1326                    .await
1327                    .map_err(|err| anyhow!("failed to resolve {}: {}", comp.r#ref, err))?
1328            };
1329            let cache_path = item.cache_path.clone().ok_or_else(|| {
1330                anyhow!("tag ref {} resolved but cache path is missing", comp.r#ref)
1331            })?;
1332            ResolvedLockItem { item, cache_path }
1333        } else {
1334            let mut resolved = dist
1335                .ensure_cached(&comp.digest)
1336                .await
1337                .ok()
1338                .and_then(|item| item.cache_path.clone().map(|path| (item, path)));
1339            if resolved.is_none()
1340                && runtime.network_policy() != NetworkPolicy::Offline
1341                && !allow_missing
1342                && comp.r#ref.starts_with("oci://")
1343            {
1344                let item = dist
1345                    .resolve_ref(&comp.r#ref)
1346                    .await
1347                    .map_err(|err| anyhow!("failed to resolve {}: {}", comp.r#ref, err))?;
1348                if let Some(path) = item.cache_path.clone() {
1349                    resolved = Some((item, path));
1350                }
1351            }
1352            let Some((item, path)) = resolved else {
1353                if runtime.network_policy() == NetworkPolicy::Offline {
1354                    anyhow::bail!(
1355                        "component {} requires network access ({}) but cache is missing; offline builds cannot download artifacts",
1356                        comp.name,
1357                        comp.r#ref
1358                    );
1359                }
1360                eprintln!(
1361                    "warning: component {} is not cached; skipping embed",
1362                    comp.name
1363                );
1364                comp.bundled = false;
1365                comp.bundled_path = None;
1366                comp.wasm_sha256 = None;
1367                comp.resolved_digest = None;
1368                continue;
1369            };
1370            ResolvedLockItem {
1371                item,
1372                cache_path: path,
1373            }
1374        };
1375
1376        let cache_path = resolved.cache_path;
1377        let bytes = fs::read(&cache_path)
1378            .with_context(|| format!("failed to read cached component {}", cache_path.display()))?;
1379        let wasm_sha256 = format!("{:x}", Sha256::digest(&bytes));
1380        let logical_path = if is_tag {
1381            format!("blobs/sha256/{}.wasm", wasm_sha256)
1382        } else {
1383            format!("components/{}.wasm", comp.name)
1384        };
1385
1386        comp.bundled = true;
1387        comp.bundled_path = Some(logical_path.clone());
1388        comp.wasm_sha256 = Some(wasm_sha256.clone());
1389        if is_tag {
1390            comp.digest = format!("sha256:{wasm_sha256}");
1391            comp.resolved_digest = Some(resolved.item.digest.clone());
1392        } else {
1393            comp.resolved_digest = None;
1394        }
1395
1396        if seen_paths.insert(logical_path.clone()) {
1397            artifacts.push(LockComponentBinary {
1398                logical_path: logical_path.clone(),
1399                source: cache_path.clone(),
1400            });
1401        }
1402        if let Some(component_id) = comp.component_id.as_ref()
1403            && comp.bundled
1404        {
1405            let alias_path = format!("components/{}.wasm", component_id.as_str());
1406            if alias_path != logical_path && seen_paths.insert(alias_path.clone()) {
1407                artifacts.push(LockComponentBinary {
1408                    logical_path: alias_path,
1409                    source: cache_path.clone(),
1410                });
1411            }
1412        }
1413    }
1414
1415    Ok(artifacts)
1416}
1417
1418struct ResolvedLockItem {
1419    item: greentic_distributor_client::ResolvedArtifact,
1420    cache_path: PathBuf,
1421}
1422
1423struct MaterializedComponents {
1424    components: Vec<ComponentManifest>,
1425    manifest_files: Vec<ComponentManifestFile>,
1426    manifest_paths: Option<BTreeMap<String, String>>,
1427}
1428
1429fn record_sbom_entry(entries: &mut Vec<SbomEntry>, path: &str, bytes: &[u8], media_type: &str) {
1430    entries.push(SbomEntry {
1431        path: path.to_string(),
1432        size: bytes.len() as u64,
1433        hash_blake3: blake3::hash(bytes).to_hex().to_string(),
1434        media_type: media_type.to_string(),
1435    });
1436}
1437
1438fn write_zip_entry(
1439    writer: &mut ZipWriter<std::fs::File>,
1440    logical_path: &str,
1441    bytes: &[u8],
1442    options: SimpleFileOptions,
1443) -> Result<()> {
1444    writer
1445        .start_file(logical_path, options)
1446        .with_context(|| format!("failed to start {}", logical_path))?;
1447    writer
1448        .write_all(bytes)
1449        .with_context(|| format!("failed to write {}", logical_path))?;
1450    Ok(())
1451}
1452
1453fn write_bytes(path: &Path, bytes: &[u8]) -> Result<()> {
1454    if let Some(parent) = path.parent() {
1455        fs::create_dir_all(parent)
1456            .with_context(|| format!("failed to create directory {}", parent.display()))?;
1457    }
1458    fs::write(path, bytes).with_context(|| format!("failed to write {}", path.display()))?;
1459    Ok(())
1460}
1461
1462fn write_stub_wasm(path: &Path) -> Result<()> {
1463    const STUB: &[u8] = &[0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
1464    write_bytes(path, STUB)
1465}
1466
1467fn collect_component_manifest_files(
1468    components: &[ComponentBinary],
1469    extra: &[ComponentManifestFile],
1470) -> Vec<ComponentManifestFile> {
1471    let mut files: Vec<ComponentManifestFile> = components
1472        .iter()
1473        .map(|binary| ComponentManifestFile {
1474            component_id: binary.id.clone(),
1475            manifest_path: binary.manifest_path.clone(),
1476            manifest_bytes: binary.manifest_bytes.clone(),
1477            manifest_hash_sha256: binary.manifest_hash_sha256.clone(),
1478        })
1479        .collect();
1480    files.extend(extra.iter().cloned());
1481    files.sort_by(|a, b| a.component_id.cmp(&b.component_id));
1482    files.dedup_by(|a, b| a.component_id == b.component_id);
1483    files
1484}
1485
1486fn materialize_flow_components(
1487    pack_dir: &Path,
1488    flows: &[PackFlowEntry],
1489    pack_lock: &greentic_pack::pack_lock::PackLockV1,
1490    components: &[ComponentBinary],
1491    lock_components: &[LockComponentBinary],
1492    require_component_manifests: bool,
1493) -> Result<MaterializedComponents> {
1494    let referenced = collect_flow_component_ids(flows);
1495    if referenced.is_empty() {
1496        return Ok(MaterializedComponents {
1497            components: Vec::new(),
1498            manifest_files: Vec::new(),
1499            manifest_paths: None,
1500        });
1501    }
1502
1503    let mut existing = BTreeSet::new();
1504    for component in components {
1505        existing.insert(component.id.clone());
1506    }
1507
1508    let mut lock_by_id = BTreeMap::new();
1509    let mut lock_by_name = BTreeMap::new();
1510    for entry in &pack_lock.components {
1511        if let Some(component_id) = entry.component_id.as_ref() {
1512            lock_by_id.insert(component_id.as_str().to_string(), entry);
1513        }
1514        lock_by_name.insert(entry.name.clone(), entry);
1515    }
1516
1517    let mut bundle_sources = BTreeMap::new();
1518    for entry in lock_components {
1519        bundle_sources.insert(entry.logical_path.clone(), entry.source.clone());
1520    }
1521
1522    let mut materialized_components = Vec::new();
1523    let mut manifest_files = Vec::new();
1524    let mut manifest_paths: BTreeMap<String, String> = BTreeMap::new();
1525
1526    for component_id in referenced {
1527        if existing.contains(&component_id) {
1528            continue;
1529        }
1530
1531        let lock_entry = lock_by_id
1532            .get(&component_id)
1533            .copied()
1534            .or_else(|| lock_by_name.get(&component_id).copied());
1535        let Some(lock_entry) = lock_entry else {
1536            handle_missing_component_manifest(&component_id, None, require_component_manifests)?;
1537            continue;
1538        };
1539        if !lock_entry.bundled {
1540            if require_component_manifests {
1541                anyhow::bail!(
1542                    "component {} is not bundled; cannot materialize manifest without local artifacts",
1543                    lock_entry.name
1544                );
1545            }
1546            eprintln!(
1547                "warning: component {} is not bundled; pack will emit PACK_COMPONENT_NOT_EXPLICIT",
1548                lock_entry.name
1549            );
1550            continue;
1551        }
1552
1553        let bundled_source = lock_entry
1554            .bundled_path
1555            .as_deref()
1556            .and_then(|path| bundle_sources.get(path));
1557        let manifest = load_component_manifest_for_lock(pack_dir, lock_entry, bundled_source)?;
1558
1559        let Some(manifest) = manifest else {
1560            handle_missing_component_manifest(
1561                &component_id,
1562                Some(&lock_entry.name),
1563                require_component_manifests,
1564            )?;
1565            continue;
1566        };
1567
1568        if let Some(lock_id) = lock_entry.component_id.as_ref()
1569            && manifest.id.as_str() != lock_id.as_str()
1570        {
1571            anyhow::bail!(
1572                "component manifest id {} does not match pack.lock component_id {}",
1573                manifest.id.as_str(),
1574                lock_id.as_str()
1575            );
1576        }
1577
1578        let manifest_file = component_manifest_file_from_manifest(&manifest)?;
1579        manifest_paths.insert(
1580            manifest.id.as_str().to_string(),
1581            manifest_file.manifest_path.clone(),
1582        );
1583        manifest_paths.insert(lock_entry.name.clone(), manifest_file.manifest_path.clone());
1584
1585        materialized_components.push(manifest);
1586        manifest_files.push(manifest_file);
1587    }
1588
1589    let manifest_paths = if manifest_paths.is_empty() {
1590        None
1591    } else {
1592        Some(manifest_paths)
1593    };
1594
1595    Ok(MaterializedComponents {
1596        components: materialized_components,
1597        manifest_files,
1598        manifest_paths,
1599    })
1600}
1601
1602fn collect_flow_component_ids(flows: &[PackFlowEntry]) -> BTreeSet<String> {
1603    let mut ids = BTreeSet::new();
1604    for flow in flows {
1605        for node in flow.flow.nodes.values() {
1606            if node.component.pack_alias.is_some() {
1607                continue;
1608            }
1609            let id = node.component.id.as_str();
1610            if !id.is_empty() {
1611                ids.insert(id.to_string());
1612            }
1613        }
1614    }
1615    ids
1616}
1617
1618fn load_component_manifest_for_lock(
1619    pack_dir: &Path,
1620    lock_entry: &greentic_pack::pack_lock::LockedComponent,
1621    bundled_source: Option<&PathBuf>,
1622) -> Result<Option<ComponentManifest>> {
1623    let mut search_paths = Vec::new();
1624    if let Some(component_id) = lock_entry.component_id.as_ref() {
1625        let id = component_id.as_str();
1626        search_paths.extend(component_manifest_search_paths(pack_dir, id));
1627    }
1628    search_paths.extend(component_manifest_search_paths(pack_dir, &lock_entry.name));
1629    if let Some(source) = bundled_source
1630        && let Some(parent) = source.parent()
1631    {
1632        search_paths.push(parent.join("component.manifest.cbor"));
1633        search_paths.push(parent.join("component.manifest.json"));
1634    }
1635
1636    for path in search_paths {
1637        if path.exists() {
1638            return Ok(Some(load_component_manifest_from_file(&path)?));
1639        }
1640    }
1641
1642    Ok(None)
1643}
1644
1645fn component_manifest_search_paths(pack_dir: &Path, name: &str) -> Vec<PathBuf> {
1646    vec![
1647        pack_dir
1648            .join("components")
1649            .join(format!("{name}.manifest.cbor")),
1650        pack_dir
1651            .join("components")
1652            .join(format!("{name}.manifest.json")),
1653        pack_dir
1654            .join("components")
1655            .join(name)
1656            .join("component.manifest.cbor"),
1657        pack_dir
1658            .join("components")
1659            .join(name)
1660            .join("component.manifest.json"),
1661    ]
1662}
1663
1664fn load_component_manifest_from_file(path: &Path) -> Result<ComponentManifest> {
1665    let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
1666    if path
1667        .extension()
1668        .and_then(|ext| ext.to_str())
1669        .is_some_and(|ext| ext.eq_ignore_ascii_case("cbor"))
1670    {
1671        let manifest = serde_cbor::from_slice(&bytes)
1672            .with_context(|| format!("{} is not valid CBOR", path.display()))?;
1673        return Ok(manifest);
1674    }
1675
1676    let manifest = serde_json::from_slice(&bytes)
1677        .with_context(|| format!("{} is not valid JSON", path.display()))?;
1678    Ok(manifest)
1679}
1680
1681fn component_manifest_file_from_manifest(
1682    manifest: &ComponentManifest,
1683) -> Result<ComponentManifestFile> {
1684    let manifest_bytes =
1685        serde_cbor::to_vec(manifest).context("encode component manifest to cbor")?;
1686    let mut sha = Sha256::new();
1687    sha.update(&manifest_bytes);
1688    let manifest_hash_sha256 = format!("sha256:{:x}", sha.finalize());
1689    let manifest_path = format!("components/{}.manifest.cbor", manifest.id.as_str());
1690
1691    Ok(ComponentManifestFile {
1692        component_id: manifest.id.as_str().to_string(),
1693        manifest_path,
1694        manifest_bytes,
1695        manifest_hash_sha256,
1696    })
1697}
1698
1699fn handle_missing_component_manifest(
1700    component_id: &str,
1701    component_name: Option<&str>,
1702    require_component_manifests: bool,
1703) -> Result<()> {
1704    let label = component_name.unwrap_or(component_id);
1705    if require_component_manifests {
1706        anyhow::bail!(
1707            "component manifest metadata missing for {} (supply component.manifest.json or use --require-component-manifests=false)",
1708            label
1709        );
1710    }
1711    eprintln!(
1712        "warning: component manifest metadata missing for {}; pack will emit PACK_COMPONENT_NOT_EXPLICIT",
1713        label
1714    );
1715    Ok(())
1716}
1717
1718fn aggregate_secret_requirements(
1719    components: &[ComponentConfig],
1720    override_path: Option<&Path>,
1721    default_scope: Option<&str>,
1722) -> Result<Vec<SecretRequirement>> {
1723    let default_scope = default_scope.map(parse_default_scope).transpose()?;
1724    let mut merged: BTreeMap<(String, String, String), SecretRequirement> = BTreeMap::new();
1725
1726    let mut process_req = |req: &SecretRequirement, source: &str| -> Result<()> {
1727        let mut req = req.clone();
1728        if req.scope.is_none() {
1729            if let Some(scope) = default_scope.clone() {
1730                req.scope = Some(scope);
1731                tracing::warn!(
1732                    key = %secret_key_string(&req),
1733                    source,
1734                    "secret requirement missing scope; applying default scope"
1735                );
1736            } else {
1737                anyhow::bail!(
1738                    "secret requirement {} from {} is missing scope (provide --default-secret-scope or fix the component manifest)",
1739                    secret_key_string(&req),
1740                    source
1741                );
1742            }
1743        }
1744        let scope = req.scope.as_ref().expect("scope present");
1745        let fmt = fmt_key(&req);
1746        let key_tuple = (req.key.clone().into(), scope_key(scope), fmt.clone());
1747        if let Some(existing) = merged.get_mut(&key_tuple) {
1748            merge_requirement(existing, &req);
1749        } else {
1750            merged.insert(key_tuple, req);
1751        }
1752        Ok(())
1753    };
1754
1755    for component in components {
1756        if let Some(secret_caps) = component.capabilities.host.secrets.as_ref() {
1757            for req in &secret_caps.required {
1758                process_req(req, &component.id)?;
1759            }
1760        }
1761    }
1762
1763    if let Some(path) = override_path {
1764        let contents = fs::read_to_string(path)
1765            .with_context(|| format!("failed to read secrets override {}", path.display()))?;
1766        let value: serde_json::Value = if path
1767            .extension()
1768            .and_then(|ext| ext.to_str())
1769            .map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
1770            .unwrap_or(false)
1771        {
1772            let yaml: YamlValue = serde_yaml_bw::from_str(&contents)
1773                .with_context(|| format!("{} is not valid YAML", path.display()))?;
1774            serde_json::to_value(yaml).context("failed to normalise YAML secrets override")?
1775        } else {
1776            serde_json::from_str(&contents)
1777                .with_context(|| format!("{} is not valid JSON", path.display()))?
1778        };
1779
1780        let overrides: Vec<SecretRequirement> =
1781            serde_json::from_value(value).with_context(|| {
1782                format!(
1783                    "{} must be an array of secret requirements (migration bridge)",
1784                    path.display()
1785                )
1786            })?;
1787        for req in &overrides {
1788            process_req(req, &format!("override:{}", path.display()))?;
1789        }
1790    }
1791
1792    let mut out: Vec<SecretRequirement> = merged.into_values().collect();
1793    out.sort_by(|a, b| {
1794        let a_scope = a.scope.as_ref().map(scope_key).unwrap_or_default();
1795        let b_scope = b.scope.as_ref().map(scope_key).unwrap_or_default();
1796        (a_scope, secret_key_string(a), fmt_key(a)).cmp(&(
1797            b_scope,
1798            secret_key_string(b),
1799            fmt_key(b),
1800        ))
1801    });
1802    Ok(out)
1803}
1804
1805fn fmt_key(req: &SecretRequirement) -> String {
1806    req.format
1807        .as_ref()
1808        .map(|f| format!("{:?}", f))
1809        .unwrap_or_else(|| "unspecified".to_string())
1810}
1811
1812fn scope_key(scope: &SecretScope) -> String {
1813    format!(
1814        "{}/{}/{}",
1815        &scope.env,
1816        &scope.tenant,
1817        scope
1818            .team
1819            .as_deref()
1820            .map(|t| t.to_string())
1821            .unwrap_or_else(|| "_".to_string())
1822    )
1823}
1824
1825fn secret_key_string(req: &SecretRequirement) -> String {
1826    let key: String = req.key.clone().into();
1827    key
1828}
1829
1830fn merge_requirement(base: &mut SecretRequirement, incoming: &SecretRequirement) {
1831    if base.description.is_none() {
1832        base.description = incoming.description.clone();
1833    }
1834    if let Some(schema) = &incoming.schema {
1835        if base.schema.is_none() {
1836            base.schema = Some(schema.clone());
1837        } else if base.schema.as_ref() != Some(schema) {
1838            tracing::warn!(
1839                key = %secret_key_string(base),
1840                "conflicting secret schema encountered; keeping first"
1841            );
1842        }
1843    }
1844
1845    if !incoming.examples.is_empty() {
1846        for example in &incoming.examples {
1847            if !base.examples.contains(example) {
1848                base.examples.push(example.clone());
1849            }
1850        }
1851    }
1852
1853    base.required = base.required || incoming.required;
1854}
1855
1856fn parse_default_scope(raw: &str) -> Result<SecretScope> {
1857    let parts: Vec<_> = raw.split('/').collect();
1858    if parts.len() < 2 || parts.len() > 3 {
1859        anyhow::bail!(
1860            "default secret scope must be ENV/TENANT or ENV/TENANT/TEAM (got {})",
1861            raw
1862        );
1863    }
1864    Ok(SecretScope {
1865        env: parts[0].to_string(),
1866        tenant: parts[1].to_string(),
1867        team: parts.get(2).map(|s| s.to_string()),
1868    })
1869}
1870
1871fn write_secret_requirements_file(
1872    pack_root: &Path,
1873    requirements: &[SecretRequirement],
1874    logical_name: &str,
1875) -> Result<PathBuf> {
1876    let path = pack_root.join(".packc").join(logical_name);
1877    if let Some(parent) = path.parent() {
1878        fs::create_dir_all(parent)
1879            .with_context(|| format!("failed to create {}", parent.display()))?;
1880    }
1881    let data = serde_json::to_vec_pretty(&requirements)
1882        .context("failed to serialise secret requirements")?;
1883    fs::write(&path, data).with_context(|| format!("failed to write {}", path.display()))?;
1884    Ok(path)
1885}
1886
1887#[cfg(test)]
1888mod tests {
1889    use super::*;
1890    use crate::config::BootstrapConfig;
1891    use crate::runtime::resolve_runtime;
1892    use greentic_pack::pack_lock::{LockedComponent, PackLockV1};
1893    use greentic_types::ComponentId;
1894    use greentic_types::flow::FlowKind;
1895    use serde_json::json;
1896    use sha2::{Digest, Sha256};
1897    use std::collections::BTreeSet;
1898    use std::fs::File;
1899    use std::io::Read;
1900    use std::path::Path;
1901    use std::{fs, path::PathBuf};
1902    use tempfile::tempdir;
1903    use zip::ZipArchive;
1904
1905    #[test]
1906    fn map_kind_accepts_known_values() {
1907        assert!(matches!(
1908            map_kind("application").unwrap(),
1909            PackKind::Application
1910        ));
1911        assert!(matches!(map_kind("provider").unwrap(), PackKind::Provider));
1912        assert!(matches!(
1913            map_kind("infrastructure").unwrap(),
1914            PackKind::Infrastructure
1915        ));
1916        assert!(matches!(map_kind("library").unwrap(), PackKind::Library));
1917        assert!(map_kind("unknown").is_err());
1918    }
1919
1920    #[test]
1921    fn collect_assets_preserves_relative_paths() {
1922        let root = PathBuf::from("/packs/demo");
1923        let assets = vec![AssetConfig {
1924            path: root.join("assets").join("foo.txt"),
1925        }];
1926        let collected = collect_assets(&assets, &root).expect("collect assets");
1927        assert_eq!(collected[0].logical_path, "assets/foo.txt");
1928    }
1929
1930    fn write_sample_manifest(path: &Path, component_id: &str) {
1931        let manifest: ComponentManifest = serde_json::from_value(json!({
1932            "id": component_id,
1933            "version": "0.1.0",
1934            "supports": [],
1935            "world": "greentic:component/component@0.5.0",
1936            "profiles": { "default": "stateless", "supported": ["stateless"] },
1937            "capabilities": { "wasi": {}, "host": {} },
1938            "operations": [],
1939            "resources": {},
1940            "dev_flows": {}
1941        }))
1942        .expect("manifest");
1943        let bytes = serde_cbor::to_vec(&manifest).expect("encode manifest");
1944        fs::write(path, bytes).expect("write manifest");
1945    }
1946
1947    #[test]
1948    fn load_component_manifest_from_disk_supports_id_specific_files() {
1949        let temp = tempdir().expect("temp dir");
1950        let components = temp.path().join("components");
1951        fs::create_dir_all(&components).expect("create components dir");
1952        let wasm = components.join("component.wasm");
1953        fs::write(&wasm, b"wasm").expect("write wasm");
1954        let manifest_name = components.join("foo.component.manifest.cbor");
1955        write_sample_manifest(&manifest_name, "foo.component");
1956
1957        let manifest =
1958            load_component_manifest_from_disk(&wasm, "foo.component").expect("load manifest");
1959        let manifest = manifest.expect("manifest present");
1960        assert_eq!(manifest.id.to_string(), "foo.component");
1961    }
1962
1963    #[test]
1964    fn load_component_manifest_from_disk_accepts_generic_names() {
1965        let temp = tempdir().expect("temp dir");
1966        let components = temp.path().join("components");
1967        fs::create_dir_all(&components).expect("create components dir");
1968        let wasm = components.join("component.wasm");
1969        fs::write(&wasm, b"wasm").expect("write wasm");
1970        let manifest_name = components.join("component.manifest.cbor");
1971        write_sample_manifest(&manifest_name, "component");
1972
1973        let manifest =
1974            load_component_manifest_from_disk(&wasm, "component").expect("load manifest");
1975        let manifest = manifest.expect("manifest present");
1976        assert_eq!(manifest.id.to_string(), "component");
1977    }
1978
1979    #[test]
1980    fn collect_extra_dir_files_skips_hidden_and_known_dirs() {
1981        let temp = tempdir().expect("temp dir");
1982        let root = temp.path();
1983        fs::create_dir_all(root.join("schemas")).expect("schemas dir");
1984        fs::create_dir_all(root.join("schemas").join(".nested")).expect("nested hidden dir");
1985        fs::create_dir_all(root.join(".hidden")).expect("hidden dir");
1986        fs::create_dir_all(root.join("assets")).expect("assets dir");
1987        fs::write(root.join("README.txt"), b"root").expect("root file");
1988        fs::write(root.join("schemas").join("config.schema.json"), b"{}").expect("schema file");
1989        fs::write(
1990            root.join("schemas").join(".nested").join("skip.json"),
1991            b"{}",
1992        )
1993        .expect("nested hidden file");
1994        fs::write(root.join(".hidden").join("secret.txt"), b"nope").expect("hidden file");
1995        fs::write(root.join("assets").join("asset.txt"), b"nope").expect("asset file");
1996
1997        let collected = collect_extra_dir_files(root).expect("collect extra dirs");
1998        let paths: BTreeSet<_> = collected.iter().map(|e| e.logical_path.as_str()).collect();
1999        assert!(paths.contains("README.txt"));
2000        assert!(paths.contains("schemas/config.schema.json"));
2001        assert!(!paths.contains("schemas/.nested/skip.json"));
2002        assert!(!paths.contains(".hidden/secret.txt"));
2003        assert!(!paths.iter().any(|path| path.starts_with("assets/")));
2004    }
2005
2006    #[test]
2007    fn collect_extra_dir_files_skips_reserved_sbom_files() {
2008        let temp = tempdir().expect("temp dir");
2009        let root = temp.path();
2010        fs::write(root.join("sbom.cbor"), b"binary").expect("sbom file");
2011        fs::write(root.join("sbom.json"), b"{}").expect("sbom json");
2012        fs::write(root.join("README.md"), b"hello").expect("root file");
2013
2014        let collected = collect_extra_dir_files(root).expect("collect extra dirs");
2015        let paths: BTreeSet<_> = collected.iter().map(|e| e.logical_path.as_str()).collect();
2016        assert!(paths.contains("README.md"));
2017        assert!(!paths.contains("sbom.cbor"));
2018        assert!(!paths.contains("sbom.json"));
2019    }
2020
2021    #[test]
2022    fn build_bootstrap_requires_known_references() {
2023        let config = pack_config_with_bootstrap(BootstrapConfig {
2024            install_flow: Some("flow.a".to_string()),
2025            upgrade_flow: None,
2026            installer_component: Some("component.a".to_string()),
2027        });
2028        let flows = vec![flow_entry("flow.a")];
2029        let components = vec![minimal_component_manifest("component.a")];
2030
2031        let bootstrap = build_bootstrap(&config, &flows, &components)
2032            .expect("bootstrap populated")
2033            .expect("bootstrap present");
2034
2035        assert_eq!(bootstrap.install_flow.as_deref(), Some("flow.a"));
2036        assert_eq!(bootstrap.upgrade_flow, None);
2037        assert_eq!(
2038            bootstrap.installer_component.as_deref(),
2039            Some("component.a")
2040        );
2041    }
2042
2043    #[test]
2044    fn build_bootstrap_rejects_unknown_flow() {
2045        let config = pack_config_with_bootstrap(BootstrapConfig {
2046            install_flow: Some("missing".to_string()),
2047            upgrade_flow: None,
2048            installer_component: Some("component.a".to_string()),
2049        });
2050        let flows = vec![flow_entry("flow.a")];
2051        let components = vec![minimal_component_manifest("component.a")];
2052
2053        let err = build_bootstrap(&config, &flows, &components).unwrap_err();
2054        assert!(
2055            err.to_string()
2056                .contains("bootstrap.install_flow references unknown flow"),
2057            "unexpected error: {err}"
2058        );
2059    }
2060
2061    #[test]
2062    fn component_manifest_without_dev_flows_defaults_to_empty() {
2063        let manifest: ComponentManifest = serde_json::from_value(json!({
2064            "id": "component.dev",
2065            "version": "1.0.0",
2066            "supports": ["messaging"],
2067            "world": "greentic:demo@1.0.0",
2068            "profiles": { "default": "default", "supported": ["default"] },
2069            "capabilities": { "wasi": {}, "host": {} },
2070            "operations": [],
2071            "resources": {}
2072        }))
2073        .expect("manifest without dev_flows");
2074
2075        assert!(manifest.dev_flows.is_empty());
2076
2077        let pack_manifest = pack_manifest_with_component(manifest.clone());
2078        let encoded = encode_pack_manifest(&pack_manifest).expect("encode manifest");
2079        let decoded: PackManifest =
2080            greentic_types::decode_pack_manifest(&encoded).expect("decode manifest");
2081        let stored = decoded
2082            .components
2083            .iter()
2084            .find(|item| item.id == manifest.id)
2085            .expect("component present");
2086        assert!(stored.dev_flows.is_empty());
2087    }
2088
2089    #[test]
2090    fn dev_flows_round_trip_in_manifest_and_gtpack() {
2091        let component = manifest_with_dev_flow();
2092        let pack_manifest = pack_manifest_with_component(component.clone());
2093        let manifest_bytes = encode_pack_manifest(&pack_manifest).expect("encode manifest");
2094
2095        let decoded: PackManifest =
2096            greentic_types::decode_pack_manifest(&manifest_bytes).expect("decode manifest");
2097        let decoded_component = decoded
2098            .components
2099            .iter()
2100            .find(|item| item.id == component.id)
2101            .expect("component present");
2102        assert_eq!(decoded_component.dev_flows, component.dev_flows);
2103
2104        let temp = tempdir().expect("temp dir");
2105        let wasm_path = temp.path().join("component.wasm");
2106        write_stub_wasm(&wasm_path).expect("write stub wasm");
2107
2108        let build = BuildProducts {
2109            manifest: pack_manifest,
2110            components: vec![ComponentBinary {
2111                id: component.id.to_string(),
2112                source: wasm_path,
2113                manifest_bytes: serde_cbor::to_vec(&component).expect("component cbor"),
2114                manifest_path: format!("components/{}.manifest.cbor", component.id),
2115                manifest_hash_sha256: {
2116                    let mut sha = Sha256::new();
2117                    sha.update(serde_cbor::to_vec(&component).expect("component cbor"));
2118                    format!("sha256:{:x}", sha.finalize())
2119                },
2120            }],
2121            lock_components: Vec::new(),
2122            component_manifest_files: Vec::new(),
2123            flow_files: Vec::new(),
2124            assets: Vec::new(),
2125            extra_files: Vec::new(),
2126        };
2127
2128        let out = temp.path().join("demo.gtpack");
2129        package_gtpack(&out, &manifest_bytes, &build, BundleMode::Cache).expect("package gtpack");
2130
2131        let mut archive = ZipArchive::new(fs::File::open(&out).expect("open gtpack"))
2132            .expect("read gtpack archive");
2133        let mut manifest_entry = archive.by_name("manifest.cbor").expect("manifest.cbor");
2134        let mut stored = Vec::new();
2135        manifest_entry
2136            .read_to_end(&mut stored)
2137            .expect("read manifest");
2138        let decoded: PackManifest =
2139            greentic_types::decode_pack_manifest(&stored).expect("decode packaged manifest");
2140
2141        let stored_component = decoded
2142            .components
2143            .iter()
2144            .find(|item| item.id == component.id)
2145            .expect("component preserved");
2146        assert_eq!(stored_component.dev_flows, component.dev_flows);
2147    }
2148
2149    #[test]
2150    fn component_sources_extension_respects_bundle() {
2151        let lock_tag = PackLockV1::new(vec![LockedComponent {
2152            name: "demo.tagged".into(),
2153            r#ref: "oci://ghcr.io/demo/component:1.0.0".into(),
2154            digest: "sha256:deadbeef".into(),
2155            component_id: None,
2156            bundled: true,
2157            bundled_path: Some("blobs/sha256/deadbeef.wasm".into()),
2158            wasm_sha256: Some("deadbeef".repeat(8)),
2159            resolved_digest: Some("sha256:deadbeef".into()),
2160        }]);
2161
2162        let ext_none = merge_component_sources_extension(None, &lock_tag, BundleMode::None, None)
2163            .expect("ext");
2164        let value = match ext_none
2165            .unwrap()
2166            .get(EXT_COMPONENT_SOURCES_V1)
2167            .and_then(|e| e.inline.as_ref())
2168        {
2169            Some(ExtensionInline::Other(v)) => v.clone(),
2170            _ => panic!("missing inline"),
2171        };
2172        let decoded = ComponentSourcesV1::from_extension_value(&value).expect("decode");
2173        assert!(matches!(
2174            decoded.components[0].artifact,
2175            ArtifactLocationV1::Inline { .. }
2176        ));
2177
2178        let lock_digest = PackLockV1::new(vec![LockedComponent {
2179            name: "demo.component".into(),
2180            r#ref: "oci://ghcr.io/demo/component@sha256:deadbeef".into(),
2181            digest: "sha256:deadbeef".into(),
2182            component_id: None,
2183            bundled: false,
2184            bundled_path: None,
2185            wasm_sha256: None,
2186            resolved_digest: None,
2187        }]);
2188
2189        let ext_none =
2190            merge_component_sources_extension(None, &lock_digest, BundleMode::None, None)
2191                .expect("ext");
2192        let value = match ext_none
2193            .unwrap()
2194            .get(EXT_COMPONENT_SOURCES_V1)
2195            .and_then(|e| e.inline.as_ref())
2196        {
2197            Some(ExtensionInline::Other(v)) => v.clone(),
2198            _ => panic!("missing inline"),
2199        };
2200        let decoded = ComponentSourcesV1::from_extension_value(&value).expect("decode");
2201        assert!(matches!(
2202            decoded.components[0].artifact,
2203            ArtifactLocationV1::Remote
2204        ));
2205
2206        let lock_digest_bundled = PackLockV1::new(vec![LockedComponent {
2207            name: "demo.component".into(),
2208            r#ref: "oci://ghcr.io/demo/component@sha256:deadbeef".into(),
2209            digest: "sha256:deadbeef".into(),
2210            component_id: None,
2211            bundled: true,
2212            bundled_path: Some("components/demo.component.wasm".into()),
2213            wasm_sha256: Some("deadbeef".repeat(8)),
2214            resolved_digest: None,
2215        }]);
2216
2217        let ext_cache =
2218            merge_component_sources_extension(None, &lock_digest_bundled, BundleMode::Cache, None)
2219                .expect("ext");
2220        let value = match ext_cache
2221            .unwrap()
2222            .get(EXT_COMPONENT_SOURCES_V1)
2223            .and_then(|e| e.inline.as_ref())
2224        {
2225            Some(ExtensionInline::Other(v)) => v.clone(),
2226            _ => panic!("missing inline"),
2227        };
2228        let decoded = ComponentSourcesV1::from_extension_value(&value).expect("decode");
2229        assert!(matches!(
2230            decoded.components[0].artifact,
2231            ArtifactLocationV1::Inline { .. }
2232        ));
2233    }
2234
2235    #[test]
2236    fn component_sources_extension_skips_file_refs() {
2237        let lock = PackLockV1::new(vec![LockedComponent {
2238            name: "local.component".into(),
2239            r#ref: "file:///tmp/component.wasm".into(),
2240            digest: "sha256:deadbeef".into(),
2241            component_id: None,
2242            bundled: false,
2243            bundled_path: None,
2244            wasm_sha256: None,
2245            resolved_digest: None,
2246        }]);
2247
2248        let ext_none =
2249            merge_component_sources_extension(None, &lock, BundleMode::Cache, None).expect("ext");
2250        assert!(ext_none.is_none(), "file refs should be omitted");
2251
2252        let lock = PackLockV1::new(vec![
2253            LockedComponent {
2254                name: "local.component".into(),
2255                r#ref: "file:///tmp/component.wasm".into(),
2256                digest: "sha256:deadbeef".into(),
2257                component_id: None,
2258                bundled: false,
2259                bundled_path: None,
2260                wasm_sha256: None,
2261                resolved_digest: None,
2262            },
2263            LockedComponent {
2264                name: "remote.component".into(),
2265                r#ref: "oci://ghcr.io/demo/component:2.0.0".into(),
2266                digest: "sha256:cafebabe".into(),
2267                component_id: None,
2268                bundled: false,
2269                bundled_path: None,
2270                wasm_sha256: None,
2271                resolved_digest: None,
2272            },
2273        ]);
2274
2275        let ext_some =
2276            merge_component_sources_extension(None, &lock, BundleMode::None, None).expect("ext");
2277        let value = match ext_some
2278            .unwrap()
2279            .get(EXT_COMPONENT_SOURCES_V1)
2280            .and_then(|e| e.inline.as_ref())
2281        {
2282            Some(ExtensionInline::Other(v)) => v.clone(),
2283            _ => panic!("missing inline"),
2284        };
2285        let decoded = ComponentSourcesV1::from_extension_value(&value).expect("decode");
2286        assert_eq!(decoded.components.len(), 1);
2287        assert!(matches!(
2288            decoded.components[0].source,
2289            ComponentSourceRef::Oci(_)
2290        ));
2291    }
2292
2293    #[test]
2294    fn build_embeds_lock_components_from_cache() {
2295        let rt = tokio::runtime::Runtime::new().expect("runtime");
2296        rt.block_on(async {
2297            let temp = tempdir().expect("temp dir");
2298            let pack_dir = temp.path().join("pack");
2299            fs::create_dir_all(pack_dir.join("flows")).expect("flows dir");
2300            fs::create_dir_all(pack_dir.join("components")).expect("components dir");
2301
2302            let wasm_path = pack_dir.join("components/dummy.wasm");
2303            fs::write(&wasm_path, [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00])
2304                .expect("write wasm");
2305
2306            let flow_path = pack_dir.join("flows/main.ygtc");
2307            fs::write(
2308                &flow_path,
2309                r#"id: main
2310type: messaging
2311start: call
2312nodes:
2313  call:
2314    handle_message:
2315      text: "hi"
2316    routing: out
2317"#,
2318            )
2319            .expect("write flow");
2320
2321            let cache_dir = temp.path().join("cache");
2322            let cached_bytes = b"cached-component";
2323            let digest = format!("sha256:{:x}", Sha256::digest(cached_bytes));
2324            let cache_path = cache_dir
2325                .join(digest.trim_start_matches("sha256:"))
2326                .join("component.wasm");
2327            fs::create_dir_all(cache_path.parent().expect("cache parent")).expect("cache dir");
2328            fs::write(&cache_path, cached_bytes).expect("write cached");
2329
2330            let summary = serde_json::json!({
2331                "schema_version": 1,
2332                "flow": "main.ygtc",
2333                "nodes": {
2334                    "call": {
2335                        "component_id": "dummy.component",
2336                        "source": {
2337                            "kind": "oci",
2338                            "ref": format!("oci://ghcr.io/demo/component@{digest}")
2339                        },
2340                        "digest": digest
2341                    }
2342                }
2343            });
2344            fs::write(
2345                flow_path.with_extension("ygtc.resolve.summary.json"),
2346                serde_json::to_vec_pretty(&summary).expect("summary json"),
2347            )
2348            .expect("write summary");
2349
2350            let pack_yaml = r#"pack_id: demo.lock-bundle
2351version: 0.1.0
2352kind: application
2353publisher: Test
2354components:
2355  - id: dummy.component
2356    version: "0.1.0"
2357    world: "greentic:component/component@0.5.0"
2358    supports: ["messaging"]
2359    profiles:
2360      default: "stateless"
2361      supported: ["stateless"]
2362    capabilities:
2363      wasi: {}
2364      host: {}
2365    operations:
2366      - name: "handle_message"
2367        input_schema: {}
2368        output_schema: {}
2369    wasm: "components/dummy.wasm"
2370flows:
2371  - id: main
2372    file: flows/main.ygtc
2373    tags: [default]
2374    entrypoints: [main]
2375"#;
2376            fs::write(pack_dir.join("pack.yaml"), pack_yaml).expect("pack.yaml");
2377
2378            let runtime = crate::runtime::resolve_runtime(
2379                Some(pack_dir.as_path()),
2380                Some(cache_dir.as_path()),
2381                true,
2382                None,
2383            )
2384            .expect("runtime");
2385
2386            let opts = BuildOptions {
2387                pack_dir: pack_dir.clone(),
2388                component_out: None,
2389                manifest_out: pack_dir.join("dist/manifest.cbor"),
2390                sbom_out: None,
2391                gtpack_out: Some(pack_dir.join("dist/pack.gtpack")),
2392                lock_path: pack_dir.join("pack.lock.json"),
2393                bundle: BundleMode::Cache,
2394                dry_run: false,
2395                secrets_req: None,
2396                default_secret_scope: None,
2397                allow_oci_tags: false,
2398                require_component_manifests: false,
2399                no_extra_dirs: false,
2400                runtime,
2401                skip_update: false,
2402            };
2403
2404            run(&opts).await.expect("build");
2405
2406            let gtpack_path = opts.gtpack_out.expect("gtpack path");
2407            let mut archive = ZipArchive::new(File::open(&gtpack_path).expect("open gtpack"))
2408                .expect("read gtpack");
2409            assert!(
2410                archive.by_name("components/main___call.wasm").is_ok(),
2411                "missing lock component artifact in gtpack"
2412            );
2413        });
2414    }
2415
2416    #[test]
2417    #[ignore = "requires network access to fetch OCI component"]
2418    fn build_fetches_and_embeds_lock_components_online() {
2419        if std::env::var("GREENTIC_PACK_ONLINE").is_err() {
2420            return;
2421        }
2422        let rt = tokio::runtime::Runtime::new().expect("runtime");
2423        rt.block_on(async {
2424            let temp = tempdir().expect("temp dir");
2425            let pack_dir = temp.path().join("pack");
2426            fs::create_dir_all(pack_dir.join("flows")).expect("flows dir");
2427            fs::create_dir_all(pack_dir.join("components")).expect("components dir");
2428
2429            let wasm_path = pack_dir.join("components/dummy.wasm");
2430            fs::write(&wasm_path, [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00])
2431                .expect("write wasm");
2432
2433            let flow_path = pack_dir.join("flows/main.ygtc");
2434            fs::write(
2435                &flow_path,
2436                r#"id: main
2437type: messaging
2438start: call
2439nodes:
2440  call:
2441    handle_message:
2442      text: "hi"
2443    routing: out
2444"#,
2445            )
2446            .expect("write flow");
2447
2448            let digest =
2449                "sha256:0904bee6ecd737506265e3f38f3e4fe6b185c20fd1b0e7c06ce03cdeedc00340";
2450            let summary = serde_json::json!({
2451                "schema_version": 1,
2452                "flow": "main.ygtc",
2453                "nodes": {
2454                    "call": {
2455                        "component_id": "dummy.component",
2456                        "source": {
2457                            "kind": "oci",
2458                            "ref": format!("oci://ghcr.io/greentic-ai/components/templates@{digest}")
2459                        },
2460                        "digest": digest
2461                    }
2462                }
2463            });
2464            fs::write(
2465                flow_path.with_extension("ygtc.resolve.summary.json"),
2466                serde_json::to_vec_pretty(&summary).expect("summary json"),
2467            )
2468            .expect("write summary");
2469
2470            let pack_yaml = r#"pack_id: demo.lock-online
2471version: 0.1.0
2472kind: application
2473publisher: Test
2474components:
2475  - id: dummy.component
2476    version: "0.1.0"
2477    world: "greentic:component/component@0.5.0"
2478    supports: ["messaging"]
2479    profiles:
2480      default: "stateless"
2481      supported: ["stateless"]
2482    capabilities:
2483      wasi: {}
2484      host: {}
2485    operations:
2486      - name: "handle_message"
2487        input_schema: {}
2488        output_schema: {}
2489    wasm: "components/dummy.wasm"
2490flows:
2491  - id: main
2492    file: flows/main.ygtc
2493    tags: [default]
2494    entrypoints: [main]
2495"#;
2496            fs::write(pack_dir.join("pack.yaml"), pack_yaml).expect("pack.yaml");
2497
2498            let cache_dir = temp.path().join("cache");
2499            let runtime = crate::runtime::resolve_runtime(
2500                Some(pack_dir.as_path()),
2501                Some(cache_dir.as_path()),
2502                false,
2503                None,
2504            )
2505            .expect("runtime");
2506
2507            let opts = BuildOptions {
2508                pack_dir: pack_dir.clone(),
2509                component_out: None,
2510                manifest_out: pack_dir.join("dist/manifest.cbor"),
2511                sbom_out: None,
2512                gtpack_out: Some(pack_dir.join("dist/pack.gtpack")),
2513                lock_path: pack_dir.join("pack.lock.json"),
2514                bundle: BundleMode::Cache,
2515                dry_run: false,
2516                secrets_req: None,
2517                default_secret_scope: None,
2518                allow_oci_tags: false,
2519                require_component_manifests: false,
2520                no_extra_dirs: false,
2521                runtime,
2522                skip_update: false,
2523            };
2524
2525            run(&opts).await.expect("build");
2526
2527            let gtpack_path = opts.gtpack_out.expect("gtpack path");
2528            let mut archive =
2529                ZipArchive::new(File::open(&gtpack_path).expect("open gtpack"))
2530                    .expect("read gtpack");
2531            assert!(
2532                archive.by_name("components/main___call.wasm").is_ok(),
2533                "missing lock component artifact in gtpack"
2534            );
2535        });
2536    }
2537
2538    #[test]
2539    fn aggregate_secret_requirements_dedupes_and_sorts() {
2540        let component: ComponentConfig = serde_json::from_value(json!({
2541            "id": "component.a",
2542            "version": "1.0.0",
2543            "world": "greentic:demo@1.0.0",
2544            "supports": [],
2545            "profiles": { "default": "default", "supported": ["default"] },
2546            "capabilities": {
2547                "wasi": {},
2548                "host": {
2549                    "secrets": {
2550                        "required": [
2551                    {
2552                        "key": "db/password",
2553                        "required": true,
2554                        "scope": { "env": "dev", "tenant": "t1" },
2555                        "format": "text",
2556                        "description": "primary"
2557                    }
2558                ]
2559            }
2560        }
2561            },
2562            "wasm": "component.wasm",
2563            "operations": [],
2564            "resources": {}
2565        }))
2566        .expect("component config");
2567
2568        let dupe: ComponentConfig = serde_json::from_value(json!({
2569            "id": "component.b",
2570            "version": "1.0.0",
2571            "world": "greentic:demo@1.0.0",
2572            "supports": [],
2573            "profiles": { "default": "default", "supported": ["default"] },
2574            "capabilities": {
2575                "wasi": {},
2576                "host": {
2577                    "secrets": {
2578                        "required": [
2579                            {
2580                        "key": "db/password",
2581                        "required": true,
2582                        "scope": { "env": "dev", "tenant": "t1" },
2583                        "format": "text",
2584                        "description": "secondary",
2585                        "examples": ["example"]
2586                    }
2587                ]
2588            }
2589                }
2590            },
2591            "wasm": "component.wasm",
2592            "operations": [],
2593            "resources": {}
2594        }))
2595        .expect("component config");
2596
2597        let reqs = aggregate_secret_requirements(&[component, dupe], None, None)
2598            .expect("aggregate secrets");
2599        assert_eq!(reqs.len(), 1);
2600        let req = &reqs[0];
2601        assert_eq!(req.description.as_deref(), Some("primary"));
2602        assert!(req.examples.contains(&"example".to_string()));
2603    }
2604
2605    fn pack_config_with_bootstrap(bootstrap: BootstrapConfig) -> PackConfig {
2606        PackConfig {
2607            pack_id: "demo.pack".to_string(),
2608            version: "1.0.0".to_string(),
2609            kind: "application".to_string(),
2610            publisher: "demo".to_string(),
2611            name: None,
2612            bootstrap: Some(bootstrap),
2613            components: Vec::new(),
2614            dependencies: Vec::new(),
2615            flows: Vec::new(),
2616            assets: Vec::new(),
2617            extensions: None,
2618        }
2619    }
2620
2621    fn flow_entry(id: &str) -> PackFlowEntry {
2622        let flow: Flow = serde_json::from_value(json!({
2623            "schema_version": "flow/v1",
2624            "id": id,
2625            "kind": "messaging"
2626        }))
2627        .expect("flow json");
2628
2629        PackFlowEntry {
2630            id: FlowId::new(id).expect("flow id"),
2631            kind: FlowKind::Messaging,
2632            flow,
2633            tags: Vec::new(),
2634            entrypoints: Vec::new(),
2635        }
2636    }
2637
2638    fn minimal_component_manifest(id: &str) -> ComponentManifest {
2639        serde_json::from_value(json!({
2640            "id": id,
2641            "version": "1.0.0",
2642            "supports": [],
2643            "world": "greentic:demo@1.0.0",
2644            "profiles": { "default": "default", "supported": ["default"] },
2645            "capabilities": { "wasi": {}, "host": {} },
2646            "operations": [],
2647            "resources": {}
2648        }))
2649        .expect("component manifest")
2650    }
2651
2652    fn manifest_with_dev_flow() -> ComponentManifest {
2653        serde_json::from_str(include_str!(
2654            "../tests/fixtures/component_manifest_with_dev_flows.json"
2655        ))
2656        .expect("fixture manifest")
2657    }
2658
2659    fn pack_manifest_with_component(component: ComponentManifest) -> PackManifest {
2660        let flow = serde_json::from_value(json!({
2661            "schema_version": "flow/v1",
2662            "id": "flow.dev",
2663            "kind": "messaging"
2664        }))
2665        .expect("flow json");
2666
2667        PackManifest {
2668            schema_version: "pack-v1".to_string(),
2669            pack_id: PackId::new("demo.pack").expect("pack id"),
2670            name: None,
2671            version: Version::parse("1.0.0").expect("version"),
2672            kind: PackKind::Application,
2673            publisher: "demo".to_string(),
2674            components: vec![component],
2675            flows: vec![PackFlowEntry {
2676                id: FlowId::new("flow.dev").expect("flow id"),
2677                kind: FlowKind::Messaging,
2678                flow,
2679                tags: Vec::new(),
2680                entrypoints: Vec::new(),
2681            }],
2682            dependencies: Vec::new(),
2683            capabilities: Vec::new(),
2684            secret_requirements: Vec::new(),
2685            signatures: PackSignatures::default(),
2686            bootstrap: None,
2687            extensions: None,
2688        }
2689    }
2690
2691    #[tokio::test]
2692    async fn offline_build_requires_cached_remote_component() {
2693        let temp = tempdir().expect("temp dir");
2694        let cache_dir = temp.path().join("cache");
2695        fs::create_dir_all(&cache_dir).expect("create cache dir");
2696        let project_root = Path::new(env!("CARGO_MANIFEST_DIR"))
2697            .parent()
2698            .expect("workspace root");
2699        let runtime = resolve_runtime(Some(project_root), Some(cache_dir.as_path()), true, None)
2700            .expect("resolve runtime");
2701
2702        let mut lock = PackLockV1::new(vec![LockedComponent {
2703            name: "remote".to_string(),
2704            r#ref: "oci://example/remote@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(),
2705            digest: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
2706                .to_string(),
2707            component_id: Some(ComponentId::new("remote.component").expect("valid component id")),
2708            bundled: false,
2709            bundled_path: None,
2710            wasm_sha256: None,
2711            resolved_digest: None,
2712        }]);
2713
2714        let err =
2715            match collect_lock_component_artifacts(&mut lock, &runtime, BundleMode::Cache, false)
2716                .await
2717            {
2718                Ok(_) => panic!("expected offline build to fail without cached component"),
2719                Err(err) => err,
2720            };
2721        let msg = err.to_string();
2722        assert!(
2723            msg.contains("requires network access"),
2724            "error message should describe missing network access, got {}",
2725            msg
2726        );
2727    }
2728}