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