Skip to main content

packc/
build.rs

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