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::enforce_sidecar_mappings;
7use crate::runtime::RuntimeContext;
8use anyhow::{Context, Result, anyhow};
9use greentic_flow::add_step::normalize::normalize_node_map;
10use greentic_flow::compile_ygtc_file;
11use greentic_flow::loader::load_ygtc_from_path;
12use greentic_pack::builder::SbomEntry;
13use greentic_pack::pack_lock::read_pack_lock;
14use greentic_types::component_source::ComponentSourceRef;
15use greentic_types::pack::extensions::component_manifests::{
16    ComponentManifestIndexEntryV1, ComponentManifestIndexV1, EXT_COMPONENT_MANIFEST_INDEX_V1,
17    ManifestEncoding,
18};
19use greentic_types::pack::extensions::component_sources::{
20    ArtifactLocationV1, ComponentSourceEntryV1, ComponentSourcesV1, EXT_COMPONENT_SOURCES_V1,
21    ResolvedComponentV1,
22};
23use greentic_types::{
24    BootstrapSpec, ComponentCapability, ComponentConfigurators, ComponentId, ComponentManifest,
25    ComponentOperation, ExtensionInline, ExtensionRef, Flow, FlowId, PackDependency, PackFlowEntry,
26    PackId, PackKind, PackManifest, PackSignatures, SecretRequirement, SecretScope, SemverReq,
27    encode_pack_manifest,
28};
29use semver::Version;
30use serde::Serialize;
31use serde_cbor;
32use serde_yaml_bw::Value as YamlValue;
33use sha2::{Digest, Sha256};
34use std::collections::{BTreeMap, BTreeSet};
35use std::fs;
36use std::io::Write;
37use std::path::{Path, PathBuf};
38use std::str::FromStr;
39use tracing::info;
40use zip::write::SimpleFileOptions;
41use zip::{CompressionMethod, ZipWriter};
42
43const SBOM_FORMAT: &str = "greentic-sbom-v1";
44
45#[derive(Serialize)]
46struct SbomDocument {
47    format: String,
48    files: Vec<SbomEntry>,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
52pub enum BundleMode {
53    Cache,
54    None,
55}
56
57#[derive(Clone)]
58pub struct BuildOptions {
59    pub pack_dir: PathBuf,
60    pub component_out: Option<PathBuf>,
61    pub manifest_out: PathBuf,
62    pub sbom_out: Option<PathBuf>,
63    pub gtpack_out: Option<PathBuf>,
64    pub lock_path: PathBuf,
65    pub bundle: BundleMode,
66    pub dry_run: bool,
67    pub secrets_req: Option<PathBuf>,
68    pub default_secret_scope: Option<String>,
69    pub allow_oci_tags: bool,
70    pub runtime: RuntimeContext,
71    pub skip_update: bool,
72}
73
74impl BuildOptions {
75    pub fn from_args(args: crate::BuildArgs, runtime: &RuntimeContext) -> Result<Self> {
76        let pack_dir = args
77            .input
78            .canonicalize()
79            .with_context(|| format!("failed to canonicalize pack dir {}", args.input.display()))?;
80
81        let component_out = args
82            .component_out
83            .map(|p| if p.is_absolute() { p } else { pack_dir.join(p) });
84        let manifest_out = args
85            .manifest
86            .map(|p| if p.is_relative() { pack_dir.join(p) } else { p })
87            .unwrap_or_else(|| pack_dir.join("dist").join("manifest.cbor"));
88        let sbom_out = args
89            .sbom
90            .map(|p| if p.is_absolute() { p } else { pack_dir.join(p) });
91        let default_gtpack_name = pack_dir
92            .file_name()
93            .and_then(|name| name.to_str())
94            .unwrap_or("pack");
95        let default_gtpack_out = pack_dir
96            .join("dist")
97            .join(format!("{default_gtpack_name}.gtpack"));
98        let gtpack_out = Some(
99            args.gtpack_out
100                .map(|p| if p.is_absolute() { p } else { pack_dir.join(p) })
101                .unwrap_or(default_gtpack_out),
102        );
103        let lock_path = args
104            .lock
105            .map(|p| if p.is_absolute() { p } else { pack_dir.join(p) })
106            .unwrap_or_else(|| pack_dir.join("pack.lock.json"));
107
108        Ok(Self {
109            pack_dir,
110            component_out,
111            manifest_out,
112            sbom_out,
113            gtpack_out,
114            lock_path,
115            bundle: args.bundle,
116            dry_run: args.dry_run,
117            secrets_req: args.secrets_req,
118            default_secret_scope: args.default_secret_scope,
119            allow_oci_tags: args.allow_oci_tags,
120            runtime: runtime.clone(),
121            skip_update: args.no_update,
122        })
123    }
124}
125
126pub async fn run(opts: &BuildOptions) -> Result<()> {
127    info!(
128        pack_dir = %opts.pack_dir.display(),
129        manifest_out = %opts.manifest_out.display(),
130        gtpack_out = ?opts.gtpack_out,
131        dry_run = opts.dry_run,
132        "building greentic pack"
133    );
134
135    if !opts.skip_update {
136        // Keep pack.yaml in sync before building.
137        crate::cli::update::update_pack(&opts.pack_dir, false)?;
138    }
139
140    // Resolve component references into pack.lock.json before building to ensure
141    // manifests/extensions can rely on the lockfile contents.
142    resolve::handle(
143        ResolveArgs {
144            input: opts.pack_dir.clone(),
145            lock: Some(opts.lock_path.clone()),
146        },
147        &opts.runtime,
148        false,
149    )
150    .await?;
151
152    let config = crate::config::load_pack_config(&opts.pack_dir)?;
153    info!(
154        id = %config.pack_id,
155        version = %config.version,
156        kind = %config.kind,
157        components = config.components.len(),
158        flows = config.flows.len(),
159        dependencies = config.dependencies.len(),
160        "loaded pack.yaml"
161    );
162    validate_components_extension(&config.extensions, opts.allow_oci_tags)?;
163
164    let secret_requirements = aggregate_secret_requirements(
165        &config.components,
166        opts.secrets_req.as_deref(),
167        opts.default_secret_scope.as_deref(),
168    )?;
169
170    if !opts.lock_path.exists() {
171        anyhow::bail!(
172            "pack.lock.json is required (run `greentic-pack resolve`); missing: {}",
173            opts.lock_path.display()
174        );
175    }
176    let pack_lock = read_pack_lock(&opts.lock_path).with_context(|| {
177        format!(
178            "failed to read pack lock {} (try `greentic-pack resolve`)",
179            opts.lock_path.display()
180        )
181    })?;
182
183    let mut build = assemble_manifest(&config, &opts.pack_dir, &secret_requirements)?;
184    build.manifest.extensions =
185        merge_component_sources_extension(build.manifest.extensions, &pack_lock, opts.bundle)?;
186
187    let manifest_bytes = encode_pack_manifest(&build.manifest)?;
188    info!(len = manifest_bytes.len(), "encoded manifest.cbor");
189
190    if opts.dry_run {
191        info!("dry-run complete; no files written");
192        return Ok(());
193    }
194
195    if let Some(component_out) = opts.component_out.as_ref() {
196        write_stub_wasm(component_out)?;
197    }
198
199    write_bytes(&opts.manifest_out, &manifest_bytes)?;
200
201    if let Some(sbom_out) = opts.sbom_out.as_ref() {
202        write_bytes(sbom_out, br#"{"files":[]} "#)?;
203    }
204
205    if let Some(gtpack_out) = opts.gtpack_out.as_ref() {
206        let mut build = build;
207        if !secret_requirements.is_empty() {
208            let logical = "secret-requirements.json".to_string();
209            let req_path =
210                write_secret_requirements_file(&opts.pack_dir, &secret_requirements, &logical)?;
211            build.assets.push(AssetFile {
212                logical_path: logical,
213                source: req_path,
214            });
215        }
216        package_gtpack(gtpack_out, &manifest_bytes, &build, opts.bundle)?;
217        info!(gtpack_out = %gtpack_out.display(), "gtpack archive ready");
218        eprintln!("wrote {}", gtpack_out.display());
219    }
220
221    Ok(())
222}
223
224struct BuildProducts {
225    manifest: PackManifest,
226    components: Vec<ComponentBinary>,
227    flow_files: Vec<FlowFile>,
228    assets: Vec<AssetFile>,
229}
230
231#[derive(Clone)]
232struct ComponentBinary {
233    id: String,
234    source: PathBuf,
235    manifest_bytes: Vec<u8>,
236    manifest_path: String,
237    manifest_hash_sha256: String,
238}
239
240struct AssetFile {
241    logical_path: String,
242    source: PathBuf,
243}
244
245#[derive(Clone)]
246struct FlowFile {
247    logical_path: String,
248    bytes: Vec<u8>,
249    media_type: &'static str,
250}
251
252fn assemble_manifest(
253    config: &PackConfig,
254    pack_root: &Path,
255    secret_requirements: &[SecretRequirement],
256) -> Result<BuildProducts> {
257    let components = build_components(&config.components)?;
258    let component_ids: BTreeSet<String> = config.components.iter().map(|c| c.id.clone()).collect();
259    let (flows, flow_files) = build_flows(&config.flows, &component_ids, pack_root)?;
260    let dependencies = build_dependencies(&config.dependencies)?;
261    let assets = collect_assets(&config.assets, pack_root)?;
262    let component_manifests: Vec<_> = components.iter().map(|c| c.0.clone()).collect();
263    let bootstrap = build_bootstrap(config, &flows, &component_manifests)?;
264    let extensions =
265        merge_component_manifest_extension(normalize_extensions(&config.extensions), &components)?;
266
267    let manifest = PackManifest {
268        schema_version: "pack-v1".to_string(),
269        pack_id: PackId::new(config.pack_id.clone()).context("invalid pack_id")?,
270        version: Version::parse(&config.version)
271            .context("invalid pack version (expected semver)")?,
272        kind: map_kind(&config.kind)?,
273        publisher: config.publisher.clone(),
274        components: component_manifests,
275        flows,
276        dependencies,
277        capabilities: derive_pack_capabilities(&components),
278        secret_requirements: secret_requirements.to_vec(),
279        signatures: PackSignatures::default(),
280        bootstrap,
281        extensions,
282    };
283
284    Ok(BuildProducts {
285        manifest,
286        components: components.into_iter().map(|(_, bin)| bin).collect(),
287        flow_files,
288        assets,
289    })
290}
291
292fn build_components(
293    configs: &[ComponentConfig],
294) -> Result<Vec<(ComponentManifest, ComponentBinary)>> {
295    let mut seen = BTreeSet::new();
296    let mut result = Vec::new();
297
298    for cfg in configs {
299        if !seen.insert(cfg.id.clone()) {
300            anyhow::bail!("duplicate component id {}", cfg.id);
301        }
302
303        info!(id = %cfg.id, wasm = %cfg.wasm.display(), "adding component");
304        let (manifest, binary) = resolve_component_artifacts(cfg)?;
305
306        result.push((manifest, binary));
307    }
308
309    Ok(result)
310}
311
312fn resolve_component_artifacts(
313    cfg: &ComponentConfig,
314) -> Result<(ComponentManifest, ComponentBinary)> {
315    let resolved_wasm = resolve_component_wasm_path(&cfg.wasm)?;
316
317    let mut manifest = if let Some(from_disk) = load_component_manifest_from_disk(&resolved_wasm)? {
318        if from_disk.id.to_string() != cfg.id {
319            anyhow::bail!(
320                "component manifest id {} does not match pack.yaml id {}",
321                from_disk.id,
322                cfg.id
323            );
324        }
325        if from_disk.version.to_string() != cfg.version {
326            anyhow::bail!(
327                "component manifest version {} does not match pack.yaml version {}",
328                from_disk.version,
329                cfg.version
330            );
331        }
332        from_disk
333    } else {
334        manifest_from_config(cfg)?
335    };
336
337    // Ensure operations are populated from pack.yaml when missing in the on-disk manifest.
338    if manifest.operations.is_empty() && !cfg.operations.is_empty() {
339        manifest.operations = cfg
340            .operations
341            .iter()
342            .map(operation_from_config)
343            .collect::<Result<Vec<_>>>()?;
344    }
345
346    let manifest_bytes =
347        serde_cbor::to_vec(&manifest).context("encode component manifest to cbor")?;
348    let mut sha = Sha256::new();
349    sha.update(&manifest_bytes);
350    let manifest_hash_sha256 = format!("sha256:{:x}", sha.finalize());
351    let manifest_path = format!("components/{}.manifest.cbor", cfg.id);
352
353    let binary = ComponentBinary {
354        id: cfg.id.clone(),
355        source: resolved_wasm,
356        manifest_bytes,
357        manifest_path,
358        manifest_hash_sha256,
359    };
360
361    Ok((manifest, binary))
362}
363
364fn manifest_from_config(cfg: &ComponentConfig) -> Result<ComponentManifest> {
365    Ok(ComponentManifest {
366        id: ComponentId::new(cfg.id.clone()).context("invalid component id")?,
367        version: Version::parse(&cfg.version)
368            .context("invalid component version (expected semver)")?,
369        supports: cfg.supports.iter().map(|k| k.to_kind()).collect(),
370        world: cfg.world.clone(),
371        profiles: cfg.profiles.clone(),
372        capabilities: cfg.capabilities.clone(),
373        configurators: convert_configurators(cfg)?,
374        operations: cfg
375            .operations
376            .iter()
377            .map(operation_from_config)
378            .collect::<Result<Vec<_>>>()?,
379        config_schema: cfg.config_schema.clone(),
380        resources: cfg.resources.clone().unwrap_or_default(),
381        dev_flows: BTreeMap::new(),
382    })
383}
384
385fn resolve_component_wasm_path(path: &Path) -> Result<PathBuf> {
386    if path.is_file() {
387        return Ok(path.to_path_buf());
388    }
389    if !path.exists() {
390        anyhow::bail!("component path {} does not exist", path.display());
391    }
392    if !path.is_dir() {
393        anyhow::bail!(
394            "component path {} must be a file or directory",
395            path.display()
396        );
397    }
398
399    let mut component_candidates = Vec::new();
400    let mut wasm_candidates = Vec::new();
401    let mut stack = vec![path.to_path_buf()];
402    while let Some(current) = stack.pop() {
403        for entry in fs::read_dir(&current)
404            .with_context(|| format!("failed to list components in {}", current.display()))?
405        {
406            let entry = entry?;
407            let entry_type = entry.file_type()?;
408            let entry_path = entry.path();
409            if entry_type.is_dir() {
410                stack.push(entry_path);
411                continue;
412            }
413            if entry_type.is_file() && entry_path.extension() == Some(std::ffi::OsStr::new("wasm"))
414            {
415                let file_name = entry_path
416                    .file_name()
417                    .and_then(|n| n.to_str())
418                    .unwrap_or_default();
419                if file_name.ends_with(".component.wasm") {
420                    component_candidates.push(entry_path);
421                } else {
422                    wasm_candidates.push(entry_path);
423                }
424            }
425        }
426    }
427
428    let choose = |mut list: Vec<PathBuf>| -> Result<PathBuf> {
429        list.sort();
430        if list.len() == 1 {
431            Ok(list.remove(0))
432        } else {
433            let options = list
434                .iter()
435                .map(|p| p.strip_prefix(path).unwrap_or(p).display().to_string())
436                .collect::<Vec<_>>()
437                .join(", ");
438            anyhow::bail!(
439                "multiple wasm artifacts found under {}: {} (pick a single *.component.wasm or *.wasm)",
440                path.display(),
441                options
442            );
443        }
444    };
445
446    if !component_candidates.is_empty() {
447        return choose(component_candidates);
448    }
449    if !wasm_candidates.is_empty() {
450        return choose(wasm_candidates);
451    }
452
453    anyhow::bail!(
454        "no wasm artifact found under {}; expected *.component.wasm or *.wasm",
455        path.display()
456    );
457}
458
459fn load_component_manifest_from_disk(path: &Path) -> Result<Option<ComponentManifest>> {
460    let manifest_dir = if path.is_dir() {
461        path.to_path_buf()
462    } else {
463        path.parent()
464            .map(Path::to_path_buf)
465            .ok_or_else(|| anyhow!("component path {} has no parent directory", path.display()))?
466    };
467    let manifest_path = manifest_dir.join("component.json");
468    if !manifest_path.exists() {
469        return Ok(None);
470    }
471
472    let manifest: ComponentManifest = serde_json::from_slice(
473        &fs::read(&manifest_path)
474            .with_context(|| format!("failed to read {}", manifest_path.display()))?,
475    )
476    .with_context(|| format!("{} is not a valid component.json", manifest_path.display()))?;
477
478    Ok(Some(manifest))
479}
480
481fn operation_from_config(cfg: &ComponentOperationConfig) -> Result<ComponentOperation> {
482    Ok(ComponentOperation {
483        name: cfg.name.clone(),
484        input_schema: cfg.input_schema.clone(),
485        output_schema: cfg.output_schema.clone(),
486    })
487}
488
489fn convert_configurators(cfg: &ComponentConfig) -> Result<Option<ComponentConfigurators>> {
490    let Some(configurators) = cfg.configurators.as_ref() else {
491        return Ok(None);
492    };
493
494    let basic = match &configurators.basic {
495        Some(id) => Some(FlowId::new(id).context("invalid configurator flow id")?),
496        None => None,
497    };
498    let full = match &configurators.full {
499        Some(id) => Some(FlowId::new(id).context("invalid configurator flow id")?),
500        None => None,
501    };
502
503    Ok(Some(ComponentConfigurators { basic, full }))
504}
505
506fn build_bootstrap(
507    config: &PackConfig,
508    flows: &[PackFlowEntry],
509    components: &[ComponentManifest],
510) -> Result<Option<BootstrapSpec>> {
511    let Some(raw) = config.bootstrap.as_ref() else {
512        return Ok(None);
513    };
514
515    let flow_ids: BTreeSet<_> = flows.iter().map(|flow| flow.id.to_string()).collect();
516    let component_ids: BTreeSet<_> = components.iter().map(|c| c.id.to_string()).collect();
517
518    let mut spec = BootstrapSpec::default();
519
520    if let Some(install_flow) = &raw.install_flow {
521        if !flow_ids.contains(install_flow) {
522            anyhow::bail!(
523                "bootstrap.install_flow references unknown flow {}",
524                install_flow
525            );
526        }
527        spec.install_flow = Some(install_flow.clone());
528    }
529
530    if let Some(upgrade_flow) = &raw.upgrade_flow {
531        if !flow_ids.contains(upgrade_flow) {
532            anyhow::bail!(
533                "bootstrap.upgrade_flow references unknown flow {}",
534                upgrade_flow
535            );
536        }
537        spec.upgrade_flow = Some(upgrade_flow.clone());
538    }
539
540    if let Some(component) = &raw.installer_component {
541        if !component_ids.contains(component) {
542            anyhow::bail!(
543                "bootstrap.installer_component references unknown component {}",
544                component
545            );
546        }
547        spec.installer_component = Some(component.clone());
548    }
549
550    if spec.install_flow.is_none()
551        && spec.upgrade_flow.is_none()
552        && spec.installer_component.is_none()
553    {
554        return Ok(None);
555    }
556
557    Ok(Some(spec))
558}
559
560fn build_flows(
561    configs: &[FlowConfig],
562    component_ids: &BTreeSet<String>,
563    pack_root: &Path,
564) -> Result<(Vec<PackFlowEntry>, Vec<FlowFile>)> {
565    let mut seen = BTreeSet::new();
566    let mut entries = Vec::new();
567    let mut flow_files = Vec::new();
568
569    for cfg in configs {
570        info!(id = %cfg.id, path = %cfg.file.display(), "compiling flow");
571        let yaml_bytes = fs::read(&cfg.file)
572            .with_context(|| format!("failed to read flow {}", cfg.file.display()))?;
573        let mut flow: Flow = compile_ygtc_file(&cfg.file)
574            .with_context(|| format!("failed to compile {}", cfg.file.display()))?;
575        populate_component_exec_operations(&mut flow, &cfg.file).with_context(|| {
576            format!(
577                "failed to resolve component.exec operations in {}",
578                cfg.file.display()
579            )
580        })?;
581        normalize_legacy_component_exec_ids(&mut flow)?;
582        resolve_missing_component_ids(&mut flow, component_ids).with_context(|| {
583            format!("failed to resolve component ids in {}", cfg.file.display())
584        })?;
585        enforce_sidecar_mappings(pack_root, cfg, &flow)?;
586
587        let flow_id = flow.id.to_string();
588        if !seen.insert(flow_id.clone()) {
589            anyhow::bail!("duplicate flow id {}", flow_id);
590        }
591
592        let entrypoints = if cfg.entrypoints.is_empty() {
593            flow.entrypoints.keys().cloned().collect()
594        } else {
595            cfg.entrypoints.clone()
596        };
597
598        let flow_entry = PackFlowEntry {
599            id: flow.id.clone(),
600            kind: flow.kind,
601            flow,
602            tags: cfg.tags.clone(),
603            entrypoints,
604        };
605
606        let flow_id = flow_entry.id.to_string();
607        flow_files.push(FlowFile {
608            logical_path: format!("flows/{flow_id}/flow.ygtc"),
609            bytes: yaml_bytes,
610            media_type: "application/yaml",
611        });
612        flow_files.push(FlowFile {
613            logical_path: format!("flows/{flow_id}/flow.json"),
614            bytes: serde_json::to_vec(&flow_entry.flow).context("encode flow json")?,
615            media_type: "application/json",
616        });
617        entries.push(flow_entry);
618    }
619
620    Ok((entries, flow_files))
621}
622
623fn infer_component_id_for_node(node_id: &str, components: &BTreeSet<String>) -> Result<String> {
624    if components.is_empty() {
625        anyhow::bail!(
626            "node {} is missing component id and no packaged components are available to resolve it",
627            node_id
628        );
629    }
630
631    if components.contains(node_id) {
632        return Ok(node_id.to_string());
633    }
634
635    let suffix_matches: Vec<_> = components
636        .iter()
637        .filter(|candidate| candidate.rsplit('.').next() == Some(node_id))
638        .collect();
639    if suffix_matches.len() == 1 {
640        return Ok((*suffix_matches[0]).clone());
641    }
642
643    if components.len() == 1 {
644        return Ok(components
645            .iter()
646            .next()
647            .expect("component set is non-empty")
648            .clone());
649    }
650
651    anyhow::bail!(
652        "node {} is missing component id and could not be matched to packaged components: {}",
653        node_id,
654        components.iter().cloned().collect::<Vec<_>>().join(", ")
655    );
656}
657
658fn resolve_missing_component_ids(flow: &mut Flow, components: &BTreeSet<String>) -> Result<()> {
659    for (node_id, node) in flow.nodes.iter_mut() {
660        if !node.component.id.as_str().is_empty() && node.component.id.as_str() != "component.exec"
661        {
662            continue;
663        }
664
665        let component_id = infer_component_id_for_node(node_id.as_str(), components)?;
666
667        node.component.id = ComponentId::new(&component_id)
668            .with_context(|| format!("invalid component id resolved for node {}", node_id))?;
669    }
670    Ok(())
671}
672
673fn populate_component_exec_operations(flow: &mut Flow, path: &Path) -> Result<()> {
674    let needs_op = flow.nodes.values().any(|node| {
675        node.component.id.as_str() == "component.exec" && node.component.operation.is_none()
676    });
677    if !needs_op {
678        return Ok(());
679    }
680
681    let flow_doc = load_ygtc_from_path(path)?;
682    let mut operations = BTreeMap::new();
683
684    for (node_id, node_doc) in flow_doc.nodes {
685        let value = serde_json::to_value(&node_doc)
686            .with_context(|| format!("failed to normalize component.exec node {}", node_id))?;
687        let normalized = normalize_node_map(value)?;
688        if !normalized.operation.trim().is_empty() {
689            operations.insert(node_id, normalized.operation);
690        }
691    }
692
693    for (node_id, node) in flow.nodes.iter_mut() {
694        if node.component.id.as_str() != "component.exec" || node.component.operation.is_some() {
695            continue;
696        }
697        if let Some(op) = operations.get(node_id.as_str()) {
698            node.component.operation = Some(op.clone());
699        }
700    }
701
702    Ok(())
703}
704
705fn normalize_legacy_component_exec_ids(flow: &mut Flow) -> Result<()> {
706    for (node_id, node) in flow.nodes.iter_mut() {
707        if node.component.id.as_str() != "component.exec" {
708            continue;
709        }
710        let Some(op) = node.component.operation.as_deref() else {
711            continue;
712        };
713        if !op.contains('.') && !op.contains(':') {
714            continue;
715        }
716        node.component.id = ComponentId::new(op).with_context(|| {
717            format!("invalid component id {} resolved for node {}", op, node_id)
718        })?;
719        node.component.operation = None;
720    }
721    Ok(())
722}
723
724fn build_dependencies(configs: &[crate::config::DependencyConfig]) -> Result<Vec<PackDependency>> {
725    let mut deps = Vec::new();
726    let mut seen = BTreeSet::new();
727    for cfg in configs {
728        if !seen.insert(cfg.alias.clone()) {
729            anyhow::bail!("duplicate dependency alias {}", cfg.alias);
730        }
731        deps.push(PackDependency {
732            alias: cfg.alias.clone(),
733            pack_id: PackId::new(cfg.pack_id.clone()).context("invalid dependency pack_id")?,
734            version_req: SemverReq::parse(&cfg.version_req)
735                .context("invalid dependency version requirement")?,
736            required_capabilities: cfg.required_capabilities.clone(),
737        });
738    }
739    Ok(deps)
740}
741
742fn collect_assets(configs: &[AssetConfig], pack_root: &Path) -> Result<Vec<AssetFile>> {
743    let mut assets = Vec::new();
744    for cfg in configs {
745        let logical = cfg
746            .path
747            .strip_prefix(pack_root)
748            .unwrap_or(&cfg.path)
749            .components()
750            .map(|c| c.as_os_str().to_string_lossy().into_owned())
751            .collect::<Vec<_>>()
752            .join("/");
753        if logical.is_empty() {
754            anyhow::bail!("invalid asset path {}", cfg.path.display());
755        }
756        assets.push(AssetFile {
757            logical_path: logical,
758            source: cfg.path.clone(),
759        });
760    }
761    Ok(assets)
762}
763
764fn normalize_extensions(
765    extensions: &Option<BTreeMap<String, greentic_types::ExtensionRef>>,
766) -> Option<BTreeMap<String, greentic_types::ExtensionRef>> {
767    extensions.as_ref().filter(|map| !map.is_empty()).cloned()
768}
769
770fn merge_component_manifest_extension(
771    extensions: Option<BTreeMap<String, ExtensionRef>>,
772    components: &[(ComponentManifest, ComponentBinary)],
773) -> Result<Option<BTreeMap<String, ExtensionRef>>> {
774    let entries: Vec<_> = components
775        .iter()
776        .map(|(manifest, binary)| ComponentManifestIndexEntryV1 {
777            component_id: manifest.id.to_string(),
778            manifest_file: binary.manifest_path.clone(),
779            encoding: ManifestEncoding::Cbor,
780            content_hash: Some(binary.manifest_hash_sha256.clone()),
781        })
782        .collect();
783
784    let index = ComponentManifestIndexV1::new(entries);
785    let value = index
786        .to_extension_value()
787        .context("serialize component manifest index extension")?;
788
789    let ext = ExtensionRef {
790        kind: EXT_COMPONENT_MANIFEST_INDEX_V1.to_string(),
791        version: "v1".to_string(),
792        digest: None,
793        location: None,
794        inline: Some(ExtensionInline::Other(value)),
795    };
796
797    let mut map = extensions.unwrap_or_default();
798    map.insert(EXT_COMPONENT_MANIFEST_INDEX_V1.to_string(), ext);
799    if map.is_empty() {
800        Ok(None)
801    } else {
802        Ok(Some(map))
803    }
804}
805
806fn merge_component_sources_extension(
807    extensions: Option<BTreeMap<String, ExtensionRef>>,
808    lock: &greentic_pack::pack_lock::PackLockV1,
809    bundle: BundleMode,
810) -> Result<Option<BTreeMap<String, ExtensionRef>>> {
811    let mut entries = Vec::new();
812    for comp in &lock.components {
813        let source = match ComponentSourceRef::from_str(&comp.r#ref) {
814            Ok(parsed) => parsed,
815            Err(_) => {
816                eprintln!(
817                    "warning: skipping pack.lock entry `{}` with unsupported ref {}",
818                    comp.name, comp.r#ref
819                );
820                continue;
821            }
822        };
823        let artifact = match bundle {
824            BundleMode::None => ArtifactLocationV1::Remote,
825            BundleMode::Cache => ArtifactLocationV1::Inline {
826                wasm_path: format!("components/{}.wasm", comp.name),
827                manifest_path: None,
828            },
829        };
830        entries.push(ComponentSourceEntryV1 {
831            name: comp.name.clone(),
832            component_id: None,
833            source,
834            resolved: ResolvedComponentV1 {
835                digest: comp.digest.clone(),
836                signature: None,
837                signed_by: None,
838            },
839            artifact,
840            licensing_hint: None,
841            metering_hint: None,
842        });
843    }
844
845    if entries.is_empty() {
846        return Ok(extensions);
847    }
848
849    let payload = ComponentSourcesV1::new(entries)
850        .to_extension_value()
851        .context("serialize component_sources extension")?;
852
853    let ext = ExtensionRef {
854        kind: EXT_COMPONENT_SOURCES_V1.to_string(),
855        version: "v1".to_string(),
856        digest: None,
857        location: None,
858        inline: Some(ExtensionInline::Other(payload)),
859    };
860
861    let mut map = extensions.unwrap_or_default();
862    map.insert(EXT_COMPONENT_SOURCES_V1.to_string(), ext);
863    if map.is_empty() {
864        Ok(None)
865    } else {
866        Ok(Some(map))
867    }
868}
869
870fn derive_pack_capabilities(
871    components: &[(ComponentManifest, ComponentBinary)],
872) -> Vec<ComponentCapability> {
873    let mut seen = BTreeSet::new();
874    let mut caps = Vec::new();
875
876    for (component, _) in components {
877        let mut add = |name: &str| {
878            if seen.insert(name.to_string()) {
879                caps.push(ComponentCapability {
880                    name: name.to_string(),
881                    description: None,
882                });
883            }
884        };
885
886        if component.capabilities.host.secrets.is_some() {
887            add("host:secrets");
888        }
889        if let Some(state) = &component.capabilities.host.state {
890            if state.read {
891                add("host:state:read");
892            }
893            if state.write {
894                add("host:state:write");
895            }
896        }
897        if component.capabilities.host.messaging.is_some() {
898            add("host:messaging");
899        }
900        if component.capabilities.host.events.is_some() {
901            add("host:events");
902        }
903        if component.capabilities.host.http.is_some() {
904            add("host:http");
905        }
906        if component.capabilities.host.telemetry.is_some() {
907            add("host:telemetry");
908        }
909        if component.capabilities.host.iac.is_some() {
910            add("host:iac");
911        }
912        if let Some(fs) = component.capabilities.wasi.filesystem.as_ref() {
913            add(&format!(
914                "wasi:fs:{}",
915                format!("{:?}", fs.mode).to_lowercase()
916            ));
917            if !fs.mounts.is_empty() {
918                add("wasi:fs:mounts");
919            }
920        }
921        if component.capabilities.wasi.random {
922            add("wasi:random");
923        }
924        if component.capabilities.wasi.clocks {
925            add("wasi:clocks");
926        }
927    }
928
929    caps
930}
931
932fn map_kind(raw: &str) -> Result<PackKind> {
933    match raw.to_ascii_lowercase().as_str() {
934        "application" => Ok(PackKind::Application),
935        "provider" => Ok(PackKind::Provider),
936        "infrastructure" => Ok(PackKind::Infrastructure),
937        "library" => Ok(PackKind::Library),
938        other => Err(anyhow!("unknown pack kind {}", other)),
939    }
940}
941
942fn package_gtpack(
943    out_path: &Path,
944    manifest_bytes: &[u8],
945    build: &BuildProducts,
946    bundle: BundleMode,
947) -> Result<()> {
948    if let Some(parent) = out_path.parent() {
949        fs::create_dir_all(parent)
950            .with_context(|| format!("failed to create {}", parent.display()))?;
951    }
952
953    let file = fs::File::create(out_path)
954        .with_context(|| format!("failed to create {}", out_path.display()))?;
955    let mut writer = ZipWriter::new(file);
956    let options = SimpleFileOptions::default()
957        .compression_method(CompressionMethod::Stored)
958        .unix_permissions(0o644);
959
960    let mut sbom_entries = Vec::new();
961    record_sbom_entry(
962        &mut sbom_entries,
963        "manifest.cbor",
964        manifest_bytes,
965        "application/cbor",
966    );
967    write_zip_entry(&mut writer, "manifest.cbor", manifest_bytes, options)?;
968
969    let mut flow_files = build.flow_files.clone();
970    flow_files.sort_by(|a, b| a.logical_path.cmp(&b.logical_path));
971    for flow_file in flow_files {
972        record_sbom_entry(
973            &mut sbom_entries,
974            &flow_file.logical_path,
975            &flow_file.bytes,
976            flow_file.media_type,
977        );
978        write_zip_entry(
979            &mut writer,
980            &flow_file.logical_path,
981            &flow_file.bytes,
982            options,
983        )?;
984    }
985
986    if bundle != BundleMode::None {
987        let mut components = build.components.clone();
988        components.sort_by(|a, b| a.id.cmp(&b.id));
989        for comp in components {
990            let logical_wasm = format!("components/{}.wasm", comp.id);
991            let wasm_bytes = fs::read(&comp.source)
992                .with_context(|| format!("failed to read component {}", comp.source.display()))?;
993            record_sbom_entry(
994                &mut sbom_entries,
995                &logical_wasm,
996                &wasm_bytes,
997                "application/wasm",
998            );
999            write_zip_entry(&mut writer, &logical_wasm, &wasm_bytes, options)?;
1000
1001            record_sbom_entry(
1002                &mut sbom_entries,
1003                &comp.manifest_path,
1004                &comp.manifest_bytes,
1005                "application/cbor",
1006            );
1007            write_zip_entry(
1008                &mut writer,
1009                &comp.manifest_path,
1010                &comp.manifest_bytes,
1011                options,
1012            )?;
1013        }
1014    }
1015
1016    let mut asset_entries: Vec<_> = build
1017        .assets
1018        .iter()
1019        .map(|a| (format!("assets/{}", &a.logical_path), a.source.clone()))
1020        .collect();
1021    asset_entries.sort_by(|a, b| a.0.cmp(&b.0));
1022    for (logical, source) in asset_entries {
1023        let bytes = fs::read(&source)
1024            .with_context(|| format!("failed to read asset {}", source.display()))?;
1025        record_sbom_entry(
1026            &mut sbom_entries,
1027            &logical,
1028            &bytes,
1029            "application/octet-stream",
1030        );
1031        write_zip_entry(&mut writer, &logical, &bytes, options)?;
1032    }
1033
1034    sbom_entries.sort_by(|a, b| a.path.cmp(&b.path));
1035    let sbom_doc = SbomDocument {
1036        format: SBOM_FORMAT.to_string(),
1037        files: sbom_entries,
1038    };
1039    let sbom_bytes = serde_cbor::to_vec(&sbom_doc).context("failed to encode sbom.cbor")?;
1040    write_zip_entry(&mut writer, "sbom.cbor", &sbom_bytes, options)?;
1041
1042    writer
1043        .finish()
1044        .context("failed to finalise gtpack archive")?;
1045    Ok(())
1046}
1047
1048fn record_sbom_entry(entries: &mut Vec<SbomEntry>, path: &str, bytes: &[u8], media_type: &str) {
1049    entries.push(SbomEntry {
1050        path: path.to_string(),
1051        size: bytes.len() as u64,
1052        hash_blake3: blake3::hash(bytes).to_hex().to_string(),
1053        media_type: media_type.to_string(),
1054    });
1055}
1056
1057fn write_zip_entry(
1058    writer: &mut ZipWriter<std::fs::File>,
1059    logical_path: &str,
1060    bytes: &[u8],
1061    options: SimpleFileOptions,
1062) -> Result<()> {
1063    writer
1064        .start_file(logical_path, options)
1065        .with_context(|| format!("failed to start {}", logical_path))?;
1066    writer
1067        .write_all(bytes)
1068        .with_context(|| format!("failed to write {}", logical_path))?;
1069    Ok(())
1070}
1071
1072fn write_bytes(path: &Path, bytes: &[u8]) -> Result<()> {
1073    if let Some(parent) = path.parent() {
1074        fs::create_dir_all(parent)
1075            .with_context(|| format!("failed to create directory {}", parent.display()))?;
1076    }
1077    fs::write(path, bytes).with_context(|| format!("failed to write {}", path.display()))?;
1078    Ok(())
1079}
1080
1081fn write_stub_wasm(path: &Path) -> Result<()> {
1082    const STUB: &[u8] = &[0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
1083    write_bytes(path, STUB)
1084}
1085
1086fn aggregate_secret_requirements(
1087    components: &[ComponentConfig],
1088    override_path: Option<&Path>,
1089    default_scope: Option<&str>,
1090) -> Result<Vec<SecretRequirement>> {
1091    let default_scope = default_scope.map(parse_default_scope).transpose()?;
1092    let mut merged: BTreeMap<(String, String, String), SecretRequirement> = BTreeMap::new();
1093
1094    let mut process_req = |req: &SecretRequirement, source: &str| -> Result<()> {
1095        let mut req = req.clone();
1096        if req.scope.is_none() {
1097            if let Some(scope) = default_scope.clone() {
1098                req.scope = Some(scope);
1099                tracing::warn!(
1100                    key = %secret_key_string(&req),
1101                    source,
1102                    "secret requirement missing scope; applying default scope"
1103                );
1104            } else {
1105                anyhow::bail!(
1106                    "secret requirement {} from {} is missing scope (provide --default-secret-scope or fix the component manifest)",
1107                    secret_key_string(&req),
1108                    source
1109                );
1110            }
1111        }
1112        let scope = req.scope.as_ref().expect("scope present");
1113        let fmt = fmt_key(&req);
1114        let key_tuple = (req.key.clone().into(), scope_key(scope), fmt.clone());
1115        if let Some(existing) = merged.get_mut(&key_tuple) {
1116            merge_requirement(existing, &req);
1117        } else {
1118            merged.insert(key_tuple, req);
1119        }
1120        Ok(())
1121    };
1122
1123    for component in components {
1124        if let Some(secret_caps) = component.capabilities.host.secrets.as_ref() {
1125            for req in &secret_caps.required {
1126                process_req(req, &component.id)?;
1127            }
1128        }
1129    }
1130
1131    if let Some(path) = override_path {
1132        let contents = fs::read_to_string(path)
1133            .with_context(|| format!("failed to read secrets override {}", path.display()))?;
1134        let value: serde_json::Value = if path
1135            .extension()
1136            .and_then(|ext| ext.to_str())
1137            .map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
1138            .unwrap_or(false)
1139        {
1140            let yaml: YamlValue = serde_yaml_bw::from_str(&contents)
1141                .with_context(|| format!("{} is not valid YAML", path.display()))?;
1142            serde_json::to_value(yaml).context("failed to normalise YAML secrets override")?
1143        } else {
1144            serde_json::from_str(&contents)
1145                .with_context(|| format!("{} is not valid JSON", path.display()))?
1146        };
1147
1148        let overrides: Vec<SecretRequirement> =
1149            serde_json::from_value(value).with_context(|| {
1150                format!(
1151                    "{} must be an array of secret requirements (migration bridge)",
1152                    path.display()
1153                )
1154            })?;
1155        for req in &overrides {
1156            process_req(req, &format!("override:{}", path.display()))?;
1157        }
1158    }
1159
1160    let mut out: Vec<SecretRequirement> = merged.into_values().collect();
1161    out.sort_by(|a, b| {
1162        let a_scope = a.scope.as_ref().map(scope_key).unwrap_or_default();
1163        let b_scope = b.scope.as_ref().map(scope_key).unwrap_or_default();
1164        (a_scope, secret_key_string(a), fmt_key(a)).cmp(&(
1165            b_scope,
1166            secret_key_string(b),
1167            fmt_key(b),
1168        ))
1169    });
1170    Ok(out)
1171}
1172
1173fn fmt_key(req: &SecretRequirement) -> String {
1174    req.format
1175        .as_ref()
1176        .map(|f| format!("{:?}", f))
1177        .unwrap_or_else(|| "unspecified".to_string())
1178}
1179
1180fn scope_key(scope: &SecretScope) -> String {
1181    format!(
1182        "{}/{}/{}",
1183        &scope.env,
1184        &scope.tenant,
1185        scope
1186            .team
1187            .as_deref()
1188            .map(|t| t.to_string())
1189            .unwrap_or_else(|| "_".to_string())
1190    )
1191}
1192
1193fn secret_key_string(req: &SecretRequirement) -> String {
1194    let key: String = req.key.clone().into();
1195    key
1196}
1197
1198fn merge_requirement(base: &mut SecretRequirement, incoming: &SecretRequirement) {
1199    if base.description.is_none() {
1200        base.description = incoming.description.clone();
1201    }
1202    if let Some(schema) = &incoming.schema {
1203        if base.schema.is_none() {
1204            base.schema = Some(schema.clone());
1205        } else if base.schema.as_ref() != Some(schema) {
1206            tracing::warn!(
1207                key = %secret_key_string(base),
1208                "conflicting secret schema encountered; keeping first"
1209            );
1210        }
1211    }
1212
1213    if !incoming.examples.is_empty() {
1214        for example in &incoming.examples {
1215            if !base.examples.contains(example) {
1216                base.examples.push(example.clone());
1217            }
1218        }
1219    }
1220
1221    base.required = base.required || incoming.required;
1222}
1223
1224fn parse_default_scope(raw: &str) -> Result<SecretScope> {
1225    let parts: Vec<_> = raw.split('/').collect();
1226    if parts.len() < 2 || parts.len() > 3 {
1227        anyhow::bail!(
1228            "default secret scope must be ENV/TENANT or ENV/TENANT/TEAM (got {})",
1229            raw
1230        );
1231    }
1232    Ok(SecretScope {
1233        env: parts[0].to_string(),
1234        tenant: parts[1].to_string(),
1235        team: parts.get(2).map(|s| s.to_string()),
1236    })
1237}
1238
1239fn write_secret_requirements_file(
1240    pack_root: &Path,
1241    requirements: &[SecretRequirement],
1242    logical_name: &str,
1243) -> Result<PathBuf> {
1244    let path = pack_root.join(".packc").join(logical_name);
1245    if let Some(parent) = path.parent() {
1246        fs::create_dir_all(parent)
1247            .with_context(|| format!("failed to create {}", parent.display()))?;
1248    }
1249    let data = serde_json::to_vec_pretty(&requirements)
1250        .context("failed to serialise secret requirements")?;
1251    fs::write(&path, data).with_context(|| format!("failed to write {}", path.display()))?;
1252    Ok(path)
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257    use super::*;
1258    use crate::config::BootstrapConfig;
1259    use greentic_pack::pack_lock::{LockedComponent, PackLockV1};
1260    use greentic_types::flow::FlowKind;
1261    use serde_json::json;
1262    use std::io::Read;
1263    use std::{fs, path::PathBuf};
1264    use tempfile::tempdir;
1265    use zip::ZipArchive;
1266
1267    #[test]
1268    fn map_kind_accepts_known_values() {
1269        assert!(matches!(
1270            map_kind("application").unwrap(),
1271            PackKind::Application
1272        ));
1273        assert!(matches!(map_kind("provider").unwrap(), PackKind::Provider));
1274        assert!(matches!(
1275            map_kind("infrastructure").unwrap(),
1276            PackKind::Infrastructure
1277        ));
1278        assert!(matches!(map_kind("library").unwrap(), PackKind::Library));
1279        assert!(map_kind("unknown").is_err());
1280    }
1281
1282    #[test]
1283    fn collect_assets_preserves_relative_paths() {
1284        let root = PathBuf::from("/packs/demo");
1285        let assets = vec![AssetConfig {
1286            path: root.join("assets").join("foo.txt"),
1287        }];
1288        let collected = collect_assets(&assets, &root).expect("collect assets");
1289        assert_eq!(collected[0].logical_path, "assets/foo.txt");
1290    }
1291
1292    #[test]
1293    fn build_bootstrap_requires_known_references() {
1294        let config = pack_config_with_bootstrap(BootstrapConfig {
1295            install_flow: Some("flow.a".to_string()),
1296            upgrade_flow: None,
1297            installer_component: Some("component.a".to_string()),
1298        });
1299        let flows = vec![flow_entry("flow.a")];
1300        let components = vec![minimal_component_manifest("component.a")];
1301
1302        let bootstrap = build_bootstrap(&config, &flows, &components)
1303            .expect("bootstrap populated")
1304            .expect("bootstrap present");
1305
1306        assert_eq!(bootstrap.install_flow.as_deref(), Some("flow.a"));
1307        assert_eq!(bootstrap.upgrade_flow, None);
1308        assert_eq!(
1309            bootstrap.installer_component.as_deref(),
1310            Some("component.a")
1311        );
1312    }
1313
1314    #[test]
1315    fn build_bootstrap_rejects_unknown_flow() {
1316        let config = pack_config_with_bootstrap(BootstrapConfig {
1317            install_flow: Some("missing".to_string()),
1318            upgrade_flow: None,
1319            installer_component: Some("component.a".to_string()),
1320        });
1321        let flows = vec![flow_entry("flow.a")];
1322        let components = vec![minimal_component_manifest("component.a")];
1323
1324        let err = build_bootstrap(&config, &flows, &components).unwrap_err();
1325        assert!(
1326            err.to_string()
1327                .contains("bootstrap.install_flow references unknown flow"),
1328            "unexpected error: {err}"
1329        );
1330    }
1331
1332    #[test]
1333    fn component_manifest_without_dev_flows_defaults_to_empty() {
1334        let manifest: ComponentManifest = serde_json::from_value(json!({
1335            "id": "component.dev",
1336            "version": "1.0.0",
1337            "supports": ["messaging"],
1338            "world": "greentic:demo@1.0.0",
1339            "profiles": { "default": "default", "supported": ["default"] },
1340            "capabilities": { "wasi": {}, "host": {} },
1341            "operations": [],
1342            "resources": {}
1343        }))
1344        .expect("manifest without dev_flows");
1345
1346        assert!(manifest.dev_flows.is_empty());
1347
1348        let pack_manifest = pack_manifest_with_component(manifest.clone());
1349        let encoded = encode_pack_manifest(&pack_manifest).expect("encode manifest");
1350        let decoded: PackManifest =
1351            greentic_types::decode_pack_manifest(&encoded).expect("decode manifest");
1352        let stored = decoded
1353            .components
1354            .iter()
1355            .find(|item| item.id == manifest.id)
1356            .expect("component present");
1357        assert!(stored.dev_flows.is_empty());
1358    }
1359
1360    #[test]
1361    fn dev_flows_round_trip_in_manifest_and_gtpack() {
1362        let component = manifest_with_dev_flow();
1363        let pack_manifest = pack_manifest_with_component(component.clone());
1364        let manifest_bytes = encode_pack_manifest(&pack_manifest).expect("encode manifest");
1365
1366        let decoded: PackManifest =
1367            greentic_types::decode_pack_manifest(&manifest_bytes).expect("decode manifest");
1368        let decoded_component = decoded
1369            .components
1370            .iter()
1371            .find(|item| item.id == component.id)
1372            .expect("component present");
1373        assert_eq!(decoded_component.dev_flows, component.dev_flows);
1374
1375        let temp = tempdir().expect("temp dir");
1376        let wasm_path = temp.path().join("component.wasm");
1377        write_stub_wasm(&wasm_path).expect("write stub wasm");
1378
1379        let build = BuildProducts {
1380            manifest: pack_manifest,
1381            components: vec![ComponentBinary {
1382                id: component.id.to_string(),
1383                source: wasm_path,
1384                manifest_bytes: serde_cbor::to_vec(&component).expect("component cbor"),
1385                manifest_path: format!("components/{}.manifest.cbor", component.id),
1386                manifest_hash_sha256: {
1387                    let mut sha = Sha256::new();
1388                    sha.update(serde_cbor::to_vec(&component).expect("component cbor"));
1389                    format!("sha256:{:x}", sha.finalize())
1390                },
1391            }],
1392            flow_files: Vec::new(),
1393            assets: Vec::new(),
1394        };
1395
1396        let out = temp.path().join("demo.gtpack");
1397        package_gtpack(&out, &manifest_bytes, &build, BundleMode::Cache).expect("package gtpack");
1398
1399        let mut archive = ZipArchive::new(fs::File::open(&out).expect("open gtpack"))
1400            .expect("read gtpack archive");
1401        let mut manifest_entry = archive.by_name("manifest.cbor").expect("manifest.cbor");
1402        let mut stored = Vec::new();
1403        manifest_entry
1404            .read_to_end(&mut stored)
1405            .expect("read manifest");
1406        let decoded: PackManifest =
1407            greentic_types::decode_pack_manifest(&stored).expect("decode packaged manifest");
1408
1409        let stored_component = decoded
1410            .components
1411            .iter()
1412            .find(|item| item.id == component.id)
1413            .expect("component preserved");
1414        assert_eq!(stored_component.dev_flows, component.dev_flows);
1415    }
1416
1417    #[test]
1418    fn component_sources_extension_respects_bundle() {
1419        let lock = PackLockV1::new(vec![LockedComponent {
1420            name: "demo.component".into(),
1421            r#ref: "oci://ghcr.io/demo/component:1.0.0".into(),
1422            digest: "sha256:deadbeef".into(),
1423        }]);
1424
1425        let ext_none =
1426            merge_component_sources_extension(None, &lock, BundleMode::None).expect("ext");
1427        let value = match ext_none
1428            .unwrap()
1429            .get(EXT_COMPONENT_SOURCES_V1)
1430            .and_then(|e| e.inline.as_ref())
1431        {
1432            Some(ExtensionInline::Other(v)) => v.clone(),
1433            _ => panic!("missing inline"),
1434        };
1435        let decoded = ComponentSourcesV1::from_extension_value(&value).expect("decode");
1436        assert!(matches!(
1437            decoded.components[0].artifact,
1438            ArtifactLocationV1::Remote
1439        ));
1440
1441        let ext_cache =
1442            merge_component_sources_extension(None, &lock, BundleMode::Cache).expect("ext");
1443        let value = match ext_cache
1444            .unwrap()
1445            .get(EXT_COMPONENT_SOURCES_V1)
1446            .and_then(|e| e.inline.as_ref())
1447        {
1448            Some(ExtensionInline::Other(v)) => v.clone(),
1449            _ => panic!("missing inline"),
1450        };
1451        let decoded = ComponentSourcesV1::from_extension_value(&value).expect("decode");
1452        assert!(matches!(
1453            decoded.components[0].artifact,
1454            ArtifactLocationV1::Inline { .. }
1455        ));
1456    }
1457
1458    #[test]
1459    fn aggregate_secret_requirements_dedupes_and_sorts() {
1460        let component: ComponentConfig = serde_json::from_value(json!({
1461            "id": "component.a",
1462            "version": "1.0.0",
1463            "world": "greentic:demo@1.0.0",
1464            "supports": [],
1465            "profiles": { "default": "default", "supported": ["default"] },
1466            "capabilities": {
1467                "wasi": {},
1468                "host": {
1469                    "secrets": {
1470                        "required": [
1471                    {
1472                        "key": "db/password",
1473                        "required": true,
1474                        "scope": { "env": "dev", "tenant": "t1" },
1475                        "format": "text",
1476                        "description": "primary"
1477                    }
1478                ]
1479            }
1480        }
1481            },
1482            "wasm": "component.wasm",
1483            "operations": [],
1484            "resources": {}
1485        }))
1486        .expect("component config");
1487
1488        let dupe: ComponentConfig = serde_json::from_value(json!({
1489            "id": "component.b",
1490            "version": "1.0.0",
1491            "world": "greentic:demo@1.0.0",
1492            "supports": [],
1493            "profiles": { "default": "default", "supported": ["default"] },
1494            "capabilities": {
1495                "wasi": {},
1496                "host": {
1497                    "secrets": {
1498                        "required": [
1499                            {
1500                        "key": "db/password",
1501                        "required": true,
1502                        "scope": { "env": "dev", "tenant": "t1" },
1503                        "format": "text",
1504                        "description": "secondary",
1505                        "examples": ["example"]
1506                    }
1507                ]
1508            }
1509                }
1510            },
1511            "wasm": "component.wasm",
1512            "operations": [],
1513            "resources": {}
1514        }))
1515        .expect("component config");
1516
1517        let reqs = aggregate_secret_requirements(&[component, dupe], None, None)
1518            .expect("aggregate secrets");
1519        assert_eq!(reqs.len(), 1);
1520        let req = &reqs[0];
1521        assert_eq!(req.description.as_deref(), Some("primary"));
1522        assert!(req.examples.contains(&"example".to_string()));
1523    }
1524
1525    fn pack_config_with_bootstrap(bootstrap: BootstrapConfig) -> PackConfig {
1526        PackConfig {
1527            pack_id: "demo.pack".to_string(),
1528            version: "1.0.0".to_string(),
1529            kind: "application".to_string(),
1530            publisher: "demo".to_string(),
1531            bootstrap: Some(bootstrap),
1532            components: Vec::new(),
1533            dependencies: Vec::new(),
1534            flows: Vec::new(),
1535            assets: Vec::new(),
1536            extensions: None,
1537        }
1538    }
1539
1540    fn flow_entry(id: &str) -> PackFlowEntry {
1541        let flow: Flow = serde_json::from_value(json!({
1542            "schema_version": "flow/v1",
1543            "id": id,
1544            "kind": "messaging"
1545        }))
1546        .expect("flow json");
1547
1548        PackFlowEntry {
1549            id: FlowId::new(id).expect("flow id"),
1550            kind: FlowKind::Messaging,
1551            flow,
1552            tags: Vec::new(),
1553            entrypoints: Vec::new(),
1554        }
1555    }
1556
1557    fn minimal_component_manifest(id: &str) -> ComponentManifest {
1558        serde_json::from_value(json!({
1559            "id": id,
1560            "version": "1.0.0",
1561            "supports": [],
1562            "world": "greentic:demo@1.0.0",
1563            "profiles": { "default": "default", "supported": ["default"] },
1564            "capabilities": { "wasi": {}, "host": {} },
1565            "operations": [],
1566            "resources": {}
1567        }))
1568        .expect("component manifest")
1569    }
1570
1571    fn manifest_with_dev_flow() -> ComponentManifest {
1572        serde_json::from_str(include_str!(
1573            "../tests/fixtures/component_manifest_with_dev_flows.json"
1574        ))
1575        .expect("fixture manifest")
1576    }
1577
1578    fn pack_manifest_with_component(component: ComponentManifest) -> PackManifest {
1579        let flow = serde_json::from_value(json!({
1580            "schema_version": "flow/v1",
1581            "id": "flow.dev",
1582            "kind": "messaging"
1583        }))
1584        .expect("flow json");
1585
1586        PackManifest {
1587            schema_version: "pack-v1".to_string(),
1588            pack_id: PackId::new("demo.pack").expect("pack id"),
1589            version: Version::parse("1.0.0").expect("version"),
1590            kind: PackKind::Application,
1591            publisher: "demo".to_string(),
1592            components: vec![component],
1593            flows: vec![PackFlowEntry {
1594                id: FlowId::new("flow.dev").expect("flow id"),
1595                kind: FlowKind::Messaging,
1596                flow,
1597                tags: Vec::new(),
1598                entrypoints: Vec::new(),
1599            }],
1600            dependencies: Vec::new(),
1601            capabilities: Vec::new(),
1602            secret_requirements: Vec::new(),
1603            signatures: PackSignatures::default(),
1604            bootstrap: None,
1605            extensions: None,
1606        }
1607    }
1608}