Skip to main content

packc/
build.rs

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