Skip to main content

packc/
build.rs

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