Skip to main content

greentic_secrets_validate/
lib.rs

1#![forbid(unsafe_code)]
2#![deny(missing_docs)]
3
4//! Secrets domain pack validators.
5
6use greentic_types_validate::{
7    Diagnostic, ExtensionInline, PackManifest, PackValidator, ProviderExtensionInline, Severity,
8};
9use serde_json::Value;
10
11const SECRET_REQUIREMENTS_PATHS: [&str; 2] = [
12    "assets/secret-requirements.json",
13    "assets/secret_requirements.json",
14];
15
16/// Returns the secrets-domain validators for Greentic packs.
17pub fn secrets_validators() -> Vec<Box<dyn PackValidator>> {
18    vec![
19        Box::new(SecretRequirementsDeclValidator),
20        Box::new(SecretRequirementsWellFormedValidator),
21        Box::new(SecretKeyFormatValidator),
22    ]
23}
24
25/// Returns true if the manifest appears to describe a secrets pack.
26pub fn is_secrets_pack(manifest: &PackManifest) -> bool {
27    pack_id_starts_with_secrets(manifest)
28        || manifest_references_secret_requirements(manifest)
29        || providers_hint_secrets(manifest)
30}
31
32fn pack_id_starts_with_secrets(manifest: &PackManifest) -> bool {
33    manifest
34        .pack_id
35        .as_str()
36        .to_ascii_lowercase()
37        .starts_with("secrets-")
38}
39
40fn manifest_references_secret_requirements(manifest: &PackManifest) -> bool {
41    if !manifest.secret_requirements.is_empty() {
42        return true;
43    }
44    let Some(extensions) = manifest.extensions.as_ref() else {
45        return false;
46    };
47    for (key, extension) in extensions {
48        if SECRET_REQUIREMENTS_PATHS
49            .iter()
50            .any(|path| key.contains(path) || extension.kind.contains(path))
51        {
52            return true;
53        }
54        if let Some(location) = extension.location.as_ref()
55            && SECRET_REQUIREMENTS_PATHS
56                .iter()
57                .any(|path| location.contains(path))
58        {
59            return true;
60        }
61        if let Some(inline) = extension.inline.as_ref()
62            && inline_mentions_secret_requirements(inline)
63        {
64            return true;
65        }
66    }
67    false
68}
69
70fn inline_mentions_secret_requirements(inline: &ExtensionInline) -> bool {
71    match inline {
72        ExtensionInline::Provider(_) => false,
73        ExtensionInline::Other(value) => value_mentions_secret_requirements(value),
74    }
75}
76
77fn value_mentions_secret_requirements(value: &Value) -> bool {
78    match value {
79        Value::String(text) => SECRET_REQUIREMENTS_PATHS
80            .iter()
81            .any(|path| text.contains(path)),
82        Value::Array(items) => items.iter().any(value_mentions_secret_requirements),
83        Value::Object(map) => map.values().any(value_mentions_secret_requirements),
84        _ => false,
85    }
86}
87
88fn providers_hint_secrets(manifest: &PackManifest) -> bool {
89    manifest
90        .provider_extension_inline()
91        .map(provider_extension_mentions_secrets)
92        .unwrap_or(false)
93}
94
95fn provider_extension_mentions_secrets(inline: &ProviderExtensionInline) -> bool {
96    inline.providers.iter().any(provider_decl_mentions_secrets)
97}
98
99fn provider_decl_mentions_secrets(provider: &greentic_types_validate::ProviderDecl) -> bool {
100    let mut fields = Vec::new();
101    fields.push(provider.provider_type.as_str());
102    fields.push(provider.config_schema_ref.as_str());
103    if let Some(state_schema_ref) = provider.state_schema_ref.as_ref() {
104        fields.push(state_schema_ref.as_str());
105    }
106    if let Some(docs_ref) = provider.docs_ref.as_ref() {
107        fields.push(docs_ref.as_str());
108    }
109    fields.push(provider.runtime.world.as_str());
110    fields.push(provider.runtime.component_ref.as_str());
111
112    fields
113        .into_iter()
114        .any(|value| value.to_ascii_lowercase().contains("secrets"))
115}
116
117fn secrets_required_hint(manifest: &PackManifest) -> bool {
118    is_secrets_pack(manifest)
119        || !manifest.secret_requirements.is_empty()
120        || manifest
121            .capabilities
122            .iter()
123            .any(|cap| cap.name.to_ascii_lowercase().contains("secret"))
124}
125
126struct SecretRequirementsDeclValidator;
127
128impl PackValidator for SecretRequirementsDeclValidator {
129    fn id(&self) -> &'static str {
130        "secrets.requirements.decl"
131    }
132
133    fn applies(&self, manifest: &PackManifest) -> bool {
134        secrets_required_hint(manifest)
135    }
136
137    fn validate(&self, manifest: &PackManifest) -> Vec<Diagnostic> {
138        if manifest_references_secret_requirements(manifest) {
139            return Vec::new();
140        }
141        vec![diagnostic(
142            Severity::Warn,
143            "SEC_REQUIREMENTS_NOT_DISCOVERABLE",
144            "Secrets are required but no secret requirements reference is discoverable.",
145            Some("secret_requirements".to_owned()),
146            Some(
147                "Include assets/secret-requirements.json or embed secret requirements.".to_owned(),
148            ),
149        )]
150    }
151}
152
153struct SecretRequirementsWellFormedValidator;
154
155impl PackValidator for SecretRequirementsWellFormedValidator {
156    fn id(&self) -> &'static str {
157        "secrets.requirements.well_formed"
158    }
159
160    fn applies(&self, manifest: &PackManifest) -> bool {
161        secrets_required_hint(manifest)
162    }
163
164    fn validate(&self, manifest: &PackManifest) -> Vec<Diagnostic> {
165        if manifest.secret_requirements.is_empty() {
166            return vec![diagnostic(
167                Severity::Info,
168                "SEC_REQ_PARSE_NEEDS_PACK_ACCESS",
169                "Secret requirements parse checks require pack file access.",
170                Some("secret_requirements".to_owned()),
171                Some(
172                    "Provide secret requirements in the manifest or validate with pack bytes."
173                        .to_owned(),
174                ),
175            )];
176        }
177
178        let mut diagnostics = Vec::new();
179        for (idx, req) in manifest.secret_requirements.iter().enumerate() {
180            if req.key.as_str().trim().is_empty() {
181                diagnostics.push(diagnostic(
182                    Severity::Error,
183                    "SEC_REQ_MISSING_KEY",
184                    "Secret requirement is missing a key.",
185                    Some(format!("secret_requirements.{idx}.key")),
186                    Some("Provide a non-empty key for each secret requirement.".to_owned()),
187                ));
188            }
189        }
190        diagnostics
191    }
192}
193
194struct SecretKeyFormatValidator;
195
196impl PackValidator for SecretKeyFormatValidator {
197    fn id(&self) -> &'static str {
198        "secrets.requirements.key_format"
199    }
200
201    fn applies(&self, manifest: &PackManifest) -> bool {
202        !manifest.secret_requirements.is_empty()
203    }
204
205    fn validate(&self, manifest: &PackManifest) -> Vec<Diagnostic> {
206        let mut diagnostics = Vec::new();
207        for (idx, req) in manifest.secret_requirements.iter().enumerate() {
208            let key = req.key.as_str();
209            if key.trim().is_empty() {
210                continue;
211            }
212            if is_upper_snake(key) || key.starts_with("greentic://") {
213                continue;
214            }
215            diagnostics.push(diagnostic(
216                Severity::Warn,
217                "SEC_BAD_KEY_FORMAT",
218                "Secret requirement key format should be UPPER_SNAKE or greentic:// URI.",
219                Some(format!("secret_requirements.{idx}.key")),
220                Some("Rename the key to UPPER_SNAKE or a greentic:// URI.".to_owned()),
221            ));
222        }
223        diagnostics
224    }
225}
226
227fn is_upper_snake(value: &str) -> bool {
228    !value.is_empty()
229        && value
230            .chars()
231            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
232}
233
234fn diagnostic(
235    severity: Severity,
236    code: &str,
237    message: &str,
238    path: Option<String>,
239    hint: Option<String>,
240) -> Diagnostic {
241    Diagnostic {
242        severity,
243        code: code.to_owned(),
244        message: message.to_owned(),
245        path,
246        hint,
247        data: Value::Null,
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use greentic_types_validate::{
255        ExtensionRef, PROVIDER_EXTENSION_ID, PackId, PackKind, PackSignatures, ProviderDecl,
256        ProviderRuntimeRef,
257    };
258    use semver::Version;
259    use serde::Deserialize;
260    use std::collections::BTreeMap;
261
262    #[derive(Debug, Deserialize)]
263    struct SecretRequirementDecl {
264        #[serde(default)]
265        key: Option<String>,
266        #[serde(default)]
267        name: Option<String>,
268        #[serde(default)]
269        id: Option<String>,
270        #[serde(default)]
271        sensitive: Option<bool>,
272        #[serde(default)]
273        redact: Option<bool>,
274    }
275
276    impl SecretRequirementDecl {
277        fn key_name(&self) -> Option<&str> {
278            self.key
279                .as_deref()
280                .or(self.name.as_deref())
281                .or(self.id.as_deref())
282        }
283
284        fn explicit_sensitivity(&self) -> Option<bool> {
285            self.sensitive.or(self.redact)
286        }
287    }
288
289    fn validate_secret_requirement_decls(
290        decls: &[SecretRequirementDecl],
291        implicit_sensitive: bool,
292    ) -> Vec<Diagnostic> {
293        let mut diagnostics = Vec::new();
294        for (idx, decl) in decls.iter().enumerate() {
295            let key = decl.key_name().unwrap_or_default();
296            if key.trim().is_empty() {
297                diagnostics.push(diagnostic(
298                    Severity::Error,
299                    "SEC_REQ_MISSING_KEY",
300                    "Secret requirement is missing a key.",
301                    Some(format!("secret_requirements.{idx}.key")),
302                    Some("Provide a non-empty key/name for each requirement.".to_owned()),
303                ));
304                continue;
305            }
306            match decl.explicit_sensitivity() {
307                Some(false) => diagnostics.push(diagnostic(
308                    Severity::Error,
309                    "SEC_REQ_EXPLICITLY_NOT_SENSITIVE",
310                    "Secret requirement explicitly marks non-sensitive data.",
311                    Some(format!("secret_requirements.{idx}.sensitive")),
312                    Some("Remove the explicit false or mark secrets as sensitive.".to_owned()),
313                )),
314                Some(true) => {}
315                None if !implicit_sensitive => diagnostics.push(diagnostic(
316                    Severity::Error,
317                    "SEC_REQ_NOT_SENSITIVE",
318                    "Secret requirement is not marked sensitive.",
319                    Some(format!("secret_requirements.{idx}.sensitive")),
320                    Some("Mark secrets as sensitive or use a secrets-only structure.".to_owned()),
321                )),
322                None => {}
323            }
324        }
325        diagnostics
326    }
327
328    fn base_manifest(pack_id: &str) -> PackManifest {
329        PackManifest {
330            schema_version: "pack-v1".to_owned(),
331            pack_id: pack_id.parse::<PackId>().expect("pack id"),
332            name: None,
333            version: Version::parse("0.1.0").expect("version"),
334            kind: PackKind::Application,
335            publisher: "greentic".to_owned(),
336            components: Vec::new(),
337            flows: Vec::new(),
338            dependencies: Vec::new(),
339            capabilities: Vec::new(),
340            secret_requirements: Vec::new(),
341            signatures: PackSignatures::default(),
342            bootstrap: None,
343            extensions: None,
344        }
345    }
346
347    #[test]
348    fn detects_secrets_pack_by_id_prefix() {
349        let manifest = base_manifest("secrets-demo");
350        assert!(is_secrets_pack(&manifest));
351    }
352
353    #[test]
354    fn warns_when_secrets_requirements_missing() {
355        let mut manifest = base_manifest("vendor.demo.pack");
356        let provider = ProviderDecl {
357            provider_type: "demo".to_owned(),
358            capabilities: Vec::new(),
359            ops: Vec::new(),
360            config_schema_ref: "assets/schemas/secrets/demo/config.schema.json".to_owned(),
361            state_schema_ref: None,
362            runtime: ProviderRuntimeRef {
363                component_ref: "component".to_owned(),
364                export: "invoke".to_owned(),
365                world: "greentic:provider-schema-core/schema-core@1.0.0".to_owned(),
366            },
367            docs_ref: None,
368        };
369        let inline = ProviderExtensionInline {
370            providers: vec![provider],
371            additional_fields: BTreeMap::new(),
372        };
373        let mut extensions = BTreeMap::new();
374        extensions.insert(
375            PROVIDER_EXTENSION_ID.to_owned(),
376            ExtensionRef {
377                kind: PROVIDER_EXTENSION_ID.to_owned(),
378                version: "1.0.0".to_owned(),
379                digest: None,
380                location: None,
381                inline: Some(ExtensionInline::Provider(inline)),
382            },
383        );
384        manifest.extensions = Some(extensions);
385
386        let validator = SecretRequirementsDeclValidator;
387        let diagnostics = validator.validate(&manifest);
388        assert!(
389            diagnostics
390                .iter()
391                .any(|diag| diag.code == "SEC_REQUIREMENTS_NOT_DISCOVERABLE")
392        );
393    }
394
395    #[test]
396    fn detects_missing_key() {
397        let raw = r#"[{"sensitive": true}]"#;
398        let decls: Vec<SecretRequirementDecl> = serde_json::from_str(raw).expect("parse");
399        let diagnostics = validate_secret_requirement_decls(&decls, true);
400        assert!(
401            diagnostics
402                .iter()
403                .any(|diag| diag.code == "SEC_REQ_MISSING_KEY")
404        );
405    }
406
407    #[test]
408    fn detects_explicit_not_sensitive() {
409        let raw = r#"[{"key": "API_KEY", "sensitive": false}]"#;
410        let decls: Vec<SecretRequirementDecl> = serde_json::from_str(raw).expect("parse");
411        let diagnostics = validate_secret_requirement_decls(&decls, true);
412        assert!(
413            diagnostics
414                .iter()
415                .any(|diag| diag.code == "SEC_REQ_EXPLICITLY_NOT_SENSITIVE")
416        );
417    }
418
419    #[test]
420    fn accepts_valid_sensitive_requirement() {
421        let raw = r#"[{"key": "API_KEY", "sensitive": true}]"#;
422        let decls: Vec<SecretRequirementDecl> = serde_json::from_str(raw).expect("parse");
423        let diagnostics = validate_secret_requirement_decls(&decls, true);
424        assert!(diagnostics.is_empty());
425    }
426
427    #[test]
428    fn detects_secret_requirements_from_extension_location() {
429        let mut manifest = base_manifest("vendor.demo.pack");
430        let mut extensions = BTreeMap::new();
431        extensions.insert(
432            "vendor.secret-req".to_owned(),
433            ExtensionRef {
434                kind: "vendor.secret-req".to_owned(),
435                version: "1.0.0".to_owned(),
436                digest: None,
437                location: Some("assets/secret-requirements.json".to_owned()),
438                inline: None,
439            },
440        );
441        manifest.extensions = Some(extensions);
442
443        assert!(manifest_references_secret_requirements(&manifest));
444        assert!(is_secrets_pack(&manifest));
445    }
446
447    #[test]
448    fn detects_secret_requirements_from_unknown_inline_payload() {
449        let mut manifest = base_manifest("vendor.demo.pack");
450        let mut extensions = BTreeMap::new();
451        extensions.insert(
452            "vendor.secret-req".to_owned(),
453            ExtensionRef {
454                kind: "vendor.secret-req".to_owned(),
455                version: "1.0.0".to_owned(),
456                digest: None,
457                location: None,
458                inline: Some(ExtensionInline::Other(serde_json::json!({
459                    "path": "assets/secret_requirements.json"
460                }))),
461            },
462        );
463        manifest.extensions = Some(extensions);
464
465        assert!(manifest_references_secret_requirements(&manifest));
466    }
467
468    #[test]
469    fn key_format_validator_accepts_greentic_uri_keys() {
470        let mut manifest = base_manifest("vendor.demo.pack");
471        let mut requirement = greentic_types_validate::SecretRequirement::default();
472        requirement.key = "greentic://tenant/configs/db".into();
473        manifest.secret_requirements.push(requirement);
474
475        let diagnostics = SecretKeyFormatValidator.validate(&manifest);
476        assert!(diagnostics.is_empty());
477    }
478
479    #[test]
480    fn validators_are_registered_in_expected_order() {
481        let ids: Vec<_> = secrets_validators()
482            .into_iter()
483            .map(|validator| validator.id())
484            .collect();
485        assert_eq!(
486            ids,
487            vec![
488                "secrets.requirements.decl",
489                "secrets.requirements.well_formed",
490                "secrets.requirements.key_format"
491            ]
492        );
493    }
494
495    #[test]
496    fn well_formed_validator_reports_pack_access_requirement_without_embedded_requirements() {
497        let manifest = base_manifest("vendor.demo.pack");
498        let diagnostics = SecretRequirementsWellFormedValidator.validate(&manifest);
499        assert!(
500            diagnostics
501                .iter()
502                .any(|diag| diag.code == "SEC_REQ_PARSE_NEEDS_PACK_ACCESS")
503        );
504    }
505
506    #[test]
507    fn secrets_required_hint_detects_secret_capability_names() {
508        let mut manifest = base_manifest("vendor.demo.pack");
509        manifest
510            .capabilities
511            .push(greentic_types_validate::ComponentCapability {
512                name: "secret-sync".into(),
513                description: None,
514            });
515
516        assert!(secrets_required_hint(&manifest));
517    }
518}