Skip to main content

packc/
build.rs

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