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