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