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