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}