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