Skip to main content

packc/
pack_lock_doctor.rs

1#![forbid(unsafe_code)]
2
3use std::collections::{BTreeMap, HashMap};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result, anyhow, bail};
8use greentic_distributor_client::{DistClient, DistOptions};
9use greentic_flow::wizard_ops::{WizardMode, decode_component_qa_spec, fetch_wizard_spec};
10use greentic_pack::PackLoad;
11use greentic_pack::pack_lock::{LockedComponent, PackLockV1, read_pack_lock, validate_pack_lock};
12use greentic_types::cbor::canonical;
13use greentic_types::pack::extensions::component_sources::{
14    ArtifactLocationV1, ComponentSourceEntryV1, ComponentSourcesV1,
15};
16use greentic_types::schemas::common::schema_ir::{AdditionalProperties, SchemaIr};
17use greentic_types::schemas::component::v0_6_0::{ComponentDescribe, schema_hash};
18use greentic_types::validate::{Diagnostic, Severity};
19use serde_json::{Value, json};
20use sha2::{Digest, Sha256};
21use tokio::runtime::Handle;
22use wasmtime::Engine;
23use wasmtime::component::{Component as WasmtimeComponent, Linker};
24
25use crate::component_host_stubs::{
26    DescribeHostState, add_describe_host_imports, stub_remaining_imports,
27};
28use crate::runtime::{NetworkPolicy, RuntimeContext};
29
30pub struct PackLockDoctorInput<'a> {
31    pub load: &'a PackLoad,
32    pub pack_dir: Option<&'a Path>,
33    pub runtime: &'a RuntimeContext,
34    pub allow_oci_tags: bool,
35    pub use_describe_cache: bool,
36    pub online: bool,
37}
38
39pub struct PackLockDoctorOutput {
40    pub diagnostics: Vec<Diagnostic>,
41    pub has_errors: bool,
42}
43
44#[derive(Clone)]
45struct ComponentDiagnostic {
46    component_id: String,
47    diagnostic: Diagnostic,
48}
49
50struct WasmSource {
51    bytes: Vec<u8>,
52    source_path: Option<PathBuf>,
53    describe_bytes: Option<Vec<u8>>,
54}
55
56struct DescribeResolution {
57    describe: ComponentDescribe,
58    requires_typed_instance: bool,
59}
60
61pub fn run_pack_lock_doctor(input: PackLockDoctorInput<'_>) -> Result<PackLockDoctorOutput> {
62    let mut diagnostics: Vec<ComponentDiagnostic> = Vec::new();
63    let mut has_errors = false;
64
65    let pack_lock = match load_pack_lock(input.load, input.pack_dir) {
66        Ok(Some(lock)) => lock,
67        Ok(None) => {
68            diagnostics.push(ComponentDiagnostic {
69                component_id: String::new(),
70                diagnostic: Diagnostic {
71                    severity: Severity::Warn,
72                    code: "PACK_LOCK_MISSING".to_string(),
73                    message: "pack.lock.cbor missing; skipping pack lock doctor checks".to_string(),
74                    path: Some("pack.lock.cbor".to_string()),
75                    hint: Some(
76                        "run `greentic-pack resolve` to generate pack.lock.cbor".to_string(),
77                    ),
78                    data: Value::Null,
79                },
80            });
81            return Ok(finish_diagnostics(diagnostics));
82        }
83        Err(err) => {
84            diagnostics.push(ComponentDiagnostic {
85                component_id: String::new(),
86                diagnostic: Diagnostic {
87                    severity: Severity::Error,
88                    code: "PACK_LOCK_INVALID".to_string(),
89                    message: format!("failed to load pack.lock.cbor: {err}"),
90                    path: Some("pack.lock.cbor".to_string()),
91                    hint: Some("regenerate the lock with `greentic-pack resolve`".to_string()),
92                    data: Value::Null,
93                },
94            });
95            return Ok(finish_diagnostics(diagnostics));
96        }
97    };
98
99    let component_sources = match load_component_sources(input.load) {
100        Ok(sources) => sources,
101        Err(err) => {
102            diagnostics.push(ComponentDiagnostic {
103                component_id: String::new(),
104                diagnostic: Diagnostic {
105                    severity: Severity::Warn,
106                    code: "PACK_LOCK_COMPONENT_SOURCES_INVALID".to_string(),
107                    message: format!("component sources extension invalid: {err}"),
108                    path: Some("manifest.cbor".to_string()),
109                    hint: Some("rebuild the pack to refresh component sources".to_string()),
110                    data: Value::Null,
111                },
112            });
113            None
114        }
115    };
116
117    let component_sources_map = build_component_sources_map(component_sources.as_ref());
118    let manifest_map: HashMap<_, _> = input
119        .load
120        .manifest
121        .components
122        .iter()
123        .map(|entry| (entry.name.clone(), entry))
124        .collect();
125
126    let engine = Engine::default();
127
128    for (component_id, locked) in &pack_lock.components {
129        if locked.abi_version != "0.6.0" {
130            continue;
131        }
132
133        let wasm = match resolve_component_wasm(
134            &input,
135            &manifest_map,
136            &component_sources_map,
137            component_id,
138            locked,
139        ) {
140            Ok(wasm) => wasm,
141            Err(err) => {
142                has_errors = true;
143                diagnostics.push(component_diag(
144                    component_id,
145                    Severity::Error,
146                    "PACK_LOCK_COMPONENT_WASM_MISSING",
147                    format!("component wasm unavailable: {err}"),
148                    Some(format!("components/{component_id}")),
149                    Some(
150                        "bundle artifacts into the pack or allow online resolution with --online"
151                            .to_string(),
152                    ),
153                    Value::Null,
154                ));
155                continue;
156            }
157        };
158
159        let digest = format!("sha256:{}", hex::encode(Sha256::digest(&wasm.bytes)));
160        if digest != locked.resolved_digest {
161            has_errors = true;
162            diagnostics.push(component_diag(
163                component_id,
164                Severity::Error,
165                "PACK_LOCK_COMPONENT_DIGEST_MISMATCH",
166                "resolved_digest does not match component bytes".to_string(),
167                Some(format!("components/{component_id}")),
168                Some("re-run `greentic-pack resolve` after updating components".to_string()),
169                json!({ "expected": locked.resolved_digest, "actual": digest }),
170            ));
171        }
172
173        let describe_resolution = match describe_component_with_cache(
174            &engine,
175            &wasm,
176            input.use_describe_cache,
177            component_id,
178        ) {
179            Ok(describe) => describe,
180            Err(err) => {
181                has_errors = true;
182                diagnostics.push(component_diag(
183                    component_id,
184                    Severity::Error,
185                    "PACK_LOCK_COMPONENT_DESCRIBE_FAILED",
186                    format!("describe() failed: {err}"),
187                    Some(format!("components/{component_id}")),
188                    Some("ensure the component exports greentic:component@0.6.0".to_string()),
189                    Value::Null,
190                ));
191                continue;
192            }
193        };
194        let describe = describe_resolution.describe;
195
196        if describe.info.id != locked.component_id {
197            has_errors = true;
198            diagnostics.push(component_diag(
199                component_id,
200                Severity::Error,
201                "PACK_LOCK_COMPONENT_ID_MISMATCH",
202                "describe id does not match pack.lock component_id".to_string(),
203                Some(format!("components/{component_id}")),
204                None,
205                json!({ "describe_id": describe.info.id, "component_id": locked.component_id }),
206            ));
207        }
208
209        let describe_hash = compute_describe_hash(&describe)?;
210        if describe_hash != locked.describe_hash {
211            has_errors = true;
212            diagnostics.push(component_diag(
213                component_id,
214                Severity::Error,
215                "PACK_LOCK_DESCRIBE_HASH_MISMATCH",
216                "describe_hash does not match describe() output".to_string(),
217                Some(format!("components/{component_id}")),
218                Some("re-run `greentic-pack resolve` after updating components".to_string()),
219                json!({ "expected": locked.describe_hash, "actual": describe_hash }),
220            ));
221        }
222
223        let mut describe_ops = BTreeMap::new();
224        for op in &describe.operations {
225            describe_ops.insert(op.id.clone(), op);
226        }
227
228        for op in &describe.operations {
229            let recomputed =
230                match schema_hash(&op.input.schema, &op.output.schema, &describe.config_schema) {
231                    Ok(hash) => hash,
232                    Err(err) => {
233                        has_errors = true;
234                        diagnostics.push(component_diag(
235                            component_id,
236                            Severity::Error,
237                            "PACK_LOCK_SCHEMA_HASH_COMPUTE_FAILED",
238                            format!("schema_hash failed for {}: {err}", op.id),
239                            Some(format!(
240                                "components/{component_id}/operations/{}/schema_hash",
241                                op.id
242                            )),
243                            None,
244                            Value::Null,
245                        ));
246                        continue;
247                    }
248                };
249
250            if recomputed != op.schema_hash {
251                has_errors = true;
252                diagnostics.push(component_diag(
253                    component_id,
254                    Severity::Error,
255                    "PACK_LOCK_SCHEMA_HASH_DESCRIBE_MISMATCH",
256                    "schema_hash does not match describe() payload".to_string(),
257                    Some(format!(
258                        "components/{component_id}/operations/{}/schema_hash",
259                        op.id
260                    )),
261                    None,
262                    json!({ "expected": op.schema_hash, "actual": recomputed }),
263                ));
264            }
265
266            match locked
267                .operations
268                .iter()
269                .find(|entry| entry.operation_id == op.id)
270            {
271                Some(lock_op) => {
272                    if recomputed != lock_op.schema_hash {
273                        has_errors = true;
274                        diagnostics.push(component_diag(
275                            component_id,
276                            Severity::Error,
277                            "PACK_LOCK_SCHEMA_HASH_LOCK_MISMATCH",
278                            "schema_hash does not match pack.lock entry".to_string(),
279                            Some(format!(
280                                "components/{component_id}/operations/{}/schema_hash",
281                                op.id
282                            )),
283                            None,
284                            json!({ "expected": lock_op.schema_hash, "actual": recomputed }),
285                        ));
286                    }
287                }
288                None => {
289                    has_errors = true;
290                    diagnostics.push(component_diag(
291                        component_id,
292                        Severity::Error,
293                        "PACK_LOCK_OPERATION_MISSING",
294                        "operation missing from pack.lock".to_string(),
295                        Some(format!("components/{component_id}/operations/{}", op.id)),
296                        Some(
297                            "re-run `greentic-pack resolve` to refresh lock operations".to_string(),
298                        ),
299                        Value::Null,
300                    ));
301                }
302            }
303
304            validate_schema_ir(
305                component_id,
306                &op.input.schema,
307                &format!(
308                    "components/{component_id}/operations/{}/input.schema",
309                    op.id
310                ),
311                &mut diagnostics,
312                &mut has_errors,
313            );
314            validate_schema_ir(
315                component_id,
316                &op.output.schema,
317                &format!(
318                    "components/{component_id}/operations/{}/output.schema",
319                    op.id
320                ),
321                &mut diagnostics,
322                &mut has_errors,
323            );
324        }
325
326        for lock_op in &locked.operations {
327            if !describe_ops.contains_key(&lock_op.operation_id) {
328                has_errors = true;
329                diagnostics.push(component_diag(
330                    component_id,
331                    Severity::Error,
332                    "PACK_LOCK_OPERATION_STALE",
333                    "pack.lock operation not present in describe()".to_string(),
334                    Some(format!(
335                        "components/{component_id}/operations/{}",
336                        lock_op.operation_id
337                    )),
338                    Some("re-run `greentic-pack resolve` to refresh lock operations".to_string()),
339                    Value::Null,
340                ));
341            }
342        }
343
344        validate_schema_ir(
345            component_id,
346            &describe.config_schema,
347            &format!("components/{component_id}/config_schema"),
348            &mut diagnostics,
349            &mut has_errors,
350        );
351
352        if let Err(err) = WasmtimeComponent::from_binary(&engine, &wasm.bytes) {
353            has_errors = true;
354            diagnostics.push(component_diag(
355                component_id,
356                Severity::Error,
357                "PACK_LOCK_COMPONENT_DECODE_FAILED",
358                format!("component bytes are not a valid component: {err}"),
359                Some(format!("components/{component_id}")),
360                Some("rebuild the pack with a valid component artifact".to_string()),
361                Value::Null,
362            ));
363            continue;
364        }
365
366        if !describe_resolution.requires_typed_instance {
367            diagnostics.push(component_diag(
368                component_id,
369                Severity::Warn,
370                "PACK_LOCK_COMPONENT_WORLD_FALLBACK",
371                "describe was resolved via fallback; qa/i18n contract checks were skipped"
372                    .to_string(),
373                Some(format!("components/{component_id}")),
374                None,
375                Value::Null,
376            ));
377        }
378
379        for (mode, label) in [
380            (WizardMode::Default, "default"),
381            (WizardMode::Setup, "setup"),
382            (WizardMode::Update, "update"),
383            (WizardMode::Remove, "remove"),
384        ] {
385            let spec = match fetch_wizard_spec(&wasm.bytes, mode) {
386                Ok(spec) => spec,
387                Err(err) => {
388                    has_errors = true;
389                    diagnostics.push(component_diag(
390                        component_id,
391                        Severity::Error,
392                        "PACK_LOCK_QA_SPEC_MISSING",
393                        format!("wizard qa_spec fetch failed for {label}: {err}"),
394                        Some(format!("components/{component_id}/qa/{label}")),
395                        Some(
396                            "ensure setup contract and setup.apply_answers are exported"
397                                .to_string(),
398                        ),
399                        Value::Null,
400                    ));
401                    continue;
402                }
403            };
404            if let Err(err) = decode_component_qa_spec(&spec.qa_spec_cbor, mode) {
405                has_errors = true;
406                diagnostics.push(component_diag(
407                    component_id,
408                    Severity::Error,
409                    "PACK_LOCK_QA_SPEC_DECODE_FAILED",
410                    format!("qa_spec decode failed for {label}: {err}"),
411                    Some(format!("components/{component_id}/qa/{label}")),
412                    Some("ensure qa_spec is valid canonical CBOR/legacy JSON".to_string()),
413                    Value::Null,
414                ));
415            }
416        }
417    }
418
419    Ok(finish_diagnostics(diagnostics))
420}
421
422fn finish_diagnostics(mut diagnostics: Vec<ComponentDiagnostic>) -> PackLockDoctorOutput {
423    diagnostics.sort_by(|a, b| {
424        let path_a = a.diagnostic.path.as_deref().unwrap_or_default();
425        let path_b = b.diagnostic.path.as_deref().unwrap_or_default();
426        a.component_id
427            .cmp(&b.component_id)
428            .then_with(|| a.diagnostic.code.cmp(&b.diagnostic.code))
429            .then_with(|| path_a.cmp(path_b))
430    });
431    let mut has_errors = false;
432    let diagnostics: Vec<Diagnostic> = diagnostics
433        .into_iter()
434        .map(|entry| {
435            if matches!(entry.diagnostic.severity, Severity::Error) {
436                has_errors = true;
437            }
438            entry.diagnostic
439        })
440        .collect();
441    PackLockDoctorOutput {
442        diagnostics,
443        has_errors,
444    }
445}
446
447fn load_pack_lock(load: &PackLoad, pack_dir: Option<&Path>) -> Result<Option<PackLockV1>> {
448    if let Some(bytes) = load.files.get("pack.lock.cbor") {
449        return read_pack_lock_from_bytes(bytes).map(Some);
450    }
451    let Some(pack_dir) = pack_dir else {
452        return Ok(None);
453    };
454    let path = pack_dir.join("pack.lock.cbor");
455    if !path.exists() {
456        return Ok(None);
457    }
458    read_pack_lock(&path).map(Some)
459}
460
461fn read_pack_lock_from_bytes(bytes: &[u8]) -> Result<PackLockV1> {
462    canonical::ensure_canonical(bytes).context("pack.lock.cbor must be canonical")?;
463    let lock: PackLockV1 = canonical::from_cbor(bytes).context("decode pack.lock.cbor")?;
464    validate_pack_lock(&lock)?;
465    Ok(lock)
466}
467
468fn load_component_sources(load: &PackLoad) -> Result<Option<ComponentSourcesV1>> {
469    let Some(manifest) = load.gpack_manifest.as_ref() else {
470        return Ok(None);
471    };
472    manifest
473        .get_component_sources_v1()
474        .map_err(|err| anyhow!(err.to_string()))
475}
476
477fn build_component_sources_map(
478    sources: Option<&ComponentSourcesV1>,
479) -> HashMap<String, ComponentSourceEntryV1> {
480    let mut map = HashMap::new();
481    let Some(sources) = sources else {
482        return map;
483    };
484    for entry in &sources.components {
485        let key = entry
486            .component_id
487            .as_ref()
488            .map(|id| id.to_string())
489            .unwrap_or_else(|| entry.name.clone());
490        map.insert(key, entry.clone());
491    }
492    map
493}
494
495fn resolve_component_wasm(
496    input: &PackLockDoctorInput<'_>,
497    manifest_map: &HashMap<String, &greentic_pack::builder::ComponentEntry>,
498    component_sources_map: &HashMap<String, ComponentSourceEntryV1>,
499    component_id: &str,
500    locked: &LockedComponent,
501) -> Result<WasmSource> {
502    if let Some(entry) = manifest_map.get(component_id) {
503        let logical = entry.file_wasm.clone();
504        if let Some(bytes) = input.load.files.get(&logical) {
505            return Ok(WasmSource {
506                bytes: bytes.clone(),
507                source_path: input
508                    .pack_dir
509                    .map(|dir| dir.join(&entry.file_wasm))
510                    .filter(|path| path.exists()),
511                describe_bytes: load_describe_sidecar_from_pack(input.load, &logical),
512            });
513        }
514        if let Some(pack_dir) = input.pack_dir {
515            let disk_path = pack_dir.join(&entry.file_wasm);
516            if disk_path.exists() {
517                let bytes = fs::read(&disk_path)
518                    .with_context(|| format!("read {}", disk_path.display()))?;
519                return Ok(WasmSource {
520                    bytes,
521                    source_path: Some(disk_path),
522                    describe_bytes: None,
523                });
524            }
525        }
526    }
527
528    if let Some(entry) = component_sources_map.get(component_id)
529        && let ArtifactLocationV1::Inline { wasm_path, .. } = &entry.artifact
530    {
531        if let Some(bytes) = input.load.files.get(wasm_path) {
532            return Ok(WasmSource {
533                bytes: bytes.clone(),
534                source_path: input
535                    .pack_dir
536                    .map(|dir| dir.join(wasm_path))
537                    .filter(|path| path.exists()),
538                describe_bytes: load_describe_sidecar_from_pack(input.load, wasm_path),
539            });
540        }
541        if let Some(pack_dir) = input.pack_dir {
542            let disk_path = pack_dir.join(wasm_path);
543            if disk_path.exists() {
544                let bytes = fs::read(&disk_path)
545                    .with_context(|| format!("read {}", disk_path.display()))?;
546                return Ok(WasmSource {
547                    bytes,
548                    source_path: Some(disk_path),
549                    describe_bytes: None,
550                });
551            }
552        }
553    }
554
555    if let Some(reference) = locked.r#ref.as_ref()
556        && reference.starts_with("file://")
557    {
558        let rel = strip_file_uri_prefix(reference);
559        // Root the (possibly relative) ref at the pack dir so portable locks
560        // resolve; absolute legacy refs are left untouched by join().
561        let path = input
562            .pack_dir
563            .map(|dir| dir.join(rel))
564            .unwrap_or_else(|| PathBuf::from(rel));
565        let bytes = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
566        return Ok(WasmSource {
567            bytes,
568            source_path: Some(path),
569            describe_bytes: None,
570        });
571    }
572
573    let reference = locked
574        .r#ref
575        .as_ref()
576        .ok_or_else(|| anyhow!("component {} missing ref", component_id))?;
577    if input.online {
578        input
579            .runtime
580            .require_online("pack lock doctor component download")?;
581    }
582    let offline = !input.online || input.runtime.network_policy() == NetworkPolicy::Offline;
583    let dist = DistClient::new(DistOptions {
584        cache_dir: input.runtime.cache_dir(),
585        allow_tags: input.allow_oci_tags,
586        offline,
587        allow_insecure_local_http: false,
588        ..DistOptions::default()
589    });
590
591    let handle = Handle::try_current().context("component resolution requires a Tokio runtime")?;
592    let resolved = if offline {
593        dist.open_cached(&locked.resolved_digest)
594            .map_err(|err| anyhow!("offline cache miss for {}: {}", reference, err))?
595    } else {
596        let source = dist
597            .parse_source(reference)
598            .map_err(|err| anyhow!("resolve {}: {}", reference, err))?;
599        let descriptor = block_on(
600            &handle,
601            dist.resolve(source, greentic_distributor_client::ResolvePolicy),
602        )
603        .map_err(|err| anyhow!("resolve {}: {}", reference, err))?;
604        block_on(
605            &handle,
606            dist.fetch(&descriptor, greentic_distributor_client::CachePolicy),
607        )
608        .map_err(|err| anyhow!("resolve {}: {}", reference, err))?
609    };
610    let path = resolved
611        .cache_path
612        .ok_or_else(|| anyhow!("resolved component missing path for {}", reference))?;
613    let bytes = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
614    Ok(WasmSource {
615        bytes,
616        source_path: Some(path),
617        describe_bytes: None,
618    })
619}
620
621fn block_on<F, T, E>(handle: &Handle, fut: F) -> std::result::Result<T, E>
622where
623    F: std::future::Future<Output = std::result::Result<T, E>>,
624{
625    tokio::task::block_in_place(|| handle.block_on(fut))
626}
627
628fn describe_component_with_cache(
629    engine: &Engine,
630    wasm: &WasmSource,
631    use_cache: bool,
632    component_id: &str,
633) -> Result<DescribeResolution> {
634    match describe_component(engine, &wasm.bytes) {
635        Ok(describe) => Ok(DescribeResolution {
636            describe,
637            requires_typed_instance: true,
638        }),
639        Err(err) => {
640            if should_fallback_to_untyped_describe(&err)
641                && let Ok(describe) = describe_component_untyped(engine, &wasm.bytes)
642            {
643                return Ok(DescribeResolution {
644                    describe,
645                    requires_typed_instance: false,
646                });
647            }
648            if use_cache || should_fallback_to_describe_cache(&err) {
649                if let Some(describe) = load_describe_from_cache(
650                    wasm.describe_bytes.as_deref(),
651                    wasm.source_path.as_deref(),
652                )? {
653                    return Ok(DescribeResolution {
654                        describe,
655                        requires_typed_instance: false,
656                    });
657                }
658                bail!("describe failed and no describe cache found for {component_id}: {err}");
659            }
660            Err(err)
661        }
662    }
663}
664
665fn describe_component_untyped(engine: &Engine, bytes: &[u8]) -> Result<ComponentDescribe> {
666    let component = WasmtimeComponent::from_binary(engine, bytes)
667        .map_err(|err| anyhow!("decode component bytes: {err}"))?;
668    let mut store = wasmtime::Store::new(engine, DescribeHostState::default());
669    let mut linker = Linker::new(engine);
670    add_describe_host_imports(&mut linker)?;
671    // Stub any remaining imports (secrets-store, http-client, interfaces-types,
672    // etc.) that the component needs but describe() never calls at runtime.
673    stub_remaining_imports(&mut linker, &component)?;
674    let instance = linker
675        .instantiate(&mut store, &component)
676        .map_err(|err| anyhow!("instantiate component root world: {err}"))?;
677
678    let descriptor = [
679        "component-descriptor",
680        "greentic:component/component-descriptor",
681        "greentic:component/component-descriptor@0.6.0",
682    ]
683    .iter()
684    .find_map(|name| instance.get_export_index(&mut store, None, name))
685    .ok_or_else(|| anyhow!("missing exported descriptor instance"))?;
686    let describe_export = [
687        "describe",
688        "greentic:component/component-descriptor@0.6.0#describe",
689    ]
690    .iter()
691    .find_map(|name| instance.get_export_index(&mut store, Some(&descriptor), name))
692    .ok_or_else(|| anyhow!("missing exported describe function"))?;
693    let describe_func = instance
694        .get_typed_func::<(), (Vec<u8>,)>(&mut store, &describe_export)
695        .map_err(|err| anyhow!("lookup component-descriptor.describe: {err}"))?;
696    let (describe_bytes,) = describe_func
697        .call(&mut store, ())
698        .map_err(|err| anyhow!("call component-descriptor.describe: {err}"))?;
699    canonical::from_cbor(&describe_bytes).context("decode ComponentDescribe")
700}
701
702fn should_fallback_to_describe_cache(err: &anyhow::Error) -> bool {
703    err.to_string().contains("instantiate component-v0-v6-v0")
704}
705
706fn should_fallback_to_untyped_describe(err: &anyhow::Error) -> bool {
707    err.to_string().contains("instantiate component-v0-v6-v0")
708}
709
710fn describe_component(engine: &Engine, bytes: &[u8]) -> Result<ComponentDescribe> {
711    describe_component_untyped(engine, bytes)
712}
713
714fn load_describe_from_cache(
715    inline_bytes: Option<&[u8]>,
716    source_path: Option<&Path>,
717) -> Result<Option<ComponentDescribe>> {
718    if let Some(bytes) = inline_bytes {
719        canonical::ensure_canonical(bytes).context("describe cache must be canonical")?;
720        let describe =
721            canonical::from_cbor(bytes).context("decode ComponentDescribe from cache")?;
722        return Ok(Some(describe));
723    }
724    if let Some(path) = source_path {
725        let describe_path = PathBuf::from(format!("{}.describe.cbor", path.display()));
726        if !describe_path.exists() {
727            return Ok(None);
728        }
729        let bytes = fs::read(&describe_path)
730            .with_context(|| format!("read {}", describe_path.display()))?;
731        canonical::ensure_canonical(&bytes).context("describe cache must be canonical")?;
732        let describe =
733            canonical::from_cbor(&bytes).context("decode ComponentDescribe from cache")?;
734        return Ok(Some(describe));
735    }
736    Ok(None)
737}
738
739fn load_describe_sidecar_from_pack(load: &PackLoad, logical_path: &str) -> Option<Vec<u8>> {
740    let describe_path = format!("{logical_path}.describe.cbor");
741    load.files.get(&describe_path).cloned()
742}
743
744fn compute_describe_hash(describe: &ComponentDescribe) -> Result<String> {
745    let bytes =
746        canonical::to_canonical_cbor_allow_floats(describe).context("canonicalize describe")?;
747    let digest = Sha256::digest(bytes.as_slice());
748    Ok(hex::encode(digest))
749}
750
751fn validate_schema_ir(
752    component_id: &str,
753    schema: &SchemaIr,
754    path: &str,
755    diagnostics: &mut Vec<ComponentDiagnostic>,
756    has_errors: &mut bool,
757) {
758    match schema {
759        SchemaIr::Object {
760            properties,
761            required,
762            additional,
763        } => {
764            if properties.is_empty()
765                && required.is_empty()
766                && matches!(additional, AdditionalProperties::Allow)
767            {
768                let inside_variant = path.contains("/variants/");
769                if !inside_variant {
770                    *has_errors = true;
771                }
772                diagnostics.push(component_diag(
773                    component_id,
774                    if inside_variant {
775                        Severity::Warn
776                    } else {
777                        Severity::Error
778                    },
779                    "PACK_LOCK_SCHEMA_UNCONSTRAINED_OBJECT",
780                    "object schema is unconstrained".to_string(),
781                    Some(path.to_string()),
782                    Some("add properties or set additional=forbid".to_string()),
783                    Value::Null,
784                ));
785            }
786            for req in required {
787                if !properties.contains_key(req) {
788                    *has_errors = true;
789                    diagnostics.push(component_diag(
790                        component_id,
791                        Severity::Error,
792                        "PACK_LOCK_SCHEMA_REQUIRED_MISSING",
793                        format!("required property `{req}` missing from properties"),
794                        Some(path.to_string()),
795                        None,
796                        Value::Null,
797                    ));
798                }
799            }
800            for (name, prop) in properties {
801                let child_path = format!("{path}/properties/{name}");
802                validate_schema_ir(component_id, prop, &child_path, diagnostics, has_errors);
803            }
804            if let AdditionalProperties::Schema(schema) = additional {
805                let child_path = format!("{path}/additional");
806                validate_schema_ir(component_id, schema, &child_path, diagnostics, has_errors);
807            }
808        }
809        SchemaIr::Array {
810            items,
811            min_items,
812            max_items,
813        } => {
814            if let (Some(min), Some(max)) = (min_items, max_items)
815                && min > max
816            {
817                *has_errors = true;
818                diagnostics.push(component_diag(
819                    component_id,
820                    Severity::Error,
821                    "PACK_LOCK_SCHEMA_ARRAY_BOUNDS",
822                    format!("min_items {min} exceeds max_items {max}"),
823                    Some(path.to_string()),
824                    None,
825                    Value::Null,
826                ));
827            }
828            let child_path = format!("{path}/items");
829            validate_schema_ir(component_id, items, &child_path, diagnostics, has_errors);
830        }
831        SchemaIr::String {
832            min_len,
833            max_len,
834            regex,
835            format,
836        } => {
837            if let (Some(min), Some(max)) = (min_len, max_len)
838                && min > max
839            {
840                *has_errors = true;
841                diagnostics.push(component_diag(
842                    component_id,
843                    Severity::Error,
844                    "PACK_LOCK_SCHEMA_STRING_BOUNDS",
845                    format!("min_len {min} exceeds max_len {max}"),
846                    Some(path.to_string()),
847                    None,
848                    Value::Null,
849                ));
850            }
851            if regex.is_some() || format.is_some() {
852                diagnostics.push(component_diag(
853                    component_id,
854                    Severity::Warn,
855                    "PACK_LOCK_SCHEMA_STRING_CONSTRAINT",
856                    "string regex/format constraints are not validated by pack doctor".to_string(),
857                    Some(path.to_string()),
858                    None,
859                    Value::Null,
860                ));
861            }
862        }
863        SchemaIr::Int { min, max } => {
864            if let (Some(min), Some(max)) = (min, max)
865                && min > max
866            {
867                *has_errors = true;
868                diagnostics.push(component_diag(
869                    component_id,
870                    Severity::Error,
871                    "PACK_LOCK_SCHEMA_INT_BOUNDS",
872                    format!("min {min} exceeds max {max}"),
873                    Some(path.to_string()),
874                    None,
875                    Value::Null,
876                ));
877            }
878        }
879        SchemaIr::Float { min, max } => {
880            if let (Some(min), Some(max)) = (min, max)
881                && min > max
882            {
883                *has_errors = true;
884                diagnostics.push(component_diag(
885                    component_id,
886                    Severity::Error,
887                    "PACK_LOCK_SCHEMA_FLOAT_BOUNDS",
888                    format!("min {min} exceeds max {max}"),
889                    Some(path.to_string()),
890                    None,
891                    Value::Null,
892                ));
893            }
894        }
895        SchemaIr::Enum { values } => {
896            if values.is_empty() {
897                *has_errors = true;
898                diagnostics.push(component_diag(
899                    component_id,
900                    Severity::Error,
901                    "PACK_LOCK_SCHEMA_ENUM_EMPTY",
902                    "enum has no values".to_string(),
903                    Some(path.to_string()),
904                    None,
905                    Value::Null,
906                ));
907            }
908        }
909        SchemaIr::OneOf { variants } => {
910            if variants.is_empty() {
911                *has_errors = true;
912                diagnostics.push(component_diag(
913                    component_id,
914                    Severity::Error,
915                    "PACK_LOCK_SCHEMA_ONEOF_EMPTY",
916                    "oneOf has no variants".to_string(),
917                    Some(path.to_string()),
918                    None,
919                    Value::Null,
920                ));
921            }
922            for (idx, variant) in variants.iter().enumerate() {
923                let child_path = format!("{path}/variants/{idx}");
924                validate_schema_ir(component_id, variant, &child_path, diagnostics, has_errors);
925            }
926        }
927        SchemaIr::Bool | SchemaIr::Null | SchemaIr::Bytes | SchemaIr::Ref { .. } => {}
928    }
929}
930
931fn component_diag(
932    component_id: &str,
933    severity: Severity,
934    code: &str,
935    message: String,
936    path: Option<String>,
937    hint: Option<String>,
938    data: Value,
939) -> ComponentDiagnostic {
940    ComponentDiagnostic {
941        component_id: component_id.to_string(),
942        diagnostic: Diagnostic {
943            severity,
944            code: code.to_string(),
945            message,
946            path,
947            hint,
948            data,
949        },
950    }
951}
952
953fn strip_file_uri_prefix(reference: &str) -> &str {
954    reference.strip_prefix("file://").unwrap_or(reference)
955}
956
957#[cfg(test)]
958mod tests {
959    use super::*;
960    use greentic_pack::PackLoad;
961    use greentic_types::ComponentId;
962    use greentic_types::component_source::ComponentSourceRef;
963    use greentic_types::pack::extensions::component_sources::{
964        ComponentSourceEntryV1, ResolvedComponentV1,
965    };
966    use greentic_types::schemas::common::schema_ir::AdditionalProperties;
967    use tempfile::TempDir;
968
969    #[test]
970    fn finish_diagnostics_sorts_and_marks_errors() {
971        let output = finish_diagnostics(vec![
972            component_diag(
973                "b.component",
974                Severity::Warn,
975                "WARN_CODE",
976                "warn".to_string(),
977                Some("z".to_string()),
978                None,
979                Value::Null,
980            ),
981            component_diag(
982                "a.component",
983                Severity::Error,
984                "ERR_CODE",
985                "err".to_string(),
986                Some("a".to_string()),
987                None,
988                Value::Null,
989            ),
990        ]);
991
992        assert!(output.has_errors);
993        assert_eq!(output.diagnostics[0].code, "ERR_CODE");
994        assert_eq!(output.diagnostics[1].code, "WARN_CODE");
995    }
996
997    #[test]
998    fn build_component_sources_map_prefers_component_id_when_present() {
999        let sources = ComponentSourcesV1::new(vec![
1000            ComponentSourceEntryV1 {
1001                name: "friendly".to_string(),
1002                component_id: Some(ComponentId::try_from("demo.component").expect("component id")),
1003                source: ComponentSourceRef::File("components/demo.wasm".to_string()),
1004                resolved: ResolvedComponentV1 {
1005                    digest: "sha256:abc".to_string(),
1006                    signature: None,
1007                    signed_by: None,
1008                },
1009                artifact: ArtifactLocationV1::Remote,
1010                licensing_hint: None,
1011                metering_hint: None,
1012            },
1013            ComponentSourceEntryV1 {
1014                name: "name.only".to_string(),
1015                component_id: None,
1016                source: ComponentSourceRef::File("components/name.wasm".to_string()),
1017                resolved: ResolvedComponentV1 {
1018                    digest: "sha256:def".to_string(),
1019                    signature: None,
1020                    signed_by: None,
1021                },
1022                artifact: ArtifactLocationV1::Remote,
1023                licensing_hint: None,
1024                metering_hint: None,
1025            },
1026        ]);
1027
1028        let map = build_component_sources_map(Some(&sources));
1029        assert!(map.contains_key("demo.component"));
1030        assert!(map.contains_key("name.only"));
1031        assert!(!map.contains_key("friendly"));
1032    }
1033
1034    #[test]
1035    fn validate_schema_ir_reports_unconstrained_objects_and_bad_bounds() {
1036        let mut diagnostics = Vec::new();
1037        let mut has_errors = false;
1038
1039        validate_schema_ir(
1040            "demo.component",
1041            &SchemaIr::Object {
1042                properties: BTreeMap::new(),
1043                required: Vec::new(),
1044                additional: AdditionalProperties::Allow,
1045            },
1046            "config",
1047            &mut diagnostics,
1048            &mut has_errors,
1049        );
1050        validate_schema_ir(
1051            "demo.component",
1052            &SchemaIr::Array {
1053                items: Box::new(SchemaIr::Bool),
1054                min_items: Some(3),
1055                max_items: Some(1),
1056            },
1057            "config/list",
1058            &mut diagnostics,
1059            &mut has_errors,
1060        );
1061
1062        assert!(has_errors);
1063        assert!(diagnostics.iter().any(|diag| {
1064            diag.diagnostic.code == "PACK_LOCK_SCHEMA_UNCONSTRAINED_OBJECT"
1065                && diag.diagnostic.path.as_deref() == Some("config")
1066        }));
1067        assert!(diagnostics.iter().any(|diag| {
1068            diag.diagnostic.code == "PACK_LOCK_SCHEMA_ARRAY_BOUNDS"
1069                && diag.diagnostic.path.as_deref() == Some("config/list")
1070        }));
1071    }
1072
1073    #[test]
1074    fn validate_schema_ir_downgrades_variant_unconstrained_object_to_warning() {
1075        let mut diagnostics = Vec::new();
1076        let mut has_errors = false;
1077
1078        validate_schema_ir(
1079            "demo.component",
1080            &SchemaIr::Object {
1081                properties: BTreeMap::new(),
1082                required: Vec::new(),
1083                additional: AdditionalProperties::Allow,
1084            },
1085            "config/variants/0",
1086            &mut diagnostics,
1087            &mut has_errors,
1088        );
1089
1090        assert!(!has_errors);
1091        assert_eq!(diagnostics.len(), 1);
1092        assert!(matches!(diagnostics[0].diagnostic.severity, Severity::Warn));
1093    }
1094
1095    #[test]
1096    fn strip_file_uri_prefix_handles_prefixed_and_plain_paths() {
1097        assert_eq!(
1098            strip_file_uri_prefix("file:///tmp/demo.wasm"),
1099            "/tmp/demo.wasm"
1100        );
1101        assert_eq!(
1102            strip_file_uri_prefix("components/demo.wasm"),
1103            "components/demo.wasm"
1104        );
1105    }
1106
1107    #[test]
1108    fn validate_schema_ir_reports_string_constraints_as_warnings() {
1109        let mut diagnostics = Vec::new();
1110        let mut has_errors = false;
1111
1112        validate_schema_ir(
1113            "demo.component",
1114            &SchemaIr::String {
1115                min_len: Some(1),
1116                max_len: Some(8),
1117                regex: Some("^demo$".to_string()),
1118                format: None,
1119            },
1120            "config/name",
1121            &mut diagnostics,
1122            &mut has_errors,
1123        );
1124
1125        assert!(!has_errors);
1126        assert_eq!(diagnostics.len(), 1);
1127        assert_eq!(
1128            diagnostics[0].diagnostic.code,
1129            "PACK_LOCK_SCHEMA_STRING_CONSTRAINT"
1130        );
1131    }
1132
1133    #[test]
1134    fn load_describe_from_cache_reads_inline_and_sidecar_bytes() {
1135        let temp = TempDir::new().expect("temp dir");
1136        let describe = greentic_types::schemas::component::v0_6_0::ComponentDescribe {
1137            info: greentic_types::schemas::component::v0_6_0::ComponentInfo {
1138                id: "demo.component".to_string(),
1139                version: "0.1.0".to_string(),
1140                role: "tool".to_string(),
1141                display_name: None,
1142            },
1143            provided_capabilities: Vec::new(),
1144            required_capabilities: Vec::new(),
1145            metadata: BTreeMap::new(),
1146            operations: Vec::new(),
1147            config_schema: SchemaIr::Bool,
1148            outcomes: Vec::new(),
1149        };
1150        let bytes = canonical::to_canonical_cbor_allow_floats(&describe).expect("describe bytes");
1151        let wasm_path = temp.path().join("component.wasm");
1152        std::fs::write(&wasm_path, b"\0asm").expect("wasm");
1153        std::fs::write(format!("{}.describe.cbor", wasm_path.display()), &bytes).expect("sidecar");
1154
1155        let inline = load_describe_from_cache(Some(&bytes), None)
1156            .expect("inline load")
1157            .expect("inline describe");
1158        let sidecar = load_describe_from_cache(None, Some(&wasm_path))
1159            .expect("sidecar load")
1160            .expect("sidecar describe");
1161
1162        assert_eq!(inline.info.id, "demo.component");
1163        assert_eq!(sidecar.info.id, "demo.component");
1164    }
1165
1166    #[test]
1167    fn load_describe_sidecar_from_pack_uses_logical_suffix() {
1168        let manifest = greentic_pack::builder::PackManifest {
1169            meta: greentic_pack::builder::PackMeta {
1170                pack_version: 1,
1171                pack_id: "demo.pack".to_string(),
1172                version: semver::Version::parse("0.1.0").expect("version"),
1173                name: "Demo".to_string(),
1174                kind: None,
1175                description: None,
1176                authors: Vec::new(),
1177                license: None,
1178                homepage: None,
1179                support: None,
1180                vendor: None,
1181                imports: Vec::new(),
1182                entry_flows: Vec::new(),
1183                created_at_utc: "2026-01-01T00:00:00Z".to_string(),
1184                events: None,
1185                repo: None,
1186                messaging: None,
1187                interfaces: Vec::new(),
1188                annotations: serde_json::Map::new(),
1189                distribution: None,
1190                components: Vec::new(),
1191            },
1192            flows: Vec::new(),
1193            components: Vec::new(),
1194            distribution: None,
1195            component_descriptors: Vec::new(),
1196        };
1197        let mut load = PackLoad {
1198            manifest,
1199            report: Default::default(),
1200            sbom: Vec::new(),
1201            files: HashMap::new(),
1202            gpack_manifest: None,
1203        };
1204        load.files.insert(
1205            "components/demo.wasm.describe.cbor".to_string(),
1206            vec![1, 2, 3],
1207        );
1208
1209        assert_eq!(
1210            load_describe_sidecar_from_pack(&load, "components/demo.wasm"),
1211            Some(vec![1, 2, 3])
1212        );
1213    }
1214
1215    #[test]
1216    fn compute_describe_hash_is_stable_for_same_payload() {
1217        let describe = greentic_types::schemas::component::v0_6_0::ComponentDescribe {
1218            info: greentic_types::schemas::component::v0_6_0::ComponentInfo {
1219                id: "demo.component".to_string(),
1220                version: "0.1.0".to_string(),
1221                role: "tool".to_string(),
1222                display_name: None,
1223            },
1224            provided_capabilities: Vec::new(),
1225            required_capabilities: Vec::new(),
1226            metadata: BTreeMap::new(),
1227            operations: Vec::new(),
1228            config_schema: SchemaIr::Bool,
1229            outcomes: Vec::new(),
1230        };
1231
1232        let first = compute_describe_hash(&describe).expect("first hash");
1233        let second = compute_describe_hash(&describe).expect("second hash");
1234        assert_eq!(first, second);
1235        assert_eq!(first.len(), 64);
1236    }
1237
1238    #[test]
1239    fn fallback_heuristics_only_trigger_for_known_errors() {
1240        let fallback = anyhow!("failed to instantiate component-v0-v6-v0 during describe");
1241        let other = anyhow!("totally different error");
1242
1243        assert!(should_fallback_to_describe_cache(&fallback));
1244        assert!(should_fallback_to_untyped_describe(&fallback));
1245        assert!(!should_fallback_to_describe_cache(&other));
1246        assert!(!should_fallback_to_untyped_describe(&other));
1247    }
1248
1249    #[test]
1250    fn load_pack_lock_returns_none_when_no_bytes_or_disk_file_exist() {
1251        let manifest = greentic_pack::builder::PackManifest {
1252            meta: greentic_pack::builder::PackMeta {
1253                pack_version: 1,
1254                pack_id: "demo.pack".to_string(),
1255                version: semver::Version::parse("0.1.0").expect("version"),
1256                name: "Demo".to_string(),
1257                kind: None,
1258                description: None,
1259                authors: Vec::new(),
1260                license: None,
1261                homepage: None,
1262                support: None,
1263                vendor: None,
1264                imports: Vec::new(),
1265                entry_flows: Vec::new(),
1266                created_at_utc: "2026-01-01T00:00:00Z".to_string(),
1267                events: None,
1268                repo: None,
1269                messaging: None,
1270                interfaces: Vec::new(),
1271                annotations: serde_json::Map::new(),
1272                distribution: None,
1273                components: Vec::new(),
1274            },
1275            flows: Vec::new(),
1276            components: Vec::new(),
1277            distribution: None,
1278            component_descriptors: Vec::new(),
1279        };
1280        let load = PackLoad {
1281            manifest,
1282            report: Default::default(),
1283            sbom: Vec::new(),
1284            files: HashMap::new(),
1285            gpack_manifest: None,
1286        };
1287        let temp = TempDir::new().expect("temp dir");
1288
1289        assert!(load_pack_lock(&load, None).expect("none").is_none());
1290        assert!(
1291            load_pack_lock(&load, Some(temp.path()))
1292                .expect("none")
1293                .is_none()
1294        );
1295    }
1296
1297    #[test]
1298    fn read_pack_lock_from_bytes_rejects_invalid_lock_documents() {
1299        let invalid = PackLockV1::new(BTreeMap::from([(
1300            "demo.component".to_string(),
1301            LockedComponent {
1302                component_id: "demo.component".to_string(),
1303                r#ref: None,
1304                abi_version: "0.6.0".to_string(),
1305                resolved_digest: "sha256:abc".to_string(),
1306                describe_hash: "not-a-real-hash".to_string(),
1307                operations: Vec::new(),
1308                world: None,
1309                component_version: None,
1310                role: None,
1311            },
1312        )]));
1313        let bytes = canonical::to_canonical_cbor_allow_floats(&invalid).expect("cbor");
1314
1315        let err = read_pack_lock_from_bytes(&bytes).expect_err("invalid lock should fail");
1316        assert!(err.to_string().contains("describe_hash"));
1317    }
1318
1319    #[test]
1320    fn validate_schema_ir_reports_missing_required_and_empty_variants() {
1321        let mut diagnostics = Vec::new();
1322        let mut has_errors = false;
1323
1324        validate_schema_ir(
1325            "demo.component",
1326            &SchemaIr::Object {
1327                properties: BTreeMap::new(),
1328                required: vec!["missing".to_string()],
1329                additional: AdditionalProperties::Forbid,
1330            },
1331            "config",
1332            &mut diagnostics,
1333            &mut has_errors,
1334        );
1335        validate_schema_ir(
1336            "demo.component",
1337            &SchemaIr::OneOf {
1338                variants: Vec::new(),
1339            },
1340            "config/choice",
1341            &mut diagnostics,
1342            &mut has_errors,
1343        );
1344        validate_schema_ir(
1345            "demo.component",
1346            &SchemaIr::Enum { values: Vec::new() },
1347            "config/enum",
1348            &mut diagnostics,
1349            &mut has_errors,
1350        );
1351
1352        assert!(has_errors);
1353        assert!(
1354            diagnostics
1355                .iter()
1356                .any(|diag| { diag.diagnostic.code == "PACK_LOCK_SCHEMA_REQUIRED_MISSING" })
1357        );
1358        assert!(
1359            diagnostics
1360                .iter()
1361                .any(|diag| { diag.diagnostic.code == "PACK_LOCK_SCHEMA_ONEOF_EMPTY" })
1362        );
1363        assert!(
1364            diagnostics
1365                .iter()
1366                .any(|diag| { diag.diagnostic.code == "PACK_LOCK_SCHEMA_ENUM_EMPTY" })
1367        );
1368    }
1369
1370    #[test]
1371    fn validate_schema_ir_reports_numeric_bound_inversions() {
1372        let mut diagnostics = Vec::new();
1373        let mut has_errors = false;
1374
1375        validate_schema_ir(
1376            "demo.component",
1377            &SchemaIr::Int {
1378                min: Some(10),
1379                max: Some(1),
1380            },
1381            "config/int",
1382            &mut diagnostics,
1383            &mut has_errors,
1384        );
1385        validate_schema_ir(
1386            "demo.component",
1387            &SchemaIr::Float {
1388                min: Some(4.0),
1389                max: Some(2.0),
1390            },
1391            "config/float",
1392            &mut diagnostics,
1393            &mut has_errors,
1394        );
1395
1396        assert!(has_errors);
1397        assert!(
1398            diagnostics
1399                .iter()
1400                .any(|diag| { diag.diagnostic.code == "PACK_LOCK_SCHEMA_INT_BOUNDS" })
1401        );
1402        assert!(
1403            diagnostics
1404                .iter()
1405                .any(|diag| { diag.diagnostic.code == "PACK_LOCK_SCHEMA_FLOAT_BOUNDS" })
1406        );
1407    }
1408}