Skip to main content

greentic_pack/validate/
mod.rs

1use std::collections::BTreeSet;
2use std::path::PathBuf;
3
4use greentic_types::ComponentId;
5use greentic_types::pack::extensions::component_manifests::{
6    ComponentManifestIndexV1, EXT_COMPONENT_MANIFEST_INDEX_V1,
7};
8use greentic_types::pack::extensions::component_sources::{
9    ComponentSourcesV1, EXT_COMPONENT_SOURCES_V1,
10};
11use greentic_types::pack_manifest::{ExtensionInline, PackManifest};
12const EXT_BUILD_MODE_ID: &str = "greentic.pack-mode.v1";
13use greentic_types::provider::ProviderDecl;
14use greentic_types::validate::{
15    Diagnostic, PackValidator, Severity, ValidationReport, validate_pack_manifest_core,
16};
17use serde_json::Value;
18
19use crate::PackLoad;
20
21#[derive(Clone, Debug, Default)]
22pub struct ValidateCtx {
23    pub pack_paths: BTreeSet<String>,
24    pub sbom_paths: BTreeSet<String>,
25    pub referenced_paths: BTreeSet<String>,
26    pub pack_root: Option<PathBuf>,
27    pub prod_build: bool,
28}
29
30impl ValidateCtx {
31    pub fn from_pack_load(load: &PackLoad) -> Self {
32        let prod_build = is_production_pack(load);
33        let pack_paths = load.files.keys().cloned().collect();
34        let sbom_paths = load.sbom.iter().map(|entry| entry.path.clone()).collect();
35
36        let mut referenced_paths = BTreeSet::new();
37        for flow in &load.manifest.flows {
38            referenced_paths.insert(flow.file_yaml.clone());
39            referenced_paths.insert(flow.file_json.clone());
40        }
41        for component in &load.manifest.components {
42            referenced_paths.insert(component.file_wasm.clone());
43            if let Some(schema_file) = component.schema_file.as_ref() {
44                referenced_paths.insert(schema_file.clone());
45            }
46            if let Some(manifest_file) = component.manifest_file.as_ref() {
47                referenced_paths.insert(manifest_file.clone());
48            }
49        }
50        if let Some(manifest) = load.gpack_manifest.as_ref()
51            && let Some(value) = manifest
52                .extensions
53                .as_ref()
54                .and_then(|exts| exts.get(EXT_COMPONENT_MANIFEST_INDEX_V1))
55                .and_then(|ext| ext.inline.as_ref())
56                .and_then(|inline| match inline {
57                    ExtensionInline::Other(value) => Some(value),
58                    _ => None,
59                })
60            && let Ok(index) = ComponentManifestIndexV1::from_extension_value(value)
61        {
62            for entry in index.entries {
63                referenced_paths.insert(entry.manifest_file);
64            }
65        }
66
67        Self {
68            pack_paths,
69            sbom_paths,
70            referenced_paths,
71            pack_root: None,
72            prod_build,
73        }
74    }
75}
76
77pub fn run_validators(
78    manifest: &PackManifest,
79    _ctx: &ValidateCtx,
80    validators: &[Box<dyn PackValidator>],
81) -> ValidationReport {
82    let mut report = ValidationReport {
83        pack_id: Some(manifest.pack_id.clone()),
84        pack_version: Some(manifest.version.clone()),
85        diagnostics: Vec::new(),
86    };
87
88    report
89        .diagnostics
90        .extend(validate_pack_manifest_core(manifest));
91
92    for validator in validators {
93        if validator.applies(manifest) {
94            report.diagnostics.extend(validator.validate(manifest));
95        }
96    }
97
98    report
99}
100
101#[derive(Clone, Debug)]
102pub struct ReferencedFilesExistValidator {
103    ctx: ValidateCtx,
104}
105
106impl ReferencedFilesExistValidator {
107    pub fn new(ctx: ValidateCtx) -> Self {
108        Self { ctx }
109    }
110}
111
112impl PackValidator for ReferencedFilesExistValidator {
113    fn id(&self) -> &'static str {
114        "pack.referenced-files-exist"
115    }
116
117    fn applies(&self, _manifest: &PackManifest) -> bool {
118        true
119    }
120
121    fn validate(&self, _manifest: &PackManifest) -> Vec<Diagnostic> {
122        let mut diagnostics = Vec::new();
123
124        for path in &self.ctx.referenced_paths {
125            if self.ctx.prod_build && is_flow_source_path(path) {
126                continue;
127            }
128            let missing_in_pack = !self.ctx.pack_paths.contains(path);
129            let missing_in_sbom = !self.ctx.sbom_paths.contains(path);
130
131            if missing_in_pack || missing_in_sbom {
132                let message = if missing_in_pack && missing_in_sbom {
133                    "Referenced file is missing from the pack archive and SBOM."
134                } else if missing_in_pack {
135                    "Referenced file is missing from the pack archive."
136                } else {
137                    "Referenced file is missing from the SBOM."
138                };
139                diagnostics.push(missing_file_diagnostic(
140                    "PACK_MISSING_FILE",
141                    message,
142                    Some(path.clone()),
143                ));
144            }
145        }
146
147        diagnostics
148    }
149}
150
151#[derive(Clone, Debug)]
152pub struct SbomConsistencyValidator {
153    ctx: ValidateCtx,
154}
155
156impl SbomConsistencyValidator {
157    pub fn new(ctx: ValidateCtx) -> Self {
158        Self { ctx }
159    }
160}
161
162impl PackValidator for SbomConsistencyValidator {
163    fn id(&self) -> &'static str {
164        "pack.sbom-consistency"
165    }
166
167    fn applies(&self, _manifest: &PackManifest) -> bool {
168        true
169    }
170
171    fn validate(&self, _manifest: &PackManifest) -> Vec<Diagnostic> {
172        let mut diagnostics = Vec::new();
173        let manifest_path = "manifest.cbor";
174
175        if !self.ctx.pack_paths.contains(manifest_path)
176            || !self.ctx.sbom_paths.contains(manifest_path)
177        {
178            diagnostics.push(Diagnostic {
179                severity: Severity::Error,
180                code: "PACK_MISSING_MANIFEST_CBOR".to_string(),
181                message: "manifest.cbor must be present in the pack and listed in the SBOM."
182                    .to_string(),
183                path: Some(manifest_path.to_string()),
184                hint: Some(
185                    "Rebuild the pack so manifest.cbor is included in the SBOM.".to_string(),
186                ),
187                data: Value::Null,
188            });
189        }
190
191        for path in &self.ctx.sbom_paths {
192            if !self.ctx.pack_paths.contains(path) {
193                diagnostics.push(Diagnostic {
194                    severity: Severity::Error,
195                    code: "PACK_SBOM_DANGLING_PATH".to_string(),
196                    message: "SBOM entry references a path missing from the pack archive."
197                        .to_string(),
198                    path: Some(path.clone()),
199                    hint: Some("Remove stale SBOM entries or rebuild the pack.".to_string()),
200                    data: Value::Null,
201                });
202            }
203        }
204
205        diagnostics
206    }
207}
208
209#[derive(Clone, Debug)]
210pub struct ProviderReferencesExistValidator {
211    ctx: ValidateCtx,
212}
213
214impl ProviderReferencesExistValidator {
215    pub fn new(ctx: ValidateCtx) -> Self {
216        Self { ctx }
217    }
218}
219
220impl PackValidator for ProviderReferencesExistValidator {
221    fn id(&self) -> &'static str {
222        "pack.provider-references-exist"
223    }
224
225    fn applies(&self, manifest: &PackManifest) -> bool {
226        manifest.provider_extension_inline().is_some()
227    }
228
229    fn validate(&self, manifest: &PackManifest) -> Vec<Diagnostic> {
230        let mut diagnostics = Vec::new();
231
232        for provider in providers_from_manifest(manifest) {
233            let config_path = provider.config_schema_ref.as_str();
234            if !config_path.is_empty() {
235                diagnostics.extend(check_pack_path(
236                    &self.ctx,
237                    config_path,
238                    "provider config schema",
239                ));
240            }
241            if let Some(docs_path) = provider.docs_ref.as_deref()
242                && !docs_path.is_empty()
243            {
244                diagnostics.extend(check_pack_path(&self.ctx, docs_path, "provider docs"));
245            }
246        }
247
248        diagnostics
249    }
250}
251
252#[derive(Clone, Debug)]
253pub struct SecretRequirementsValidator;
254
255impl PackValidator for SecretRequirementsValidator {
256    fn id(&self) -> &'static str {
257        "pack.secret-requirements-invalid"
258    }
259
260    fn applies(&self, manifest: &PackManifest) -> bool {
261        !manifest.secret_requirements.is_empty()
262    }
263
264    fn validate(&self, manifest: &PackManifest) -> Vec<Diagnostic> {
265        let mut diagnostics = Vec::new();
266        for (idx, requirement) in manifest.secret_requirements.iter().enumerate() {
267            let key = requirement.key.as_str();
268            if requirement.scope.is_none() {
269                diagnostics.push(Diagnostic {
270                    severity: Severity::Error,
271                    code: "PACK_SECRET_REQUIREMENTS_INVALID".to_string(),
272                    message: format!("secret requirement `{}` is missing a scope", key),
273                    path: Some(format!("secretRequirements[{idx}]")),
274                    hint: Some(
275                        "Provide env/tenant values in secretRequirements or pass --default-secret-scope when building."
276                            .to_string(),
277                    ),
278                    data: Value::Null,
279                });
280                continue;
281            }
282            let scope = requirement.scope.as_ref().unwrap();
283            if scope.env.trim().is_empty() {
284                diagnostics.push(Diagnostic {
285                    severity: Severity::Error,
286                    code: "PACK_SECRET_REQUIREMENTS_INVALID".to_string(),
287                    message: format!("secret requirement `{}` has an empty env scope", key),
288                    path: Some(format!("secretRequirements[{idx}].scope.env")),
289                    hint: Some(
290                        "Ensure the secret scope includes a valid environment identifier."
291                            .to_string(),
292                    ),
293                    data: Value::Null,
294                });
295            }
296            if scope.tenant.trim().is_empty() {
297                diagnostics.push(Diagnostic {
298                    severity: Severity::Error,
299                    code: "PACK_SECRET_REQUIREMENTS_INVALID".to_string(),
300                    message: format!("secret requirement `{}` has an empty tenant scope", key),
301                    path: Some(format!("secretRequirements[{idx}].scope.tenant")),
302                    hint: Some(
303                        "Ensure the secret scope includes a valid tenant identifier.".to_string(),
304                    ),
305                    data: Value::Null,
306                });
307            }
308        }
309        diagnostics
310    }
311}
312
313#[derive(Clone, Debug, Default)]
314pub struct ComponentReferencesExistValidator;
315
316impl PackValidator for ComponentReferencesExistValidator {
317    fn id(&self) -> &'static str {
318        "pack.component-references-exist"
319    }
320
321    fn applies(&self, _manifest: &PackManifest) -> bool {
322        true
323    }
324
325    fn validate(&self, manifest: &PackManifest) -> Vec<Diagnostic> {
326        let mut known: BTreeSet<ComponentId> = BTreeSet::new();
327        for component in &manifest.components {
328            known.insert(component.id.clone());
329        }
330
331        let mut source_ids: BTreeSet<ComponentId> = BTreeSet::new();
332        if let Some(value) = manifest
333            .extensions
334            .as_ref()
335            .and_then(|exts| exts.get(EXT_COMPONENT_SOURCES_V1))
336            .and_then(|ext| ext.inline.as_ref())
337            .and_then(|inline| match inline {
338                ExtensionInline::Other(value) => Some(value),
339                _ => None,
340            })
341            && let Ok(cs) = ComponentSourcesV1::from_extension_value(value)
342        {
343            for entry in cs.components {
344                if let Some(id) = entry.component_id {
345                    source_ids.insert(id);
346                }
347            }
348        }
349
350        let mut diagnostics = Vec::new();
351        for flow in &manifest.flows {
352            for (node_id, node) in &flow.flow.nodes {
353                if node.component.pack_alias.is_some() {
354                    continue;
355                }
356                let component_id = &node.component.id;
357                if !known.contains(component_id) && !source_ids.contains(component_id) {
358                    diagnostics.push(Diagnostic {
359                        severity: Severity::Error,
360                        code: "PACK_MISSING_COMPONENT_REFERENCE".to_string(),
361                        message: format!(
362                            "Flow references component '{}' missing from manifest/component sources.",
363                            component_id
364                        ),
365                        path: Some(format!(
366                            "flows.{}.nodes.{}.component",
367                            flow.id.as_str(),
368                            node_id.as_str()
369                        )),
370                        hint: Some(
371                            "Add the component to the pack manifest or component sources."
372                                .to_string(),
373                        ),
374                        data: Value::Null,
375                    });
376                }
377            }
378        }
379
380        diagnostics
381    }
382}
383
384fn providers_from_manifest(manifest: &PackManifest) -> Vec<ProviderDecl> {
385    let mut providers = manifest
386        .provider_extension_inline()
387        .map(|inline| inline.providers.clone())
388        .unwrap_or_default();
389    providers.sort_by(|a, b| a.provider_type.cmp(&b.provider_type));
390    providers
391}
392
393fn check_pack_path(ctx: &ValidateCtx, path: &str, label: &str) -> Vec<Diagnostic> {
394    if path.trim().is_empty() {
395        return Vec::new();
396    }
397
398    let mut diagnostics = Vec::new();
399    if !ctx.pack_paths.contains(path) {
400        diagnostics.push(missing_file_diagnostic(
401            "PACK_MISSING_FILE",
402            &format!("{label} is missing from the pack archive."),
403            Some(path.to_string()),
404        ));
405    } else if !ctx.sbom_paths.contains(path) {
406        diagnostics.push(missing_file_diagnostic(
407            "PACK_MISSING_FILE",
408            &format!("{label} is missing from the SBOM."),
409            Some(path.to_string()),
410        ));
411    }
412
413    diagnostics
414}
415
416fn is_flow_source_path(path: &str) -> bool {
417    path.starts_with("flows/") && (path.ends_with(".ygtc") || path.ends_with(".json"))
418}
419
420fn is_production_pack(load: &PackLoad) -> bool {
421    if let Some(manifest) = load.gpack_manifest.as_ref()
422        && let Some(extension) = manifest
423            .extensions
424            .as_ref()
425            .and_then(|map| map.get(EXT_BUILD_MODE_ID))
426        && let Some(ExtensionInline::Other(value)) = extension.inline.as_ref()
427        && let Some(mode) = value.get("mode").and_then(|value| value.as_str())
428    {
429        return !mode.eq_ignore_ascii_case("dev");
430    }
431    !load.files.keys().any(|path| path.ends_with(".ygtc"))
432}
433
434fn missing_file_diagnostic(code: &str, message: &str, path: Option<String>) -> Diagnostic {
435    Diagnostic {
436        severity: Severity::Error,
437        code: code.to_string(),
438        message: message.to_string(),
439        path,
440        hint: None,
441        data: Value::Null,
442    }
443}