plecto_control/
diagnostic.rs1use plecto_host::LoadError;
15
16use crate::ControlError;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct Diagnostic {
22 pub code: &'static str,
23 pub cause: &'static str,
24 pub suggestion: &'static str,
25 pub docs: &'static str,
26}
27
28impl std::fmt::Display for Diagnostic {
29 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 write!(
31 f,
32 "{}: {}\n suggestion: {}\n docs: {}",
33 self.code, self.cause, self.suggestion, self.docs
34 )
35 }
36}
37
38pub const SIGNATURE_VERIFICATION_FAILED: Diagnostic = Diagnostic {
40 code: "PLECTO-E0001",
41 cause: "the component or SBOM signature does not verify against any key in the manifest's [trust]",
42 suggestion: "sign with a key listed under [trust] (cosign sign-blob or your CI's signer); for \
43 local dev, run `plecto dev`, which signs with your project's .plecto/dev-key automatically",
44 docs: "docs/ADR/000006.md",
45};
46
47pub const QUOTA_EXCEEDED: Diagnostic = Diagnostic {
49 code: "PLECTO-E0002",
50 cause: "the request exceeded the filter's host-native rate-limit bucket",
51 suggestion: "raise [filter.ratelimit] capacity/refill_per_sec in the manifest, or have the \
52 client back off using the retry-after-ms header on the 429",
53 docs: "docs/ADR/000026.md",
54};
55
56pub const PATH_NORMALIZATION_REJECTED: Diagnostic = Diagnostic {
58 code: "PLECTO-E0003",
59 cause: "the request path failed normalization (e.g. `..` traversal, invalid percent-encoding, \
60 or a raw control byte)",
61 suggestion: "this rejects the client's request, not the manifest; if a legitimate client hits \
62 this, check what it is sending for the path",
63 docs: "docs/ADR/000013.md",
64};
65
66pub const DEV_KEY_IN_TRUST: Diagnostic = Diagnostic {
70 code: "PLECTO-E0004",
71 cause: "a [trust] key file carries the plecto-dev-key marker — it was generated by `plecto \
72 dev`/`plecto new-filter`, not a production signing key",
73 suggestion: "if this manifest is meant for production, replace the key with one from your \
74 real signing pipeline; if it's a dev manifest, this warning is expected",
75 docs: "docs/ADR/000065.md",
76};
77
78pub fn diagnose(err: &ControlError) -> Option<Diagnostic> {
81 match err {
82 ControlError::Load { err, .. } => match err.downcast_ref::<LoadError>() {
83 Some(LoadError::UnverifiedComponentSignature | LoadError::UnverifiedSbomSignature) => {
84 Some(SIGNATURE_VERIFICATION_FAILED)
85 }
86 _ => None,
87 },
88 _ => None,
89 }
90}
91
92pub fn diagnosed_message(err: &ControlError) -> String {
96 match diagnose(err) {
97 Some(diagnostic) => format!("{err}\n{diagnostic}"),
98 None => err.to_string(),
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 #[test]
107 fn unverified_component_signature_maps_to_e0001() {
108 let err = ControlError::Load {
109 id: "f".to_string(),
110 err: anyhow::Error::from(LoadError::UnverifiedComponentSignature),
111 };
112 assert_eq!(diagnose(&err), Some(SIGNATURE_VERIFICATION_FAILED));
113 }
114
115 #[test]
116 fn unrelated_control_errors_get_no_diagnostic() {
117 let err = ControlError::DuplicateFilterId("f".to_string());
118 assert_eq!(diagnose(&err), None);
119 }
120
121 #[test]
122 fn display_renders_all_four_parts() {
123 let rendered = SIGNATURE_VERIFICATION_FAILED.to_string();
124 assert!(rendered.contains("PLECTO-E0001"));
125 assert!(rendered.contains("suggestion:"));
126 assert!(rendered.contains("docs:"));
127 }
128
129 #[test]
130 fn diagnosed_message_appends_the_diagnostic_only_when_one_is_registered() {
131 let signature = ControlError::Load {
132 id: "f".to_string(),
133 err: anyhow::Error::from(LoadError::UnverifiedComponentSignature),
134 };
135 let rendered = diagnosed_message(&signature);
136 assert!(rendered.contains("failed the load gate"));
137 assert!(rendered.contains("PLECTO-E0001"));
138 assert!(rendered.contains("suggestion:"));
139
140 let plain = ControlError::DuplicateFilterId("f".to_string());
141 assert_eq!(diagnosed_message(&plain), plain.to_string());
142 }
143}