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