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